You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Draft plan for a ticket. Parent context: #431 (memory-exhaustion thrash), #436 (lxc-oomd).
Problem
We have no idea who is using what.
The cluster runs 296 containers across 132 owners on 4 Proxmox nodes, and there is currently
no mechanism to answer any of these questions:
Which user is consuming the most CPU / RAM / disk / I/O?
Which containers are allocated resources they never use?
Is the cluster over-committed, and by how much?
When a container degrades its neighbours, who do we contact?
The incident in #431 made the cost concrete: a single 4 GB container degraded all four nodes,
and identifying the owner was a manual forensic exercise. On 2026-08-02 an identical incident
recurred (CT 284, owner zbarrell) and again took manual investigation to isolate.
Evidence from the 2026-08-02 incident
Recorded here because it defines the detection thresholds and validates the design.
Single container, ozwell-studio-92e3d441 (CT 284, 4 GB cap) on pve2:
Signal
Value
Memory
3.97 GB / 4.00 GB (100%)
anon / file split
3896 MB anon, 60 MB file cache
memory.swap.max
0 (swap disabled)
CPU split
99.3% system / 0.7% user
PSI memory some avg10
99.00
memory.high breaches
321,586,574
Direct reclaim scan
841 B pages scanned, 40.8 B stolen — 4.85% efficiency
workingset_refault_file
30.6 billion
pgmajfault
99.4 million
Block reads
662 MiB/s
oom_kill
0 — kernel OOM never fired
Mechanism: anon memory filled the cap and, with swap disabled, was 100% unreclaimable. The only
reclaimable memory left was file-backed page cache — the executables and shared libraries of the
running processes. The kernel evicted code that was immediately needed again, faulted it back
from Ceph, and repeated. The container was re-reading its own program text continuously.
Effect of stopping the single container:
Metric
Before
After (T+3min)
Change
Ceph client reads
559 MiB/s
210 KiB/s
−99.96%
pve2 load1
56.00
13.04
−77%
pve2 CPU PSI
21.04
0.12
−99%
pve2 IO PSI
16.85
0.25
−99%
pve3 load1
25.02
22.99
−8%
pve4 load1
19.68
16.65
−15%
pve1 load1
6.86
5.80
−15%
One container generated 99.96% of all Ceph read traffic cluster-wide. This is why the problem
is not containable per-node: cgroups cap memory, but not the I/O consequences of memory pressure.
Current state of the codebase
Area
State
Ownership (DB)
Container.username → User.uid
Ownership (Proxmox)
tags: field, 100% coverage verified (72/72 on pve2), synced at routers/api/v1/containers.js:739
Proxmox client
create-a-container/utils/proxmox-api.js — 28 methods, API-token auth, has clusterResources()
Background jobs
create-a-container/job-runner.js — custom DB polling loop, 2 s interval, transactional claim
Quota model
ResourceRequest tracks requested resources only. No enforcement, no actual-usage comparison
Usage tracking
None — no Prometheus, RRD, metrics, or billing code anywhere
Notifications
Password-reset email only (utils/email.js). #434 not started
Agent
Per-site LXC, 30 s systemd check-in. No Proxmox API access, no node access
Key finding: most of this needs no node-resident code
#436 states that none of the useful levers are reachable from the REST API. That is only half
true, and it changes the delivery order.
Verified against the live cluster:
/cluster/resources?type=vm returns, in one call, for every container: tags (= owner), cpu, maxcpu, mem, maxmem, disk, maxdisk, diskread, diskwrite, netin, netout, uptime, status, node, vmid.
/nodes/{node}/lxc/{vmid}/rrddataalready exports PSI: pressurememoryfull, pressurememorysome, pressurecpufull, pressurecpusome, pressureiofull, pressureiosome — at 60 s resolution, 60 points per hour.
Capability
REST API
Node-resident required
Per-CT PSI detection
yes (60 s lag)
only for sub-60 s reaction
Accounting: cpu / mem / disk / bytes / net
yes
—
Owner attribution
yes (tags)
—
stop / shutdown / reboot
yes
—
set memory, cores, swap
yes (updateLxcConfig)
—
IOPS (rios/wios)
no
yes
cgroup.freeze
no — verified: LXC status endpoints are only current, migrate, reboot, shutdown, start, stop
yes
memory.high, memory.reclaim, io.max
no
yes
Consequence: the accounting layer and detection levels L0–L2 ship with zero node-resident
deployment. lxc-oomd is still required, but only for IOPS collection, freeze, and the cgroup
tunables. It moves off the critical path.
RAM is currently 1335 GiB allocated of ~1900 GiB — not over-subscribed. Moving 296 containers
to an 8 GB default raises allocation to roughly 2.4 TiB → 1.25x over-subscribed.
Since a memory-thrash incident is what started this whole thread, raising the default before
detection exists inverts the risk. Recommend landing accounting + detection before #432.
swap: 0 is the direct cause of the thrash-instead-of-OOM behaviour. With no swap, anon memory
is 100% unreclaimable, so the kernel can only evict executable pages.
283 of 291 capped containers cluster-wide are configured swap: 0. Every one has the same
failure mode latent. Setting ~1 GB swap converts a cliff into a slope and is API-settable.
Note this is the opposite of the advice in the original #431 discussion, which suggested swap: 0 to "fail fast to OOM instead". The measured evidence contradicts it: with swap: 0
the kernel did not OOM — it thrashed indefinitely with oom_kill = 0 across 321 M limit
breaches.
IOPS vs bytes
io.stat per container exposes rios / wios against the RBD device (major 251), attributing
Ceph IOPS directly to a container. Not available via the API.
Fleet baseline measured with CT 284 stopped:
Metric
Value
Containers doing I/O
151
Total IOPS
853
Total throughput
5.7 MiB/s
Fleet average I/O size
6.8 KB
Small random I/O — the profile Ceph handles worst. At replication factor 3 the real OSD load is
roughly 2,500 IOPS, not 853.
The two metrics correlate in steady state (top-20 overlap: 17/20) but diverge exactly at the
failure modes, and they catch different incidents:
Failure mode
Signature
Caught by
Refault / bandwidth storm (CT 284)
662 MiB/s at ~1.3k op/s = 476 KB/op (readahead)
bytes
Small-random-I/O storm
high op count, trivial bandwidth
IOPS
Today's incident was a bandwidth problem — IOPS alone would have missed it. The inverse case is
equally capable of degrading co-tenants and is invisible to bytes. Collect both; io.stat emits
all four counters in a single read.
Design
Schema
ContainerUsageSample -- raw, 5-min interval, 30-day retention
containerId, username, siteId, nodeId, sampledAt, status
cpuUsed, cpuAlloc
memUsed, memAlloc
diskUsed, diskAlloc
diskReadBytes, diskWriteBytes
diskReadOps, diskWriteOps -- tier 3 only (node-resident)
netInBytes, netOutBytes
psiMemSome, psiMemFull, psiCpuSome, psiCpuFull, psiIoSome, psiIoFull
ContainerUsageDaily -- LEAF LEDGER: one row per container per day.
username, containerId, day -- source of truth, retained indefinitely
cpuCoreHours, memGbHours, diskGbDay
ioReadGb, ioWriteGb, ioOpsTotal, netGb
psiMemFullMinutes -- health/abuse signal, not just consumption
sampleCount
cpuCoreHours and memGbHours are the billing-grade primitives: integrable, comparable across
users, and directly chargeable if showback is ever wanted. Charge I/O on ioOpsTotal rather than
bytes — it tracks the cost Ceph actually bears.
Org hierarchy (new — nothing equivalent exists today):
OrgUnit -- cost-centre tree; stable across reorgs
id, name, parentId, costCentreCode, active
User (extend existing model)
managerUid -- self-referential FK -> User.uid; NULL = root
orgUnitId -- optional FK -> OrgUnit
OrgUsageDaily -- DERIVED rollup; safe to drop and recompute
scopeType -- 'user' | 'orgunit'
scopeId, day, depth
directCpuCoreHours, directMemGbHours, directIoOpsTotal, ...
subtreeCpuCoreHours, subtreeMemGbHours, subtreeIoOpsTotal, ...
memberCount, containerCount
Direct vs subtree must be stored separately. A leader needs to see "my own containers" and
"my organisation" as distinct numbers, and keeping them in separate columns is what prevents
double counting. ContainerUsageDaily is the only ledger; OrgUsageDaily is derived and can be
rebuilt from it at any time.
Collector
New create-a-container/usage-collector.js, reusing the job-runner.js polling pattern and the
existing ProxmoxApi class.
Two-tier polling matters: /cluster/resources is 1 call, but rrddata is 1 call per
container (296 per cycle). Bulk scan first, drill into outliers only.
// every 5 min — one bulk call, cheapconstrows=awaitpve.clusterResources('vm');// attribute owner via tags, cross-check against Container.username// then, only for containers whose derived rates look suspicious:// pve.rrdData(node, vmid, 'hour') → PSI series
Collection tiers
Tier
Source
Access
Metrics
1
/cluster/resources
API
owner, cpu, mem, disk, bytes, net
2
rrddata
API
PSI (60 s)
3
io.stat
node
IOPS, per-device
Tiers 1–2 ship immediately. Tier 3 rides along with lxc-oomd (#436).
Attribution
Use Proxmox tags as primary — it lives on the container itself, survives DB loss, and has 100%
coverage today. Cross-check against Container.username. Divergence between the two is itself
an alertable drift condition.
Org hierarchy & rollup
No org hierarchy exists anywhere in the system today. Verified:
User has uidNumber, uid, gidNumber, cn, sn, givenName, mail, oidcSubject, oidcIssuer — but no manager, department, or title.
Group is flat (gidNumber, cn, isAdmin) with no parent, and only two groups exist: sysadmins and ldapusers (migrations/20260616000000-seed-groups.js). They are authorisation
roles, not org structure — they must not be overloaded for accounting.
Authentik is the identity source and LDAP (ldaps://ldap1.cluster.mieweb.org:6636, dc=cluster,dc=mieweb,dc=org) is an outpost of it. Authentik supports arbitrary user.attributes, so a manager attribute can be populated there and flow through
LDAP → manager DB without schema surgery on the directory.
So hierarchy is net-new work, and it is the single largest unknown in this plan.
Two edges, both supported, used for different purposes:
Edge
Source
Answers
User.managerUid
Authentik user.attributes.manager → LDAP → sync
"who is accountable for this person"
User.orgUnitId
manually curated, or HR feed
"which cost centre does this land in"
Start with managerUid only — it directly satisfies "leaders accountable for their staff" and
needs no new curation process. OrgUnit is deferred until someone actually asks for cost centres.
Rollup is a recursive walk up the manager tree, materialised nightly into OrgUsageDaily:
WITH RECURSIVE reports AS (
SELECT uid, uid AS root, 0AS depth FROM"Users"UNION ALLSELECTu.uid, r.root, r.depth+1FROM"Users" u JOIN reports r ON u."managerUid"=r.uidWHEREr.depth<10-- hard depth cap: cycle protection
)
SELECT root, SUM(d."cpuCoreHours") ...
FROM reports r JOIN"ContainerUsageDaily" d ONd.username=r.uidGROUP BY root;
Edge cases that must be handled explicitly, because all of them exist in real directories:
Cycles (A manages B manages A) — depth cap plus a cycle detector that logs and breaks.
Orphans — users whose managerUid points at a departed user. Reparent to a synthetic unassigned root rather than silently dropping their usage from every total.
Departed owners — containers outlive people. Usage must still roll up; attribute to the
last known manager and flag for reclamation.
Self-management — roots have managerUid = NULL, not self-referencing.
Reorgs — OrgUsageDaily is snapshot-per-day, so historical rollups reflect the hierarchy as it was. Do not retroactively rewrite history when someone changes manager.
Collection: build vs OpenTelemetry
Worth considering, and my recommendation is not to adopt OTel as the system of record for
accounting — but to stay compatible with it.
The two workloads have genuinely different requirements:
Accounting
Health / observability
Needs
exact, durable, auditable, per-entity totals
timely, approximate, high-resolution
Tolerates loss
no
yes
Retention
years
days–weeks
Query shape
recursive tree rollups, joins to users
time-series, rate/percentile
Natural store
relational (Postgres)
TSDB
Against OTel for the accounting path:
OTel pipelines are lossy by design. Collectors drop under backpressure and downsample on
retention. That is correct for monitoring and disqualifying for anything chargeback-grade.
It is a large scope add. There is no collector, no TSDB, and no Grafana in this stack today.
Adopting OTel means standing up and operating an entire observability platform as a prerequisite
to shipping a usage report.
Hierarchy rollups are awkward in PromQL. Recursive manager-tree aggregation is a natural SQL
recursive CTE and an unnatural fit for a TSDB without pre-baked recording rules per level.
The data volume does not justify it: ~2.5 M sample rows per 30 days is unremarkable for Postgres,
which is already deployed and backed up.
It buys vendor-neutral export if Grafana/Prometheus arrives later.
Decision: SQL ledger for accounting, OTel-compatible export for health. Concretely:
Persist accounting to Postgres via Sequelize (the ledger is authoritative).
Name metrics using OTel semantic conventions from day one
(container.cpu.time, container.memory.usage, container.disk.io), and carry owner/manager
as resource attributes.
Ship an optional OTLP exporter behind a config flag as a later milestone, so metrics can
feed a collector if one is ever stood up — without the ledger depending on it.
This keeps the door open at near-zero cost and avoids making a usage report block on an
observability platform.
Owner reporting
Individual
GET /api/v1/users/:username/usage — self-service, reusing the existing owner+collaborator
visibility pattern (routers/api/v1/containers.js:230). Users see their own; admins see all.
Weekly digest email via existing nodemailer. The strongest lever is idle reclamation,
not billing — cmyers at 0.03 of 36 cores is a polite-email problem, not a technical one.
GET /api/v1/org/:uid/usage?depth=n — returns direct and subtree totals, plus a ranked
breakdown of direct reports. Authorisation rule: a user may read any scope at or below
themselves in the manager tree; sysadmins may read any scope. This reuses the existing
visibility-clause pattern rather than inventing a second authorisation model.
Leader digest — monthly, to anyone with at least one direct report:
team total, ranked list of reports, biggest movers month-over-month, and an idle-resource
list (allocated-vs-used) for reclamation. The idle list is the part that produces action.
Fleet report for sysadmins — the per-owner table already prototyped, plus
over-subscription ratios and attribution-drift warnings.
Reports must show allocated alongside used, never used alone. The single most actionable
number in the prototype was cmyers holding 36 cores at 0.03 used — invisible if you only
report consumption.
Detection thresholds
Three independent triggers, not one:
Trigger
Condition
Catches
Memory thrash
psiMemFull > 40 sustained 30 s and rising workingset_refault
CT 284 / CT 392 class
Bandwidth hog
diskReadBytes rate > 200 MiB/s sustained
CT 284 (would have fired)
IOPS hog
ioOps rate > 2000/s sustained
the case bytes misses
memory.current ≈ memory.max alone is not alertable — 6 containers sit at 91–99% of cap with psiMemFull = 0.00 and are perfectly healthy. PSI plus refault rate is the discriminator.
Interventions
Ladder per #431/#436, with three amendments from the measured evidence:
Freeze before notify, not after.pct exec on CT 284 hung — the container was too starved
to respond. A thrashing process cannot run a signal handler. Freeze is the only reliable first
action, and it is node-only.
io.max throttling is the most valuable intervention and a stronger justification for
lxc-oomd than freeze. Unlike freeze or kill it is graceful — it slows the offender rather
than stopping it, protecting co-tenants without an outage. No API equivalent exists.
Never auto-raise memory permanently. Temporary runtime memory.max bump for graceful
shutdown only; permanent raises stay a human decision to avoid limit creep.
Milestones
Split deliberately so that nothing in A1–A5 is blocked by another ticket. The accounting and
reporting core ships standalone; only the IOPS tier and live intervention wait on #436.
Accounting-specific (no external dependency)
ID
Milestone
Delivers
Node access
A1
Collection foundation
ContainerUsageSample schema + migration; usage-collector.js on the job-runner.js pattern; tier-1 /cluster/resources poll at 5 min; owner attribution via tags with Container.username cross-check
no
A2
PSI collection
Tier-2 rrddata poll for outliers only; PSI columns populated; two-tier throttling so we do not issue 296 calls/cycle
no
A3
Rollup & retention
Nightly job building ContainerUsageDaily; 30-day purge of raw samples; cpuCoreHours / memGbHours primitives; backfill from existing RRD where available
Critical path to something useful: A1 → A3 → A5. That produces the per-owner report and
digests with no node access and no dependency on any other ticket.
Acceptance criteria
A1–A3 (collection & rollup)
Per-owner usage report queryable via API and reproducible from /cluster/resources.
Daily rollups produce cpuCoreHours / memGbHours per user, retained indefinitely.
Raw samples purge at 30 days; rollups survive.
Attribution drift (Proxmox tag vs Container.username) is detected and reported.
Collector issues ≤ 2 API calls per cycle in the steady state (no per-container fan-out unless
an outlier is flagged).
A4 (hierarchy)
Subtree totals for any manager equal the sum of leaf ContainerUsageDaily rows beneath them —
verified by a reconciliation test asserting no double counting.
A cycle in the manager tree is detected, logged, and does not hang or inflate the rollup.
A container whose owner has departed still appears in exactly one subtree total.
Changing a user's manager does not retroactively alter historical rollups.
A5 (reporting)
A leader can retrieve their team's usage; a non-leader cannot read a peer's or a superior's scope.
Reports show allocated alongside used for every resource.
Idle-resource report identifies containers by allocated-vs-used ratio.
B1/B4/B5 (dependent)
A synthetic memory hog is detected within 60 s and attributed to the correct owner.
Owner receives a notification containing an evidence bundle.
Open questions
Hierarchy
Where does manager data come from — is it already in an HR system that can feed Authentik, or
does it need manual curation for all 132 owners? We should be discussing this with Andy and making sure we populate this automatically from BambooHR
Is a single manager tree sufficient, or are cost centres (OrgUnit) needed for cross-functional
teams where the person's manager is not who owns the budget? We could do a hash tag like approach.
Should a leader see per-container detail for their reports, or only aggregates? (Privacy vs
usefulness — container hostnames can be revealing.) Leaders should see it all.
What happens to usage attribution when someone leaves and their containers are reassigned? They should be shut down or reassigned.
Accounting
Retention: is 30 days of 5-minute samples enough? (~296 CTs × 288 samples/day × 30 = ~2.5 M rows.). Maybe 90 days, but keep daily logs for years.
Is showback/chargeback ever intended, or is this purely visibility + idle reclamation?
Should quota enforcement be in scope, or is reporting sufficient for now? ResourceRequest already models approved quotas but nothing compares them to actual usage.
Should digests be opt-in, or default-on for all 132 owners and their leaders?
Collection
Confirm the OTel decision: SQL ledger now, optional OTLP export later — or is there an existing
observability platform commitment that would change this?
Does io.max throttling need per-owner or per-team policy, or a single fleet default?
Draft plan for a ticket. Parent context: #431 (memory-exhaustion thrash), #436 (lxc-oomd).
Problem
We have no idea who is using what.
The cluster runs 296 containers across 132 owners on 4 Proxmox nodes, and there is currently
no mechanism to answer any of these questions:
The incident in #431 made the cost concrete: a single 4 GB container degraded all four nodes,
and identifying the owner was a manual forensic exercise. On 2026-08-02 an identical incident
recurred (CT 284, owner
zbarrell) and again took manual investigation to isolate.Evidence from the 2026-08-02 incident
Recorded here because it defines the detection thresholds and validates the design.
Single container,
ozwell-studio-92e3d441(CT 284, 4 GB cap) on pve2:memory.swap.maxsome avg10memory.highbreachesworkingset_refault_filepgmajfaultoom_killMechanism: anon memory filled the cap and, with swap disabled, was 100% unreclaimable. The only
reclaimable memory left was file-backed page cache — the executables and shared libraries of the
running processes. The kernel evicted code that was immediately needed again, faulted it back
from Ceph, and repeated. The container was re-reading its own program text continuously.
Effect of stopping the single container:
One container generated 99.96% of all Ceph read traffic cluster-wide. This is why the problem
is not containable per-node: cgroups cap memory, but not the I/O consequences of memory pressure.
Current state of the codebase
Container.username→User.uidtags:field, 100% coverage verified (72/72 on pve2), synced atrouters/api/v1/containers.js:739create-a-container/utils/proxmox-api.js— 28 methods, API-token auth, hasclusterResources()create-a-container/job-runner.js— custom DB polling loop, 2 s interval, transactional claimResourceRequesttracks requested resources only. No enforcement, no actual-usage comparisonutils/email.js). #434 not startedKey finding: most of this needs no node-resident code
#436 states that none of the useful levers are reachable from the REST API. That is only half
true, and it changes the delivery order.
Verified against the live cluster:
/cluster/resources?type=vmreturns, in one call, for every container:tags(= owner),cpu,maxcpu,mem,maxmem,disk,maxdisk,diskread,diskwrite,netin,netout,uptime,status,node,vmid./nodes/{node}/lxc/{vmid}/rrddataalready exports PSI:pressurememoryfull,pressurememorysome,pressurecpufull,pressurecpusome,pressureiofull,pressureiosome— at 60 s resolution, 60 points per hour.tags)updateLxcConfig)rios/wios)cgroup.freezecurrent,migrate,reboot,shutdown,start,stopmemory.high,memory.reclaim,io.maxConsequence: the accounting layer and detection levels L0–L2 ship with zero node-resident
deployment. lxc-oomd is still required, but only for IOPS collection, freeze, and the cgroup
tunables. It moves off the critical path.
Proof of concept: live report, API-only
Generated from one
/cluster/resourcescall:Physical capacity: 288 cores, ~1.9 TiB RAM across 4 nodes.
Immediately actionable from this table alone:
cmyersholds 36 allocated cores at 0.03 used — pure idle reclamation.sbarbershows 1690 G of I/O from 3 containers, an order of magnitude above peers per container.Warning for #432 (raise default maxmem to 8 GB)
RAM is currently 1335 GiB allocated of ~1900 GiB — not over-subscribed. Moving 296 containers
to an 8 GB default raises allocation to roughly 2.4 TiB → 1.25x over-subscribed.
Since a memory-thrash incident is what started this whole thread, raising the default before
detection exists inverts the risk. Recommend landing accounting + detection before #432.
Swap policy (#433) — answer
swap: 0is the direct cause of the thrash-instead-of-OOM behaviour. With no swap, anon memoryis 100% unreclaimable, so the kernel can only evict executable pages.
283 of 291 capped containers cluster-wide are configured
swap: 0. Every one has the samefailure mode latent. Setting ~1 GB swap converts a cliff into a slope and is API-settable.
Note this is the opposite of the advice in the original #431 discussion, which suggested
swap: 0to "fail fast to OOM instead". The measured evidence contradicts it: withswap: 0the kernel did not OOM — it thrashed indefinitely with
oom_kill = 0across 321 M limitbreaches.
IOPS vs bytes
io.statper container exposesrios/wiosagainst the RBD device (major 251), attributingCeph IOPS directly to a container. Not available via the API.
Fleet baseline measured with CT 284 stopped:
Small random I/O — the profile Ceph handles worst. At replication factor 3 the real OSD load is
roughly 2,500 IOPS, not 853.
The two metrics correlate in steady state (top-20 overlap: 17/20) but diverge exactly at the
failure modes, and they catch different incidents:
Today's incident was a bandwidth problem — IOPS alone would have missed it. The inverse case is
equally capable of degrading co-tenants and is invisible to bytes. Collect both;
io.statemitsall four counters in a single read.
Design
Schema
cpuCoreHoursandmemGbHoursare the billing-grade primitives: integrable, comparable acrossusers, and directly chargeable if showback is ever wanted. Charge I/O on
ioOpsTotalrather thanbytes — it tracks the cost Ceph actually bears.
Org hierarchy (new — nothing equivalent exists today):
Direct vs subtree must be stored separately. A leader needs to see "my own containers" and
"my organisation" as distinct numbers, and keeping them in separate columns is what prevents
double counting.
ContainerUsageDailyis the only ledger;OrgUsageDailyis derived and can berebuilt from it at any time.
Collector
New
create-a-container/usage-collector.js, reusing thejob-runner.jspolling pattern and theexisting
ProxmoxApiclass.Two-tier polling matters:
/cluster/resourcesis 1 call, butrrddatais 1 call percontainer (296 per cycle). Bulk scan first, drill into outliers only.
Collection tiers
/cluster/resourcesrrddataio.statTiers 1–2 ship immediately. Tier 3 rides along with lxc-oomd (#436).
Attribution
Use Proxmox
tagsas primary — it lives on the container itself, survives DB loss, and has 100%coverage today. Cross-check against
Container.username. Divergence between the two is itselfan alertable drift condition.
Org hierarchy & rollup
No org hierarchy exists anywhere in the system today. Verified:
UserhasuidNumber,uid,gidNumber,cn,sn,givenName,mail,oidcSubject,oidcIssuer— but nomanager,department, ortitle.Groupis flat (gidNumber,cn,isAdmin) with no parent, and only two groups exist:sysadminsandldapusers(migrations/20260616000000-seed-groups.js). They are authorisationroles, not org structure — they must not be overloaded for accounting.
ldaps://ldap1.cluster.mieweb.org:6636,dc=cluster,dc=mieweb,dc=org) is an outpost of it. Authentik supports arbitraryuser.attributes, so amanagerattribute can be populated there and flow throughLDAP → manager DB without schema surgery on the directory.
So hierarchy is net-new work, and it is the single largest unknown in this plan.
Two edges, both supported, used for different purposes:
User.managerUiduser.attributes.manager→ LDAP → syncUser.orgUnitIdStart with
managerUidonly — it directly satisfies "leaders accountable for their staff" andneeds no new curation process.
OrgUnitis deferred until someone actually asks for cost centres.Rollup is a recursive walk up the manager tree, materialised nightly into
OrgUsageDaily:Edge cases that must be handled explicitly, because all of them exist in real directories:
managerUidpoints at a departed user. Reparent to a syntheticunassignedroot rather than silently dropping their usage from every total.last known manager and flag for reclamation.
managerUid = NULL, not self-referencing.OrgUsageDailyis snapshot-per-day, so historical rollups reflect the hierarchyas it was. Do not retroactively rewrite history when someone changes manager.
Collection: build vs OpenTelemetry
Worth considering, and my recommendation is not to adopt OTel as the system of record for
accounting — but to stay compatible with it.
The two workloads have genuinely different requirements:
Against OTel for the accounting path:
retention. That is correct for monitoring and disqualifying for anything chargeback-grade.
Adopting OTel means standing up and operating an entire observability platform as a prerequisite
to shipping a usage report.
recursive CTE and an unnatural fit for a TSDB without pre-baked recording rules per level.
which is already deployed and backed up.
For OTel:
high-frequency metrics that nobody wants in a relational table.
Decision: SQL ledger for accounting, OTel-compatible export for health. Concretely:
(
container.cpu.time,container.memory.usage,container.disk.io), and carry owner/manageras resource attributes.
feed a collector if one is ever stood up — without the ledger depending on it.
This keeps the door open at near-zero cost and avoids making a usage report block on an
observability platform.
Owner reporting
Individual
GET /api/v1/users/:username/usage— self-service, reusing the existing owner+collaboratorvisibility pattern (
routers/api/v1/containers.js:230). Users see their own; admins see all.not billing —
cmyersat 0.03 of 36 cores is a polite-email problem, not a technical one.that notifications route to a person. Hook target is Notification queue + inbound webhook in create-a-container #434.
Hierarchical
GET /api/v1/org/:uid/usage?depth=n— returns direct and subtree totals, plus a rankedbreakdown of direct reports. Authorisation rule: a user may read any scope at or below
themselves in the manager tree;
sysadminsmay read any scope. This reuses the existingvisibility-clause pattern rather than inventing a second authorisation model.
team total, ranked list of reports, biggest movers month-over-month, and an idle-resource
list (allocated-vs-used) for reclamation. The idle list is the part that produces action.
sysadmins— the per-owner table already prototyped, plusover-subscription ratios and attribution-drift warnings.
Reports must show allocated alongside used, never used alone. The single most actionable
number in the prototype was
cmyersholding 36 cores at 0.03 used — invisible if you onlyreport consumption.
Detection thresholds
Three independent triggers, not one:
psiMemFull > 40sustained 30 s and risingworkingset_refaultdiskReadBytesrate > 200 MiB/s sustainedioOpsrate > 2000/s sustainedmemory.current ≈ memory.maxalone is not alertable — 6 containers sit at 91–99% of cap withpsiMemFull = 0.00and are perfectly healthy. PSI plus refault rate is the discriminator.Interventions
Ladder per #431/#436, with three amendments from the measured evidence:
pct execon CT 284 hung — the container was too starvedto respond. A thrashing process cannot run a signal handler. Freeze is the only reliable first
action, and it is node-only.
io.maxthrottling is the most valuable intervention and a stronger justification forlxc-oomd than freeze. Unlike freeze or kill it is graceful — it slows the offender rather
than stopping it, protecting co-tenants without an outage. No API equivalent exists.
memory.maxbump for gracefulshutdown only; permanent raises stay a human decision to avoid limit creep.
Milestones
Split deliberately so that nothing in A1–A5 is blocked by another ticket. The accounting and
reporting core ships standalone; only the IOPS tier and live intervention wait on #436.
Accounting-specific (no external dependency)
ContainerUsageSampleschema + migration;usage-collector.json thejob-runner.jspattern; tier-1/cluster/resourcespoll at 5 min; owner attribution viatagswithContainer.usernamecross-checkrrddatapoll for outliers only; PSI columns populated; two-tier throttling so we do not issue 296 calls/cycleContainerUsageDaily; 30-day purge of raw samples;cpuCoreHours/memGbHoursprimitives; backfill from existing RRD where availableUser.managerUid+ Authentik attribute → LDAP → sync; recursive-CTE rollup intoOrgUsageDaily; cycle/orphan/departed-owner handling/users/:uid/usage,/org/:uid/usage; fleet report; weekly owner digest and monthly leader digest via existing nodemailer; idle-resource reportDependent on other tickets
swap: 1024fleet-wide; API-settable, no coderios/wiosexist only in host-sideio.stat. No API equivalentcgroup.freeze,io.max,memory.highare all node-only. Verified: LXC has no suspend endpointDependency graph
Critical path to something useful: A1 → A3 → A5. That produces the per-owner report and
digests with no node access and no dependency on any other ticket.
Acceptance criteria
A1–A3 (collection & rollup)
/cluster/resources.cpuCoreHours/memGbHoursper user, retained indefinitely.Container.username) is detected and reported.an outlier is flagged).
A4 (hierarchy)
ContainerUsageDailyrows beneath them —verified by a reconciliation test asserting no double counting.
A5 (reporting)
B1/B4/B5 (dependent)
Open questions
Hierarchy
does it need manual curation for all 132 owners? We should be discussing this with Andy and making sure we populate this automatically from BambooHR
OrgUnit) needed for cross-functionalteams where the person's manager is not who owns the budget? We could do a hash tag like approach.
usefulness — container hostnames can be revealing.) Leaders should see it all.
Accounting
ResourceRequestalready models approved quotas but nothing compares them to actual usage.Collection
observability platform commitment that would change this?
io.maxthrottling need per-owner or per-team policy, or a single fleet default?