From 496ee4bafbf9f9624b5fdb26604bbd32be1f0e3b Mon Sep 17 00:00:00 2001 From: tonic Date: Mon, 2 Nov 2026 10:50:33 +0800 Subject: [PATCH 1/2] usage: carry the claim's template and size on claim events A consumer pricing compute by sandbox shape could only read the shape from the claim record, which is gone after release, and `key` is a one-way hash. Claim events now carry `template` and `size` from the claim's pool key, in the journal line and as two new `sandboxd_usage` columns. Tested: the tenant claim test and the PG round-trip test fail with the implementation reverted (empty template/size) and pass with it; sandboxd go test ./... and go-lint pass; PG tests run against postgres:16. --- docs/deploy.md | 2 ++ docs/sandboxd-api.md | 4 +++- sandboxd/pool/claim.go | 2 +- sandboxd/pool/telemetry_test.go | 3 +++ sandboxd/usage/pg/pg.go | 10 +++++----- sandboxd/usage/pg/pg_test.go | 2 +- sandboxd/usage/pg/schema.sql | 2 ++ sandboxd/usage/usage.go | 2 ++ 8 files changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index 95dd46a8a..c6c44af5a 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -840,6 +840,8 @@ only; computing durations or prices stays with the consumer. id text NOT NULL, vm text NOT NULL DEFAULT '', key text NOT NULL DEFAULT '', + template text NOT NULL DEFAULT '', + size text NOT NULL DEFAULT '', tenant text NOT NULL DEFAULT '', volumes text[], volumes_rw text[], diff --git a/docs/sandboxd-api.md b/docs/sandboxd-api.md index 8760c3084..1f056b759 100644 --- a/docs/sandboxd-api.md +++ b/docs/sandboxd-api.md @@ -1015,7 +1015,9 @@ Always on: every lifecycle transition appends one JSONL event to `/usage.jsonl` — `{"t": , "ev": "claim|hibernate|wake|fork|checkpoint|promote|release|reap|archive|unarchive|archive_delete|egress|egress_bytes|vmm_exit|vmm_restart|vmm_failed", "id": "sb_…", "vm": "sbx-…"}` plus `key` (the pool key's stable hash, claim -events), `tenant` (the owning tenant, on every event of a tenant's claim), +events), `template` and `size` (the claim's template and size tier, claim +events, so a consumer can price a sandbox by its shape without the claim +record, which is gone after release), `tenant` (the owning tenant, on every event of a tenant's claim), `children` (fork) and `ref` (the promoted template / checkpoint id, the egress host, or a `vmm_failed` reason). An egress event that injected credentials names them in `secret` (`gh,claim:`, never a value). An egress event routed through an diff --git a/sandboxd/pool/claim.go b/sandboxd/pool/claim.go index 9efedfa9e..0f9e4c12b 100644 --- a/sandboxd/pool/claim.go +++ b/sandboxd/pool/claim.go @@ -506,7 +506,7 @@ func (m *Manager) finalizeBatch(ctx context.Context, sbs []*types.Sandbox, ttl t m.recordUsage(ctx, usage.Event{ Event: "claim", ID: sb.ID, VMName: sb.VMName, - KeyHash: sb.Key.Hash(), Tenant: sb.Tenant, + KeyHash: sb.Key.Hash(), Template: sb.Key.Template, Size: string(sb.Key.Size), Tenant: sb.Tenant, Volumes: types.VolumeNames(sb.Volumes), VolumesRW: types.VolumeRWNames(sb.Volumes), }) } diff --git a/sandboxd/pool/telemetry_test.go b/sandboxd/pool/telemetry_test.go index 15b5b4fd9..1849ab83a 100644 --- a/sandboxd/pool/telemetry_test.go +++ b/sandboxd/pool/telemetry_test.go @@ -146,6 +146,9 @@ func TestTenantStampedInJournals(t *testing.T) { if ev.Tenant != "acme" { t.Errorf("claim event tenant %q, want acme", ev.Tenant) } + if ev.Template != testKey.Template || ev.Size != string(testKey.Size) { + t.Errorf("claim event template %q size %q, want %q %q", ev.Template, ev.Size, testKey.Template, testKey.Size) + } } } if !found { diff --git a/sandboxd/usage/pg/pg.go b/sandboxd/usage/pg/pg.go index ac8c8afb5..cd7c7aa5a 100644 --- a/sandboxd/usage/pg/pg.go +++ b/sandboxd/usage/pg/pg.go @@ -22,11 +22,11 @@ const ( // cellLock serializes a cell's lands, so cursors commit in order and a reader paging by cursor never skips a row. cellLock = `SELECT pg_advisory_xact_lock(hashtextextended('sandboxd_usage/' || $1, 0))` - insertRow = `INSERT INTO sandboxd_usage (cell, node, line_sha256, t, ev, id, vm, key, tenant, volumes, volumes_rw, children, ref, upstream, secret, tx, rx) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + insertRow = `INSERT INTO sandboxd_usage (cell, node, line_sha256, t, ev, id, vm, key, template, size, tenant, volumes, volumes_rw, children, ref, upstream, secret, tx, rx) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) ON CONFLICT (cell, node, line_sha256) DO NOTHING` - listColumns = `cursor, node, t, ev, id, vm, key, tenant, volumes, volumes_rw, children, ref, upstream, secret, tx, rx` + listColumns = `cursor, node, t, ev, id, vm, key, template, size, tenant, volumes, volumes_rw, children, ref, upstream, secret, tx, rx` ) //go:embed schema.sql @@ -71,7 +71,7 @@ func (s *Store) Land(ctx context.Context, recs []usage.Record) error { } var batch pgx.Batch for _, r := range recs { - batch.Queue(insertRow, s.cell, r.Node, r.Digest[:], r.Time, r.Event.Event, r.ID, r.VMName, r.KeyHash, r.Tenant, r.Volumes, r.VolumesRW, r.Children, + batch.Queue(insertRow, s.cell, r.Node, r.Digest[:], r.Time, r.Event.Event, r.ID, r.VMName, r.KeyHash, r.Template, r.Size, r.Tenant, r.Volumes, r.VolumesRW, r.Children, strings.ReplaceAll(r.Reference, "\x00", "\uFFFD"), r.Upstream, r.Secret, r.Sent, r.Received) } return tx.SendBatch(qctx, &batch).Close() @@ -112,7 +112,7 @@ func (s *Store) Close() { func scan(row pgx.CollectableRow) (usage.Record, error) { var rec usage.Record - err := row.Scan(&rec.Cursor, &rec.Node, &rec.Time, &rec.Event.Event, &rec.ID, &rec.VMName, &rec.KeyHash, &rec.Tenant, + err := row.Scan(&rec.Cursor, &rec.Node, &rec.Time, &rec.Event.Event, &rec.ID, &rec.VMName, &rec.KeyHash, &rec.Template, &rec.Size, &rec.Tenant, &rec.Volumes, &rec.VolumesRW, &rec.Children, &rec.Reference, &rec.Upstream, &rec.Secret, &rec.Sent, &rec.Received) if err != nil { return usage.Record{}, fmt.Errorf("scan usage row: %w", err) diff --git a/sandboxd/usage/pg/pg_test.go b/sandboxd/usage/pg/pg_test.go index ca9c33ccf..773a6f35b 100644 --- a/sandboxd/usage/pg/pg_test.go +++ b/sandboxd/usage/pg/pg_test.go @@ -25,7 +25,7 @@ func TestLandListsInCursorOrderAndResumesFromTheMark(t *testing.T) { } t0 := time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC) full := usage.Event{ - Time: t0.Add(time.Microsecond), Event: "claim", ID: "sb_1", VMName: "sbx-1", KeyHash: "k", Tenant: "acme", + Time: t0.Add(time.Microsecond), Event: "claim", ID: "sb_1", VMName: "sbx-1", KeyHash: "k", Template: "rt:24.04", Size: "xlarge", Tenant: "acme", Volumes: []string{"data", "ro"}, VolumesRW: []string{"data"}, Reference: "ref", Upstream: "up:1", Secret: "gh", Sent: 7, Received: 9, } land(t, s, record("n1", 1, full), record("n2", 1, usage.Event{Time: t0, Event: "claim", ID: "sb_2"})) diff --git a/sandboxd/usage/pg/schema.sql b/sandboxd/usage/pg/schema.sql index de81d6562..6fc7cf813 100644 --- a/sandboxd/usage/pg/schema.sql +++ b/sandboxd/usage/pg/schema.sql @@ -8,6 +8,8 @@ CREATE TABLE IF NOT EXISTS sandboxd_usage ( id text NOT NULL, vm text NOT NULL DEFAULT '', key text NOT NULL DEFAULT '', + template text NOT NULL DEFAULT '', + size text NOT NULL DEFAULT '', tenant text NOT NULL DEFAULT '', volumes text[], volumes_rw text[], diff --git a/sandboxd/usage/usage.go b/sandboxd/usage/usage.go index 44f82a51b..09ba5f8ab 100644 --- a/sandboxd/usage/usage.go +++ b/sandboxd/usage/usage.go @@ -16,6 +16,8 @@ type Event struct { ID string `json:"id"` VMName string `json:"vm,omitempty"` KeyHash string `json:"key,omitempty"` + Template string `json:"template,omitempty"` + Size string `json:"size,omitempty"` Tenant string `json:"tenant,omitempty"` Volumes []string `json:"volumes,omitempty"` VolumesRW []string `json:"volumes_rw,omitempty"` From 317ce3046481aad5a88b004f83857aa86bb1c77b Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 2 Nov 2026 12:36:25 +0800 Subject: [PATCH 2/2] usage: pin the template and size wire names and document pre-upgrade lines Renaming either JSON tag passed every test, so the documented journal and API keys could break silently. The /v1/usage example now shows both fields, and the docs state that claim lines from v0.1.20 or earlier lack them. --- docs/sandboxd-api.md | 9 +++++---- sandboxd/server/usage_test.go | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/sandboxd-api.md b/docs/sandboxd-api.md index 1f056b759..63f1795d3 100644 --- a/docs/sandboxd-api.md +++ b/docs/sandboxd-api.md @@ -1017,8 +1017,8 @@ Always on: every lifecycle transition appends one JSONL event to "id": "sb_…", "vm": "sbx-…"}` plus `key` (the pool key's stable hash, claim events), `template` and `size` (the claim's template and size tier, claim events, so a consumer can price a sandbox by its shape without the claim -record, which is gone after release), `tenant` (the owning tenant, on every event of a tenant's claim), -`children` (fork) and `ref` (the promoted +record, which is gone after release), `tenant` (the owning tenant, on every +event of a tenant's claim), `children` (fork) and `ref` (the promoted template / checkpoint id, the egress host, or a `vmm_failed` reason). An egress event that injected credentials names them in `secret` (`gh,claim:`, never a value). An egress event routed through an [upstream proxy](egress.md#upstream-proxies) carries `upstream` (its `host:port`), and with @@ -1049,11 +1049,12 @@ Auth: root only (tenant tokens get 403). Reads the raw usage events of every node of the cell from the [shared usage table](deploy.md#shared-usage-table). - `GET /v1/usage?tenant=&since=