Repository navigation
usage: land usage events in the shared meta store and serve GET /v1/usage - #407
Conversation
The shipper that lands the journal in the shared meta store decodes the same lines, so the line schema gets one owner both packages import.
…sage An outside billing service needs every node's usage events in one place. With meta_store.usage set, a background loop on each node reads usage.jsonl and its .1 backup every 5 s and lands new lines in sandboxd_usage, scoped by cell. The journal append is unchanged, so claims never wait on the database. Each node numbers its landed lines by seq; (cell, node, seq) is unique and the node's newest row (seq + line SHA-256) is its resume point, so restarts, retries, lost replies and outages converge without duplicates. Lands of a cell serialize on an advisory lock, so cursors commit in order and a reader paging by cursor never skips a row. Root reads the raw events through GET /v1/usage with tenant, since/until and cursor paging.
|
Either stamp |
A crash can leave the journal's last line without its newline. With O_APPEND the first event after the restart was written onto that fragment, so the merged line did not decode and the whole event was lost to every reader, the usage shipper included. Opening a journal now ends such a line first, so only the torn event is lost.
hibernate, wake, fork, checkpoint, promote, release and reap omitted the tenant, so a consumer filtering usage by tenant could not fold a claim's durations. The field is additive in usage.jsonl.
When the node's resume line is gone from both journal files, the shipper lands everything the files hold. If a crash lost an unsynced tail the node had already landed, or the journal was reset, older lines landed again under new seqs and billing counted them twice. The table now keys (cell, node, line_sha256) as unique and a land skips a line whose digest already landed, so seq stays increasing and no line lands twice. The insert moves from COPY to a batched INSERT, since COPY has no ON CONFLICT.
A prune that drops a node's newest row makes the node land its whole journal again; journals from v0.1.20 or earlier lack the tenant on hibernate, wake, fork, checkpoint, promote, release and reap.
auditFrame repeated requestFrame's nine fields; Go 1.27 promoted-field literals keep every auditFrame{Op: ...} call site unchanged and the JSON is byte-identical.
GET /v1/tenants and GET /v1/usage share the documented limit contract (default 1000, at most 10000); usage had copied the tenant handler's parsing and constants.
Five tests read and decoded usage.jsonl line by line; usageJournal does it once by usage.JournalFile.
os.Open already returns a nil file with fs.ErrNotExist, so openBackup and its gosec suppression were a wrapper over one call.
The PG step listed tenants, poolset and usage only; without SANDBOXD_TEST_PG_URL the 20 archives/pg and archivestest cases skip, so make go-test never ran them either.
|
Done in 8ea1870: hibernate, wake, fork, checkpoint, promote, release and reap now carry the claim's tenant, and |
One shipper per node lands its lines in journal order, so a node's cursor order already is its journal order and the newest row by cursor is the resume point. seq repeated that order, skipped numbers where a landed line was skipped, and cost a column, a CHECK and a unique index on every land; (cell, node, cursor) now indexes the resume query.
The journal writer never produces a line without ev, and a torn tail never decodes, so a decodable line with no event cannot reach the shipper.
A consumer pricing compute by sandbox shape could only read the shape from the claim record, which is gone after release. Claim events now carry template and size from the claim's pool key, in the journal line and as two new sandboxd_usage columns. A table made by a pre-release build of #407 needs the columns added by hand first.
What
meta_store.usage: true(opt-in, restart only) makes each node land its usage journal in one shared PostgreSQL table,sandboxd_usage, scoped bycelllike the archive rows.GET /v1/usage?tenant=&since=&until=&after=&limit=returns the raw events of every node of the cell, in cursor order with cursor paging. A node withoutmeta_store.usageanswers501 no shared usage store configured; a database outage answers503 usage store down; retry.meta_store.usagenothing changes: no table, no goroutine, no route behaviour beyond the 501.Why
An outside billing service needs every node's events in one place. In a cluster one sandbox's events span nodes (an adopted archive), and today the only source is a per-node
usage.jsonl. The table holds raw events only; durations and prices stay with the consumer.Design
usage.Shipreadsusage.jsonland its.1backup every 5 s and lands new lines, up to 1000 per transaction, until it catches up.journal.appendis untouched.(cell, node, line_sha256)is unique: a land skips a line whose digest already landed for its node (INSERT … ON CONFLICT DO NOTHING, batched in one transaction). One shipper per node lands its lines in journal order, so the node's newest row bycursoris its resume point; it lives in PG and cannot diverge from the rows. At boot and after any failed land the node reads that row and resumes after that line; in steady state it re-checks one line at a remembered offset (a rename keeps the bytes, so rotation keeps the hint valid). When neither file still holds the marked line (a crash lost a tail that had already landed, or the journal was reset), it re-reads both files and the digest key lands only what is new. A restart, a retry or a commit whose reply was lost lands no line twice.COPY), socursor(an identity column) commits in order. A consumer polling withafter=<last cursor>never skips a row that commits later, even with several nodes landing at once or a node backfilling after an outage.cell,cursor,node,line_sha256. Indexes: PK(cell, cursor)for paging,(cell, node, cursor)for the resume query, unique(cell, node, line_sha256)for idempotence,(cell, tenant, cursor)for the tenant filter,(cell, t)for since/until. The schema follows the other pg packages (metastore.Openruns the embedded DDL only when the table is missing).meta_store.usage, not on by default withmeta_store): an upgrade must not add a table, a CREATE requirement or write load to a shared database sized for tenants and pools.ref: a SOCKS domain from a guest can carry\x00, which PostgreSQL text rejects, and one such line would block the node's stream. It lands as U+FFFD (test proves the failure without it). Invalid UTF-8 is already replaced at journal write.pool/journal.go): opening a journal whose last line a crash cut short ends that line with a newline first, so the first append after the restart is a line of its own instead of being merged into an undecodable fragment.usage.jsonl), sotenant=Xreturns everything needed to fold that tenant's durations.usage.Event: the line type moves frompooltousage(first commit, mechanical) so the writer and the shipper share one schema.Hot-path cost
Zero. Claim, release, hibernate and wake append to the journal exactly as before (the first commit only renames the type). Landing runs in one background goroutine that only reads the journal files; a database outage never reaches the claim path. Off the claim path, a node's first land after boot hashes the journal once to find its mark (bounded by the 2 × 64 MiB the journal keeps).
Test evidence
usage): resume across restarts, rotation while lines are unlanded, store down then back with a rotation in between, a lost reply, two rotations during an outage, a reset journal, an undecodable line and a torn last line, a backlog past one batch, and the ticker loop undersynctest.usage/pg, real postgres:17-alpine, the CI image): cursor order and mark, every filter and paging, a land that skips a landed digest, cell isolation, the NUL case, the land waiting on the cell lock, the indexes, andShipend to end across restarts and a rotation. The CI PG step now runs./usage/...and./archives/...and still fails on any--- SKIP. Local run: 46 PASS, 0 SKIP, 0 FAIL across./tenants/... ./poolset/... ./usage/...plus the pool meta-store tests.server): root-only (403 tenant, 401 none), 501 without a store, query pass-through,nexton a full page only,{"events":[]}when empty, 400 on each bad parameter, 503 without leaking the cause.GOWORK=off make go-lint(5 modules × linux/darwin),make go-test,asl -forwarder=false ./...on both GOOS. Locally thee2emodule lint hits a staticcheck SA4023 panic on macOS that reproduces onmain(fb70ec7) unchanged; CI on Linux is the gate for that module.Testbed evidence
The runs below predate the removal of
seq(c3744ab); their(node, seq)counts read as rows per node in cursor order.Bare-metal test host, isolated cocoon root, cocoon v0.7.1,
rt:24.04, a throwaway PostgreSQL 17.11, sandboxd built from this branch (before the rebase onto #406, which only touchesengine/).(node, seq), 21 distinct digests;GET /v1/usagepaged by 4 returns them in cursor order.pg_ctl stop -m immediate) mid-run: 8 more cycles ran unaffected (claim p50 4.4 ms, hibernate 310 ms, wake 111 ms, release 161 ms), one warning logged,/v1/usageanswered 503. After PG returned the 32 backlogged events landed within ~2 s and the node loggedusage events land again.meta_store.usageon/off interleaved on,off,off,on, 10 cycles each (p50 ms): claim 0.6 / 4.6 / 4.8 / 4.5 (warm/clone bimodal, max 155–158 in every arm), hibernate 312 / 315 / 307 / 319, wake 112 / 116 / 116 / 117, release 173 / 176 / 167 / 176. No arm difference.(node, seq)= 225 distinct digests.tenant=acme; a tenant token gets 403; asince/untilsplit at one event's time partitions all events (113 + 114 = 227).Review fixes (Codex round 1)
Each fix is its own commit with a test that fails on its parent:
pool: end a crash-torn journal line before the next append—TestJournalKeepsTheFirstAppendAfterATornLinedrives the realnewJournal+append; on the parent the append merges into the fragment.pool: stamp the claim's tenant on every claim-scoped usage event—TestEveryClaimScopedUsageEventCarriesTheTenantruns claim, hibernate, wake, fork, checkpoint, promote, release and reap for a tenant claim; on the parent 7 events lack the tenant.usage: land a journal line at most once per node by its digest— PG-gatedTestShipLandsNoLineTwiceAfterTheJournalLosesItsLandedTail(6 rows instead of 4 on the parent) andTestLandSkipsALandedLine.docs: count every shared table's query pool in the connection budget— the pool set, archive registry and usage table each open a pool sized by the DSN'spool_max_conns.Hardware re-run on the same host at head 2955632 (fresh kit, PG 17.11, removed afterwards).
verify.pyhashed every journal line and compared against the table:(id, ev, t).(id, ev, t); seq increasing. A clean restart after that kept 83 = 79 + 4 with no duplicate.Review round
docs: usage table retention and pre-v0.1.21 tenant stamping— sandboxd never deletes a usage row; a prune must keep each node's newest row, since a node that finds none lands its whole journal again (checked on PostgreSQL 17: keep-newest prune → only the new line lands; prune-all → all 4 journal lines land again).GET /v1/usagenotes that journals from v0.1.20 or earlier stamp the tenant only on claim, archive, egress and vmm events.pool: embed the request frame in the audit frame—auditFramerepeatedrequestFrame's nine fields; promoted-field literals keep every call site, and the audit JSON is byte-identical (checked on both sides of the change).server: one page-limit parser for the tenant and usage lists— both lists share the documented limit contract.usage: open the journal backup inline—os.Openalready returns a nil file withfs.ErrNotExist; the wrapper and its gosec suppression go.pool: one usage journal decoder for the tests— five tests decodedusage.jsonlby hand.ci: run the archive registry tests against PostgreSQL— withoutSANDBOXD_TEST_PG_URLthe 20archives/pgandarchivestestcases skip, and the PG step did not list them, so CI never ran them. They pass on PostgreSQL 17.review: drop two comments that restate the usage wiring.Round delta: prod −17, test −35, comments added 0. Gates:
make go-lint10 ×0 issues.(5 modules × linux/darwin) plusfmt --diffclean;aslclean on both GOOS;go test -race -count=1green in all 5 modules; the CI PG step plus./archives/...on PostgreSQL 17: 84 PASS, 0 SKIP; Python SDKruff format --check,ruff check,pytest269 passed.usage: drop the seq column; a node resumes from its newest cursor—seqrepeated each node's cursor order, skipped numbers where a landed line was skipped, and cost a column, a CHECK and a unique index on every land. The resume query now runs on(cell, node, cursor)(EXPLAIN: index scan backward onsandboxd_usage_node).usage: drop the empty-event guard— no writer produces a decodable line withoutev.sdk/python: name the controller test's home volume once.Gates after these three:
make go-lint10 ×0 issues.(cold lint cache; a warm cache hits the known staticcheck SA4023 panic ine2e),aslclean on both GOOS,go test -race -count=1green in all 5 modules, the CI PG step plus./archives/...on PostgreSQL 17: 83 PASS, 0 SKIP, Python 269 passed.Two-node cluster on hardware (head aeb0e94)
Two bare-metal test hosts, one sandboxd each (isolated cocoon roots, cocoon v0.7.1,
rt:24.04sha256:4231957e9247), joined in one gossip mesh with a cluster key, onemeta_storecell withusage: true, and PostgreSQL 17.11 on the first host; the second node lands over a WireGuard link (~10 ms RTT).verify.pyhashes every journal line of both nodes and compares it withline_sha256in the table.sandboxd_config_digest_mismatch0; a SIGKILLed node drops out of the peer's view in 6.8 s and is back 1.0 s after its restart.GET /v1/usage?after=&limit=7, alternating between the nodes: 128 rows, the consumer saw all 128 exactly once and in table order. Each node: 64 journal lines = 64 rows, cursor order = journal order (the propertyseqused to carry).tenant=acmereturns 32 events (claim, hibernate, wake, release × 8, both nodes); tenant token 403, no token 401,limit=0400; asince/untilsplit at one event'stpartitions all rows (65 + 63 = 128).GET /v1/usage503 on both, one warning per node, both backfilled within 1–3 s of the restart.meta_store.usageon, off, off, on × 8 cycles (p50 ms): claim 0.7 / 0.7 / 0.7 / 0.7, hibernate 394 / 399 / 400 / 394, wake 122 / 115 / 115 / 124, release 162 / 172 / 168 / 175. No arm difference; lines journaled while off landed when it was turned back on.Docs
deploy.md(config row, a "Shared usage table" section with the DDL, exactly-once, order, outage and rename rules),sandboxd-api.md(GET /v1/usage, the root-only list, a pointer from the journal section),cluster.md(the usage journal bullet points at the table),index.md,README.md.