Database
PostgreSQL requirements, connecting and pooling through PgBouncer, how migrations run, PGlite for development, the runtime tables and the lock order.
PostgreSQL is the only source of truth. Leases, the work queue, the outbox and every aggregate are tables, so
processes never coordinate through anything else, and a crashed process leaves nothing behind that the database
does not already know. Better Compute's store is PostgreSQL or PGlite only: it needs EXCLUDE constraints,
SKIP LOCKED, range types and xid8, so there is no SQLite adapter.
Requirements
| Requirement | Why |
|---|---|
| PostgreSQL 16 or newer | migrate() reads server_version_num and refuses anything below 160000 with INVALID_CONFIG. CI runs PostgreSQL 17. |
btree_gist | The first migration runs CREATE EXTENSION IF NOT EXISTS btree_gist. device_bookings needs it for text equality inside EXCLUDE USING gist (device_id WITH =, during WITH &&), the constraint that makes a double booking impossible. |
CREATE on the database | Migrations create tables, indexes and the compute_now() function. |
btree_gist is a trusted extension, so a database owner with CREATE can install it. On a managed service that
does not allow that, have a superuser run CREATE EXTENSION btree_gist once before the first migration. The
official postgres image ships it; examples/postgres/docker-compose.yml runs postgres:17.
The booking constraint is the behind every capacity guarantee: two overlapping bookings of one device cannot both commit, whatever the application does. See capacity.
Connecting
import { postgres } from 'better-compute/postgres';
const database = postgres({ url: process.env.BETTER_COMPUTE_DATABASE_URL!, max: 20, statementTimeoutMs: 15_000 });| Option | Default | What it does |
|---|---|---|
url | — | A postgres:// or postgresql:// connection string. Give this or pool. |
pool | — | A pg.Pool you own: TLS, IAM authentication hooks, a PgBouncer endpoint. close() leaves it open. |
max | 10 | Pool size when url is given, from 1 to 100. A connection attempt gives up after 10 seconds. |
statementTimeoutMs | none | SET LOCAL statement_timeout at the start of every transaction; standalone statements then run in a short transaction of their own. Migrations run without it. |
An idle connection that errors (a restarted server) is dropped and the next checkout reconnects; it never crashes the process.
Pooling and PgBouncer
Each process holds its own pool, so the total is roughly max times the number of processes. PLAN.md lists
connections as one of the first limits at scale and puts PgBouncer in front of PostgreSQL. postgres() is safe
behind PgBouncer in transaction mode:
- it keeps no session state: the statement timeout is
SET LOCAL, and no startup parameters are set; - it never uses
LISTEN/NOTIFY; - it uses only unnamed prepared statements;
- it takes no session-level advisory locks: concurrent migrators serialize on the transaction-level
pg_advisory_xact_lock(1650683248, 1), released atCOMMIT.
Leases are table rows with a TTL and an epoch rather than advisory locks for the same reason: session-level
pg_advisory_lock breaks under transaction pooling.
Transactions
transaction(fn, { isolation?, readOnly? }) runs fn on one connection between BEGIN and COMMIT at
read committed by default; guarded updates and row locks carry the invariants. Inside fn's async call chain the
executor joins the caller's transaction, and a nested transaction() becomes a savepoint. A statement error that
fn catches still aborts the transaction in PostgreSQL; the executor detects the COMMIT that became a ROLLBACK
and rejects with INTERNAL_ERROR. sqlState(error) reads the SQLSTATE, such as 23P01 for an exclusion
violation, from either driver.
Migrations
better-compute migrate runs compute.initialize(). It first checks every method's IAM action against the merged
catalog and the normative method table, and fails with INVALID_CONFIG before touching the store if one is
missing or wrong. Then it applies migrations, anchors the clock, and starts each module. It is idempotent, and
serve and dev run it on start unless --no-migrate.
better-compute migrate # once per deploy, before new processes start
better-compute serve --no-migrateThe ledger
Two tables record what ran: compute_migrations(name, applied_at) and compute_schema_version, whose version is
the number of base migrations applied. Every migration is named <owner>/<nnnn>_<slug> and:
- runs in its own transaction and is recorded in the ledger in that same transaction, so a failed one leaves nothing behind;
- is skipped when the ledger already has it;
- is never edited, renamed or removed once released. Numbers only grow within an owner.
migrate takes the advisory lock in every transaction, so two processes migrating at once serialize and the
second skips what the first applied. It refuses to run, with INVALID_CONFIG naming the entries, when the ledger
holds something this build cannot account for:
- a later number from an owner it knows: a newer release migrated this database;
- an unknown name at or below an owner's last number: a migration was renamed or removed;
- a base schema version above its own.
Migrations of owners this build has no migrations for are ignored, so dropping a module from the configuration does not block startup. Its tables stay.
Order
Migrations run in registration order:
- the base schema, owner
core, which is reserved for it; - the built-in modules: node controllers, site boot,
inventory,capacity(capacity/0001_device_linkstocapacity/0005_extend_holds, the one-extend-at-a-time hold of f36248d),tenancy(tenancy/0001_tenant_states),confirmations(confirmations/0001_bindings, 06d7f04),quorum(quorum/0001_batchestoquorum/0008_batch_agent, which records the agent that proposed a batch, f3c5c4e),pki/0001_sealed_secretswhen the default secret store is in use, and the tunnels module whentunnelsis set; - your
modules, in the order you list them; - your
plugins.
The base schema
| Migration | Adds |
|---|---|
core/0001_clock | btree_gist, compute_sim_clock, compute_now() |
core/0002_base | The frozen v0.1 schema: sites, pools, nodes, devices, device_bookings, node_resources, quota_usage, reservations, commands, work_queue, leases, workers, outbox, authz_projection, authz_changes, iam_inbox, join_tokens, gateways, allocations, usage windows and dead letters, node_certs, inventory_snapshots, audit_events, pool_capacity_rollup |
core/0003_v01 | v0.1 columns, node_inventory, node_inventory_sections |
core/0004_command_cancel | cancel_requested_at: cancelling a command after dispatch |
core/0005_audit_commit_order | audit_events.txid and seq, so audit readers page in commit order |
core/0006_pool_archive | Archived pools |
core/0007_device_health | device_health quarantines |
core/0008_session_versions | The daemon version and protocol a node announced in Hello |
core/0009_runtime | outbox_cursors, work_queue.attempts, commands.owner, tenant indexes |
core/0010_v02_server | join_requests, node_certs.allowed_kinds, nodes.deleted_at, secret_refs |
core/0011_v02_security | sites.deleted_at, attestation_uses |
core/0012_v02_evidence | node_bindings, node signatures of attested results |
core/0013_image_cache | node_image_cache |
core/0014_ssh_certificates | ssh_certificates, for break-glass revocation and audit |
core/0015_inventory_images | The images inventory section |
core/0016_duplicate_identity | node_sessions.duplicate_hellos and duplicate_since, for the duplicate-identity quarantine |
core/0017_allocation_evidence | allocations.bound_report, bound_sig_kid, bound_sig: node-attested AllocationBound |
core/0018_partitioned_history | audit_events, inventory_snapshots and usage_windows partitioned by month, and compute_history_partition() |
core/0019_outbox_dead_letters | outbox_dead_letters: events a sink always refuses, set aside |
core/0020_command_created_at | commands.created_at, for command latency, and the commands_settled index |
core/0021_signed_specs | nodes.spec_body and spec_signature: the signed bytes of each NodeSpec generation |
core/0022_ssh_certificate_sessions | ssh_certificates.session_id and reauthorized_at, so each break-glass certificate is re-decided for the session that issued it |
core/0023_session_trust_version | node_sessions.trust_version (bigint, default 0): the trust bundle version each daemon reports in Hello and StatusReport, which pki rotate-command-root --stage switch checks over connected sessions |
core/0024_session_capabilities | node_sessions.capabilities (text[], default empty): the Hello.capabilities of the current session; tunnels.open needs tunnel.instance-dial there for instance tcp and ssh tunnels |
core/0025_secret_scope | secret_refs.scope (text, tenant or fleet, default tenant): the scope secrets.put decided on, so fleet secrets stored in the root tenant can be told from the tenant's own. Rows from before it read tenant; a tenant-scoped put or delete of a fleet secret is INVALID_STATE, and a fleet put may claim a tenant row |
core/0026_session_approver_set | node_sessions.approver_set_epoch (numeric(20, 0), at least 0) and approver_set_sha256 (bytea, no length check, so a session write never fails over a daemon's report): the approver set the daemon reports enforcing in Hello and StatusReport, beside trust_version. Both nullable and NULL until a daemon reports them (the gateway writes NULL for an empty digest); doctor's pki.quorum-lag reads them (9e1b242) |
core/0027_join_token_hosted | join_tokens.hosted (text, CHECK (hosted IN ('kubernetes')), NULL for every other token): a token that a bcd DaemonSet joins a cluster's nodes with (1f0f683). joinTokens.create({ hosted }) writes it, and JoinService lands such a node tenant-controlled unless the pool's policy.kubernetes.platformOperated is true on a pool the platform owns (a27156a, 259d82c) |
core/0028_volume_allocations | allocations.owner_kind also allows volume: an allocation that a sandbox volume owns, booking its diskGiB on one host, scalar resources only, never devices, until the volume is gone (2a09202) |
Module migrations
| Owner | Migrations |
|---|---|
capacity | 0001_device_links, 0002_lease_health, 0003_device_counts (capacity_device_counts, kept by a trigger on devices), 0004_mig_layouts (capacity_mig_layouts, the MIG layout gate) |
tenancy | 0001_tenant_states (compute_tenant_states: suspended and deleted IAM tenants, and each teardown's teardown_done_at) |
confirmations | 0001_bindings (confirmation_bindings: per delegated confirmation, the tenant, delegation, action, resource, the SHA-256 of the text the person was shown and its expiry, so an approval admits only that request; always hosted; 06d7f04, see delegated confirmation) |
pki | 0001_sealed_secrets |
quorum | 0001_batches (command_batches, command_batch_scope, command_batch_members, command_batch_approvals: the v1.0 destructive-batch quorum; the module is always hosted), 0002_payload_digest (a scope leaf's payload_sha256, empty for any payload), 0003_trust_bundle_state (the published trust bundle's version and digest, so the version follows the content), 0004_trust_bundle_approver_set (the published quorum, to check approver-set signatures; since c6214dc it also records the set's epoch), 0005_batch_request (command_batches.request_sha256, so a commandBatches.create retry returns the same batch), 0006_trust_bundle_history (every published trust bundle digest with its version, so a stale replica never republishes an old bundle as newer), 0007_leaf_payload (command_batch_scope.payload_type_url and payload, a leaf's payload for approvers to decode; 2ca0ba4), 0008_batch_agent (command_batches.created_by_agent and delegation_id: the agent that proposed a batch and its delegation, so its reason reads as the agent's; f3c5c4e); see PKI |
iam | 0001_successors |
provision | 0001_workflows, 0002_images, 0003_workflow_owner, 0004_gated_mig_layout, 0005_image_pins (provision_image_pins: images a deploy keeps in its site's cache until its workflow ends) |
metal | 0001_nodes, 0002_addresses (metal_nodes.addresses: the machine's own IPs, from inspection or metal.enroll's addresses), 0003_tenant_root, 0004_bmc_credentials, 0005_batch (metal_nodes.batch_id, the bulk verb's batch), 0006_bmc_driver_options (metal_nodes.bmc_driver_options, a jsonb object or NULL: bmc.driverOptions, sent on every BMC operation; 3f19859), 0007_discovered (metal_discovered, each site agent's PXE discovery list keyed by agent and MAC, and metal_discovery_reports, the digest of each agent's last stored list; 3d26428), 0008_mac_source (metal_nodes.mac_source: operator, inspection or discovered; discovered_mac and discovered_mac_at, a candidate awaiting metal.confirmMac; 8de6e2f) |
vps | 0001_instances, 0002_sealed_user_data, 0003_provider_bootstraps, 0004_sandboxes (sandbox_templates, sandboxes, instances.sandbox_id and warm_template_id, and paused allowed in instances.desired_state), 0005_sandbox_snapshots (sandbox_snapshots, instances.restore_from, sandboxes.from_snapshot), 0006_warm_claims (instances.claimed_at), 0007_template_env (sandbox_templates.env), 0008_template_network (sandbox_templates.network and instance_version, instances.network_policy and warm_template_version), 0009_snapshot_fixes (sandbox_snapshots.principal_instance_id and purged), 0010_sandbox_metric_indexes, 0011_host_certificates (host_certified on instances and snapshots), 0012_host_cert_validity (host_cert_valid_before on both), 0013_sandbox_expiring (sandboxes.expiring_at), 0014_sandbox_extending (sandboxes.extending_request and extending_at), 0015_snapshot_agent (sandbox_snapshots.agent_id, backfilled from each snapshot's sandbox: a delegated restore takes only its own agent's snapshots; 7b01053), 0016_warm_secret_versions (instances.warm_secret_versions: the secret_refs.version of each secret a warm guest was given, so a rotation retires it; 2c7072b), 0017_identity_rebind (instances.identity_rebind_ref, rebind_cert_valid_before and identity_rebound_ref: a memory restore's own sealed guest identity, the validity of the host certificate in it, and the bundle the host confirmed it applied; 5346523), 0018_placement (flavors.placement and instances.host_placement, both jsonb: a flavor's numaAffine, pinned and hugepages, and where on its host an instance landed, which the next placement on that host reads to keep pinned CPUs exclusive; 9d55b36), 0019_ip_pool_dns (ip_pools.dns, inet[], default empty: the DNS servers a pool's IPAM hands its guests with the gateway; 387c22f), 0020_volumes (sandbox_volumes: a tenant's persistent volume, its size (1 to 16,384 GiB), serial, host and diskGiB allocation, state ready, deleting or deleted, and the host generation it left at, with a unique live name per tenant; volume_attachments: which instance attaches which volume, in order, read-only or not, at which mount path; sandbox_templates.volumes, jsonb; 2a09202), 0021_volume_resize_hold (sandbox_volumes.resizing_request and resizing_at: the one resize whose new size is pending in the IAM projection while it is decided, taken over after 60 s; 6cb30e9), 0022_volume_storage (host_volume_storage: each runtime host's volume store as its bcd last reported it in StatusReport.volume_storage, total, available, reserved, committed and outstanding bytes and reported_at, which volume bookings are checked against; 131f5ee) |
events | 0001_webhooks, 0002_delivery_retention, 0003_webhooks_by_tenant (an index for a deleted tenant's purge) |
fleet | 0001_names, 0002_applied |
rollout | 0001_rollouts |
audit | 0001_checkpoints, 0002_archives |
The modules reference is generated from a deployment with every module and lists the same migrations.
A module you write owns its own prefix; see modules.
Upgrading
Additive migrations ("expand") run before new code starts; PLAN.md puts destructive ones ("contract") in the following release, once no process of the old release is left. Released migrations never change, so a database migrated by release N+1 refuses release N; roll back code only together with the database it expects.
One clock
All SQL reads time through , never now(), and column defaults use it
too. scripts/check-sql-now.mjs fails CI on a bare now() in packages/*/src, except in core/0001_clock, which
defines the function. In production compute_now() is the server's clock, and each process anchors its business
time to it, re-sampling at every lease renewal and raising ClockSkew beyond 1 s.
For tests and the simulator, useSimClock(sql, startMs) points compute_now() at compute_sim_clock,
setSimClock(sql, ms) moves it forward (never back), and restoreSystemClock(sql) points it back. These are
database-wide. computeNowMode(sql) reports system, sim, custom or missing, and doctor reports a
compute_now() that differs from the database clock.
The runtime tables
These tables are how processes cooperate. You never write them by hand, but you will read them when you debug.
| Table | Role |
|---|---|
leases | name, owner, epoch, expires_at. Names: shard:<n>, pool:<id>, site:<id>:boot, singleton:<job>, gateway:<id>. |
workers | One row per worker process with its heartbeat; rendezvous hashing over the live rows assigns the 256 shards. |
work_queue | The coalescing : one row per (kind, key) with shard, gen, not_before, claimed_until. |
commands | Every command with its per-node seq, state, deadline and signature. |
outbox | Events written in the transaction that caused them: seq, txid (pg_current_xact_id()), topic, payload. |
outbox_cursors | Each sink's position, saved after every delivered batch. |
authz_changes | The authorization projection's change feed, in the outbox's (txid, seq) shape. |
gateways | The gateway directory: region, internal_url, version, max_streams, sessions, draining, heartbeat. |
Leases and fencing
A lease is acquired or renewed with one guarded update, and the epoch goes up whenever the owner changes:
UPDATE leases SET owner = $me, epoch = CASE WHEN owner = $me THEN epoch ELSE epoch + 1 END,
expires_at = compute_now() + make_interval(secs => $ttl)
WHERE name = $name AND (owner = $me OR expires_at < compute_now()) RETURNING epoch;Shard, pool and site leases live 15 s, gateway leases 20 s, and both renew every 5 s. Each fenced transaction
starts with SELECT 1 FROM leases WHERE name = $name AND owner = $me AND epoch = $e AND expires_at > compute_now() FOR SHARE, so a process that lost its lease either sees that and aborts, or holds up the takeover until its
transaction ends. A bare EXISTS would not fence under read committed.
The work queue
Enqueuing happens in the state-change transaction and coalesces: a key already queued gets gen = gen + 1 and the
earlier not_before. A worker claims due keys of its shards with FOR UPDATE SKIP LOCKED and sets
claimed_until; success deletes the row only WHERE gen = $claimedGen, so a key dirtied again while it was being
processed survives and no wake-up is lost. Reconcilers also re-enqueue everything they own every resyncMs.
The outbox
Commit order is not seq order, so readers never trust seq alone:
WHERE txid < pg_snapshot_xmin(pg_current_snapshot())
AND (txid, seq) > ($cursorTxid, $cursorSeq)
ORDER BY txid, seqNothing can commit behind the cursor. The same cursor serves the SSE Last-Event-ID, authz_changes and the
audit checkpoints. Delivery is at least once, with one lease per sink. Order by the table's own columns
(o.txid, o.seq), never by a text alias of txid, which would sort "10" before "2". The retention module
prunes rows every cursor has passed; see retention.
Lock order
Any transaction that locks several of these rows takes them in this order:
quota_usage → pools → nodes → devices → node_resources → pool_capacity_rollupReserve charges quota first and release refunds it first, so concurrent reservations and releases never wait on
each other in a cycle. The order was set by the capacity torture test, which found a quota and rollup deadlock
before it was fixed. Module code that touches capacity rows (node verbs, nodes.delete, join approvals, which lock
the pool first) follows it too.
PGlite
pglite({ dataDir?, extensions? }) from better-compute/pglite runs PostgreSQL in WebAssembly. better-compute dev, the test suites and the examples use it.
@electric-sql/pgliteis an optional peer dependency, loaded on the first statement.btree_gistis always included.- Without
dataDirthe database lives in memory.BETTER_COMPUTE_DATABASE_URL=pglite:DIRorpglite:memoryselects it from the environment. - There is one connection. Transactions and standalone statements run one at a time behind an async mutex, so code in a transaction that waits for another top-level transaction deadlocks. Concurrency tests belong on real PostgreSQL.
- It runs
ANALYZEafter migrations and periodically, so query plans in development match PostgreSQL's.
PGlite is for development and tests. The production-shaped examples/postgres refuses pglite: URLs. See
local development.
Backups
Back up the database and the PKI bundle together. The bundle holds the CAs every node certificate chains to and the
keys daemons pin; doctor tells you to restore it from backup if it cannot be read, since creating a new one makes
every node rejoin. See PKI.
Better Compute is created by Sean Filimon
Last updated
Configuration
The better-compute.config file, every betterCompute() option with its default, the host export that serve runs, environment variables and the deployment secret.
Scaling
The PLAN.md section 16 performance gates, what the benchmarks measured so far, how each tier scales, which placer to run, and what breaks first.