Repository navigation
sandboxd: pluggable metadata sources backed by PostgreSQL (TenantSource, ConfigSource, Catalog) for 10,000 nodes and 1M users #291
Description
Summary
sandboxd keeps all of its control state in node-local files: config.json, pools.json and tenants.json. A mesh shares only placement hints and a digest of the settings that must match. That design fits one mesh of hundreds of nodes (see the Scale section of docs/cluster.md). This issue covers how the state splits when the target is 10,000 nodes serving 1,000,000 users, and introduces PostgreSQL as an optional, strongly consistent source behind three interfaces:
TenantSourceConfigSourceCatalog
Each interface hides a cache over its source. When no PostgreSQL is configured, it falls back to today's file and gossip implementation with identical behavior.
Not in scope: changing the claim hot path, replicating claims, or putting per-request events into PostgreSQL.
Scale assumptions
| quantity | value | basis |
|---|---|---|
| nodes | 10,000 | target |
| registered users | 1,000,000 | target |
| users with a live sandbox at peak | 100,000–200,000 | 10–20 % concurrency |
| live sandboxes per node | 10–20 average | 200,000 / 10,000 |
| nodes per mesh (cell) | 300–500 | the gossip envelope below |
| cells | 20–35 | 10,000 / 300–500 |
| users homed per cell | 30,000–50,000 | 1,000,000 / cells |
| distinct nodes one user touches per day | 1–3 | warm-miss redirects inside a cell |
| tenant sign-ups or changes | a few per second at peak | product estimate, to confirm |
Measured numbers this analysis relies on:
- Gossip state per push/pull:
- 0.63 MB at 1,000 nodes and 6.27 MB at 10,000, each with 5 templates per node;
- 352.77 MB at 10,000 nodes with 500 templates per node;
- memberlist refuses state over 20 MiB and stretches push/pull to 10 s at 10,000 nodes (mesh: delta gossip and a compact template summary, so one cell scales past the full-state push/pull #290).
- Tenant set held in memory (sandboxd: manage tenants at runtime through /v1/tenants, persisted like pools.json #286):
- auth is 102 ns at 10,000 tenants;
- each change rebuilds and fsyncs the whole set, about 21 ms and 17.6 MB allocated at 10,000 tenants, growing linearly with N.
Today: every piece of state
config.json (read at boot; the reloadable subset after #288)
| field | what it is | scope | change rate | read on |
|---|---|---|---|---|
listen, advertise_addr, client_advertise |
node addresses | node | never | boot |
data_dir, cocoon_bin, cocoond_socket, checkpoint_dir |
node paths | node | never | boot |
bridges, networks |
egress-lane attachment | node (host network) | rare, restart | boot, VM create |
restore_mode, no_direct_io, no_balloon, vmm_restart, sync_claims |
engine and journal behaviour | node class | rare | boot |
refill_concurrency, max_claims, max_fork_count, release_delay_seconds |
node capacity knobs | node class | rare | claim, refill |
audit_log |
journal toggle | node class | rare | boot |
preview_listen, preview_advertise |
preview endpoint | node | never | boot |
mesh.node_id, mesh.bind, mesh.join |
membership | node | never | boot |
mesh.cluster_key |
gossip encryption key | cell | rotation | boot |
api_token |
root credential | fleet | rotation | every request |
preview_secret |
preview URL signing key | fleet (a URL may hit any node) | rotation | preview mint and verify |
egress_ca.root_cert |
interception trust root | fleet | rotation, rebuilds goldens | arm, golden build |
egress_ca.intermediate_cert / intermediate_key |
per-node signing pair | node | rotation | arm |
checkpoint_store (s3) |
shared checkpoint and template backend | cell or fleet | rare | capture, heal |
checkpoint_ttl_hours, checkpoint_peer_heal |
replica lifetime, heal | cell | rare | sweep, heal |
idle_hibernate_seconds, archive_after_seconds, archive_delete_after_seconds |
lifecycle defaults | cell | rare | sweeps |
volumes[] (name, writable, tenants) |
dataset catalog entry and its ACL | cell | rare | claim |
volumes[].path, directio |
where the image sits on this host | node | rare | claim |
secrets[] (name, header, value_env) |
which header a named credential fills; the value comes from env or the claim | cell | rare | every egress request |
egress_internal_allow |
SSRF re-admits | cell (depends on the cell's network) | occasional | every egress dial |
egress_upstream (claim_env, allow) |
upstream proxy allow list | fleet or cell | occasional | claim, every routed dial |
egress_usage_bytes |
byte usage events | fleet | rare | arm |
pools[] |
pool definitions plus warm targets (seed only once pools.json exists) |
see below | see below | see below |
tenants[] |
tenant identities plus their egress (seed only once tenants.json exists) |
see below | see below | see below |
Pools (config.json pools[], <data_dir>/pools.json)
| part | fields | owner today | scope | change rate |
|---|---|---|---|---|
| definition | template, net, size (the key), warmup, storage, capture_trim, egress, egress_upstream_env |
config.json |
cell: every node serving the key should build the same golden | per template release (days) |
| targets | warm, warm_max, idle_hibernate_seconds, archive_* |
pools.json via PUT /v1/pools or SetPoolsCluster |
node, or node class | autoscaler cadence (minutes) |
| live state | warm list, golden, building and refill counters | memory | node | every claim |
Tenants (config.json tenants[], <data_dir>/tenants.json)
| part | fields | owner today | scope | size at 1 M users | change rate |
|---|---|---|---|---|---|
| identity | name, token SHA-256 |
tenants.json via /v1/tenants |
fleet: the SDK replays the token across a redirect | 1 M rows if every user is a tenant | sign-ups, a few per second |
| quota | max_claims |
same | per node today; intended per tenant fleet-wide | same | rare |
| egress layer | egress, egress_upstream_env |
config.json by name |
fleet | per policy class, not per user | rare |
| live count | claims per tenant | memory (tenantLive) |
node | active tenants only | every claim |
Runtime state that is not configuration
| state | where today | propagation | scope |
|---|---|---|---|
| claims | claims journal per node | none, single owner | node |
| warm counts | memory | gossip, full state per push/pull | cell |
| promoted templates | template store plus gossiped key hashes (tenant scope and root scope) | gossip | cell |
| checkpoints | checkpoint store | live probe per request, heal pulls | cell |
| sandbox to owner | the owner's journal | SDK Lookup scatter |
cell |
| usage, audit | JSONL journals per node | none | node |
Where each piece belongs at 10,000 nodes and 1 M users
Placement rules:
- Node bootstrap stays in the local file. That covers node identity, paths, attachment, engine knobs and the new metadata store DSN. None of it is shared.
- Desired state goes to PostgreSQL, read through a cache. That covers cell config, pool definitions and targets, tenants, and the template and checkpoint catalog.
- Fast-moving placement stays in gossip, within one cell: warm counts and membership.
- High-volume events never go to PostgreSQL: usage and audit stream to a log pipeline.
- Secret values never go to PostgreSQL:
secrets[]defines names and headers; values come from the node environment, a secret manager or the claim env.
| item | target owner | consistency | read path cost | PG down |
|---|---|---|---|---|
| node bootstrap block | local file | n/a | boot | unaffected |
api_token, preview_secret, mesh.cluster_key, CA root |
secret distribution (file or secret manager), not PG; ClusterDigest keeps checking that they match |
rotation is a planned two-phase rollout | per request, local | unaffected |
| cell config (egress layer, lifecycle defaults, volume catalog, checkpoint settings) | ConfigSource: one versioned document per cell |
write strong; apply eventual, convergence visible in node_status |
none; applied through the #288 reload | last applied version keeps serving |
| pool definitions | ConfigSource, in the cell document |
same | golden build | keeps last |
| pool targets | ConfigSource: a per-cell target, or a per-node-class target that each node turns into its share |
same | refill | keeps last |
| tenant identity and token | TenantSource: table plus a per-node cache of active tenants |
write strong; revocation reaches nodes within the notify or poll interval | first request per tenant per node: one indexed lookup (~1 ms); afterwards a map hit | cached tenants keep working; a cold tenant fails closed (401/503) |
| tenant quota (fleet-wide) | TenantSource plus quota leases: a node reserves a block, for example 10 claims, and returns what it does not use |
the cap is exact up to the outstanding leases | claim spends a local lease; PG is touched only on refill | leases already granted keep working |
| tenant egress layer | ConfigSource, keyed by tenant class or name |
as cell config | per request, via livePolicy |
keeps last |
| template catalog | Catalog table (id, key hash, scope, owner nodes, digest) |
write strong at promote; owner hints may go stale, and redirect falls back | one indexed lookup on a local miss | falls back to gossip and probe inside the cell |
| checkpoint catalog | Catalog table, the same shape |
write strong at capture | one lookup instead of a peer probe fan-out | falls back to the probe |
| sandbox directory | Catalog or its own table, filled from the node journal through an outbox |
eventual (seconds); the journal stays the truth | Lookup becomes one query instead of a scatter |
falls back to the scatter |
| warm counts | gossip, per cell, coarse buckets (#290) | eventual (seconds within a cell) | redirect decisions | unaffected |
| cell capacity summary for the global router | PG, written by cells every few seconds | eventual | router only | router uses the last value |
| usage, audit | log pipeline (object store or a queue), not PG | at-least-once | none | buffered on the node |
Tenants at 1 M users
- Memory: a node caches only tenants active on it. That is tens to hundreds per node, against 1 M rows held on every node with the current set.
- Cold lookups: 1 M users × 1–3 nodes a day is about 3 M lookups a day, or roughly 35 per second averaged and a few hundred per second at peak. An indexed PK lookup on a replica handles that easily.
- Writes: each sign-up is one row insert plus one notify. Today it is an O(N) rebuild and fsync on every node.
- Revocation:
- A notify (LISTEN/NOTIFY through a per-cell listener, or a version poll) evicts the entry everywhere within seconds.
- Operations that extend a claim's life (renew, fork) may check the source directly to make revocation immediate.
- The removed-tenant seals from sandboxd: manage tenants at runtime through /v1/tenants, persisted like pools.json #286 keep working, with "absent from the set" replaced by "revoked or deleted in the source".
- Negative cache:
- An unknown token must not reach PG on every request. Cache misses for a short TTL, and cap the source lookups per second per node.
- Without both, a stream of random tokens loads the database.
- The alternative stays open: one tenant per control plane, with users in
claim_refandmetadata, needs none of this.TenantSourcemakes per-user tenants viable; it does not require them.
Pools at 10,000 nodes
- One template release becomes a single cell-document change that every node in the cell applies through the reload path. Goldens are rebuilt where the stamp changed (sandboxd: reload config-owned settings at runtime (per-pool egress/warmup/storage, new template keys) without a restart #288). Today the same release is an edit and a restart on every node.
- Targets move from per-node
pools.jsonto a cell-level desired state. An autoscaler writes one row per cell instead of fanning out to every node, andPUT /v1/poolsbecomes a write to the source.
Config distribution at 10,000 nodes
- Poll load: every node polls one version row on a replica, with jitter. At a 5 s interval that is 2,000 trivial reads per second fleet-wide.
- Fetch on change: the document (kilobytes) is fetched only when the version moves.
- Applied version: each node writes its applied version and the outcome to
node_status. Convergence is a query, and a node that refused a reload names the reason there. - Connection count: 10,000 nodes must not hold direct connections. Put PgBouncer in front, route reads to replicas, and allow a separate DSN for reads and writes.
Interfaces
They follow the repository layout: the interface sits in the parent package, each implementation in a subpackage, and config.json picks one:
{"meta_store": {"kind": "pg", "dsn_env": "SANDBOXD_PG_DSN", "read_dsn_env": "SANDBOXD_PG_READ_DSN"}}meta_store is read at boot only. When it is absent, every source is the file or gossip implementation, and behavior is unchanged.
TenantSource- Methods: resolve by token SHA-256 (hot path, cached); put, delete and replace; and a paginated list, because
GET /v1/tenantscannot return 1 M rows at once. - It owns invalidation, the negative cache and the lookup rate cap.
- Methods: resolve by token SHA-256 (hot path, cached); put, delete and replace; and a paginated list, because
ConfigSource- Methods: return the current cell document with its version, notify on a version change, and record the applied version or the refusal.
- The file implementation is the local file plus SIGHUP or
POST /v1/config/reload. That is what sandboxd: reload config-owned settings at runtime (per-pool egress/warmup/storage, new template keys) without a restart #288 builds: itsReloadConfig(ctx, *config.Config)is already the seam.
Catalog- Methods: put, delete, and find the owners of a template or checkpoint by key and scope.
- The fallback implementation is today's gossip plus probe.
Each interface lands in the same PR as its PostgreSQL implementation. That PR also moves today's code behind it unchanged and adds one contract test suite that runs against both implementations. The PostgreSQL leg runs only when SANDBOXD_TEST_PG_URL is set and is reported as skipped otherwise.
Phases
Catalogfor templates and checkpoints. Templates leave the gossip, which takes the template part of mesh: delta gossip and a compact template summary, so one cell scales past the full-state push/pull #290 and the root-scope key, and checkpoint owner probes become one lookup. This is the prerequisite for cells.TenantSource: cached resolve, notify invalidation, negative cache, paginated listing, fleet-wide quota leases.ConfigSource: a versioned cell document applied through the sandboxd: reload config-owned settings at runtime (per-pool egress/warmup/storage, new template keys) without a restart #288 reload, pool definitions and targets moved into it, andnode_status.- Sandbox directory and the usage/audit pipeline.
Cell routing (user to cell) sits in front of sandboxd, in the control plane, and is outside this issue. sandboxd only has to keep each mesh within its envelope.
Acceptance per phase
- Hot path:
- claim and per-request latency are unchanged with PostgreSQL configured, on a cache hit, measured A/B in one run;
- a cold tenant costs one lookup;
- there is no PG round trip on the claim path.
- PG outage:
- with the primary and replicas stopped, running claims, exec and egress keep working;
- cached tenants authenticate and uncached ones fail closed;
- writes return a clear error;
- recovery needs no restart.
- Hardware: a two-cell testbed (two meshes) sharing one PostgreSQL, running the cluster regression plus one leg per phase: a tenant added in one cell authenticates in the other, a template promoted in one is found from the other, and a config version applies across a cell with
node_statusconverging.
Open questions
- Which PostgreSQL schema owns migrations: a
sandboxd migratesubcommand, or the data-source team's own tooling? - Should tenant quota be fleet-wide (leases) or stay per node?
- How strict should revocation be: the notify interval for everything, or a direct source check on renew and fork as proposed above?
- Pool targets: a cell-level target split by sandboxd, or per-node-class targets set by the autoscaler?
Activity
sandboxd: tenants behind tenants.Source, with a shared PostgreSQL bac…
CMGS commentedon Oct 17, 2026
Status:
tenants.Sourcewith a PG backend landed in #295 (per-node cache, NOTIFY invalidation, negative cache, cold-lookup rate cap, paginated list), and the cell-shared pool set in #312. Open:Catalog(phase 1), the cell config document andnode_status(phase 3, beyond pools), fleet-wide quota leases, a separate read DSN, and the sandbox directory (phase 4). #353 adds a per-cell PG archive registry.CMGS commentedon Oct 18, 2026
Note: #366 (merged) adds periodic claim replication behind a PostgreSQL node lease (
sandboxd_nodes, replica rows in the archive registry). It is outside this issue's scope but shares the meta store DSN and adds to its load (one lease upsert per node every 5 s, one row write per replicated claim per interval), so count it when sizing the connection pooler and replicas.