Skip to content
SHC Docs

Quota module

The quota module enforces per-tenant (and optionally per-env, per-stack) limits on compute, storage, and object counts, plus the usage bookkeeping that backs those checks.

Source: modules/quota/ + the enforcement hooks called from install_flow and update_flow.

  1. Read limits from config, scoped to (tenant, env?, stack?).
  2. Track current usage in the usages table (UsageModel), one row per scope.
  3. Enforce pre-flight on stack create, scale, and upgrade — reject if delta would exceed the limit.
  4. Reconcile periodically so drift between the DB counters and reality (containers killed by the kernel, volumes deleted out-of-band) heals automatically.
  5. Enforce node/pool capacity separately — a stack that fits in tenant quota but not on any node still gets rejected.
  6. Expose CLI/API to operators to set limits, view usage, and force re-scans.

QuotaLimits (types.go):

FieldUnitWhat it caps
cpu_millicoresmillicoresSum of deploy.resources.limits.cpus * replicas
memory_bytesbytesSum of deploy.resources.limits.memory * replicas
disk_bytesbytesVolume + backup storage footprint
max_stackscountTop-level stacks (not vApp instances)
max_environmentscountDistinct environment values in use
max_appscountStacks + vApp instances
max_vapp_instancescountvApp instances only

All limits are None-able — a None limit is “unenforced”. SHC ships with no default quotas; nothing is limited until an operator writes a quota: block.

Quota checks traverse three scopes, most-specific first. A violation at any scope rejects the operation:

tenant ← always checked
└── tenant/env ← checked if env is set on ctx
└── tenant/env/stack ← checked if stack is set on ctx

Usage rows (UsageModel) are keyed by (tenant_name, environment, stack_name) with NULL environment/stack meaning “tenant roll-up” and NULL stack only meaning “tenant/env roll-up”. The unique constraint uq_usage_scope prevents duplicates.

The composite key scope_name renders as:

  • acme — tenant only
  • acme/env:staging — tenant + env
  • acme/blog — tenant + stack
  • acme/blog/default — tenant + stack + env

QuotaService.get_limits(ctx) reads config.get("quota") — i.e. the resolved config at the ctx’s scope, which lets operators set:

# global default (/etc/shc/config.yaml)
quota:
cpu: "4"
memory: "8g"
max_stacks: 10

…and then override per tenant via shc config set … --scope tenant. Config resolution is documented in reference/configuration.

Multipliers on top of that let an app or an operator widen the reservation ↔ limit gap:

  • quota.reservation_multiplier (default 1.0) — how much compose reservations count against quota
  • quota.limit_multiplier (default 1.5) — allowance for over-subscription against limits

Resolution order is CLI flag > app.yaml > config > built-in defaults (get_multipliers).

modules/quota/state.go (ParseCPU / ParseBytes):

CPU cores or millicores
"0.5" → 500
"1" → 1000
"2500m" → 2500
Bytes IEC-ish suffixes (case-insensitive, no decimals)
"512m" → 536_870_912
"8g" → 8_589_934_592
"1t" → 1_099_511_627_776

Invalid input raises X400126BYTVAL or X400127CPUVAL.

Callers use quota_service.enforce_quota(db, ctx, delta), which:

  1. Computes new = current_usage + delta per dimension.
  2. Walks each limit; violations are collected as QuotaCheckResult.violations strings.
  3. If any non-empty, raises the first specific error code (X403001CPUEXC, X403002MEMEXC, X403003DSKEXC, X403004STKEXC, X403005ENVEXC, X403006APPEXC, X403007VAPEXC).

The helpers in enforcement.go package this up for stack lifecycle callers:

HelperUsed by
enforce_stack_creation_quotainstall_flow before compose render
update_usage_on_stack_creationinstall_flow after stack row inserted
update_usage_on_stack_deletionuninstall_flow after compose down
update_usage_on_scaleshc stack scale / replica change in update

Each of these derives (cpu, memory, disk) from the rendered compose (extract_resources_from_compose) so quota uses the exact values Docker is asked to honor, not just app-declared hints.

Tenant quota is necessary but not sufficient — the stack also has to fit somewhere. enforcement.go adds two physical checks:

  • enforce_node_capacity(node, cpu_mc, mem_b) — rejects with X403010NODCPU / X403011NODMEM if the single-node reservation exceeds the node’s known resources.cpu_cores / resources.memory_bytes. Nodes without advertised capacity are ignored (quota only, no physical gate).
  • enforce_swarm_pool_capacity(members, cpu_total, mem_total) — for swarm runtimes, rejects with X403012SWMPOL if the pool can’t hold the full stack, and with X403013SWMREP if any single replica wouldn’t fit on the largest eligible node.

These are computed against NodeModel.resources, which is populated by the node-agent heartbeat — see node.

Every delta goes through quota_service.update_usage(...), which upserts into UsageModel at all three scopes:

  • tenant row (always)
  • tenant/env row (if env given)
  • tenant/stack row (if stack given)
  • tenant/stack/env row (if both)

Adds and subtracts are idempotent from the caller’s perspective; negatives on delete are symmetric.

Disk usage is separate because filesystem introspection is slow: set_disk_usage(...) writes the measurement + disk_measured_at timestamp directly, bypassing the delta path. See below.

modules/quota/disk.goDiskMeasurement picks the cheapest accurate method for the underlying filesystem:

  1. xfs with project quotas → xfs_quota report (O(1))
  2. zfszfs list -o used (O(1))
  3. btrfs with qgroups → btrfs qgroup show (O(1))
  4. anything else → shell du -sb with a short timeout, falling back to a Go-based walker if du is missing or times out

The measurement result is cached by path inside DiskMeasurement for the lifetime of a single scan to avoid re-walking the same tree for overlapping scopes (tenant ⊇ tenant/env ⊇ tenant/stack).

Two cron jobs in tasks.go run on the leader:

  • scan_all_disk_usage — every maintenance_disk_scan_interval (see configuration — maintenance). Walks every (tenant, env, stack) scope and re-measures disk. Heartbeats via record_heartbeat("disk_scan") so shc doctor can flag staleness.
  • reconcile_all_usage — every maintenance_usage_reconcile_interval. Recomputes stack_count, vapp_instance_count, environment_count, and app_count directly from DeploymentModel. Protects against drift from crashed deploys that bypassed normal lifecycle hooks.

Neither job ever raises from the scheduler: failures are logged as X500909DSKSCA / X500913USGREC and the next tick retries.

CodeRaised when
X403001CPUEXCtenant/env/stack CPU limit exceeded
X403002MEMEXCmemory limit exceeded
X403003DSKEXCdisk limit exceeded
X403004STKEXCstack count limit exceeded
X403005ENVEXCenvironment count limit exceeded
X403006APPEXCapp count limit exceeded
X403007VAPEXCvApp instance count limit exceeded
X403010NODCPUsingle-node reservation larger than any node’s CPU
X403011NODMEMsingle-node reservation larger than any node’s memory
X403012SWMPOLswarm pool cannot hold the stack
X403013SWMREPswarm largest replica doesn’t fit on the largest eligible node
X400126BYTVALunparseable byte string
X400127CPUVALunparseable CPU string
X500909DSKSCAbackground disk scan failed
X500913USGRECusage reconcile failed
  • Flow: install — where enforce_stack_creation_quota fires
  • Flow: upgrade — where update_usage_on_scale fires
  • Tenant — the primary quota scope
  • Node — source of node/pool capacity data
  • Health — consumes record_heartbeat emitted here