Skip to content

sandboxd: pluggable metadata sources backed by PostgreSQL (TenantSource, ConfigSource, Catalog) for 10,000 nodes and 1M users #291

Open

Description

@CMGS

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:

  • TenantSource
  • ConfigSource
  • Catalog

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:

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:

  1. 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.
  2. Desired state goes to PostgreSQL, read through a cache. That covers cell config, pool definitions and targets, tenants, and the template and checkpoint catalog.
  3. Fast-moving placement stays in gossip, within one cell: warm counts and membership.
  4. High-volume events never go to PostgreSQL: usage and audit stream to a log pipeline.
  5. 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:
  • 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_ref and metadata, needs none of this. TenantSource makes per-user tenants viable; it does not require them.

Pools at 10,000 nodes

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/tenants cannot return 1 M rows at once.
    • It owns invalidation, the negative cache and the lookup rate cap.
  • ConfigSource
  • 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

  1. Catalog for 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.
  2. TenantSource: cached resolve, notify invalidation, negative cache, paginated listing, fleet-wide quota leases.
  3. 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, and node_status.
  4. 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_status converging.

Open questions

  1. Which PostgreSQL schema owns migrations: a sandboxd migrate subcommand, or the data-source team's own tooling?
  2. Should tenant quota be fleet-wide (leases) or stay per node?
  3. How strict should revocation be: the notify interval for everything, or a direct source check on renew and fork as proposed above?
  4. Pool targets: a cell-level target split by sandboxd, or per-node-class targets set by the autoscaler?

Activity

  1. CMGS commented on Oct 17, 2026

    @CMGS
    ContributorAuthor

    Status: tenants.Source with 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 and node_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.

  2. CMGS commented on Oct 18, 2026

    @CMGS
    ContributorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions