better/compute

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

RequirementWhy
PostgreSQL 16 or newermigrate() reads server_version_num and refuses anything below 160000 with INVALID_CONFIG. CI runs PostgreSQL 17.
btree_gistThe 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 databaseMigrations 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 });
OptionDefaultWhat 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.
max10Pool size when url is given, from 1 to 100. A connection attempt gives up after 10 seconds.
statementTimeoutMsnoneSET 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 at COMMIT.

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-migrate

The 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:

  1. the base schema, owner core, which is reserved for it;
  2. the built-in modules: node controllers, site boot, inventory, capacity (capacity/0001_device_links to capacity/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_batches to quorum/0008_batch_agent, which records the agent that proposed a batch, f3c5c4e), pki/0001_sealed_secrets when the default secret store is in use, and the tunnels module when tunnels is set;
  3. your modules, in the order you list them;
  4. your plugins.

The base schema

MigrationAdds
core/0001_clockbtree_gist, compute_sim_clock, compute_now()
core/0002_baseThe 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_v01v0.1 columns, node_inventory, node_inventory_sections
core/0004_command_cancelcancel_requested_at: cancelling a command after dispatch
core/0005_audit_commit_orderaudit_events.txid and seq, so audit readers page in commit order
core/0006_pool_archiveArchived pools
core/0007_device_healthdevice_health quarantines
core/0008_session_versionsThe daemon version and protocol a node announced in Hello
core/0009_runtimeoutbox_cursors, work_queue.attempts, commands.owner, tenant indexes
core/0010_v02_serverjoin_requests, node_certs.allowed_kinds, nodes.deleted_at, secret_refs
core/0011_v02_securitysites.deleted_at, attestation_uses
core/0012_v02_evidencenode_bindings, node signatures of attested results
core/0013_image_cachenode_image_cache
core/0014_ssh_certificatesssh_certificates, for break-glass revocation and audit
core/0015_inventory_imagesThe images inventory section
core/0016_duplicate_identitynode_sessions.duplicate_hellos and duplicate_since, for the duplicate-identity quarantine
core/0017_allocation_evidenceallocations.bound_report, bound_sig_kid, bound_sig: node-attested AllocationBound
core/0018_partitioned_historyaudit_events, inventory_snapshots and usage_windows partitioned by month, and compute_history_partition()
core/0019_outbox_dead_lettersoutbox_dead_letters: events a sink always refuses, set aside
core/0020_command_created_atcommands.created_at, for command latency, and the commands_settled index
core/0021_signed_specsnodes.spec_body and spec_signature: the signed bytes of each NodeSpec generation
core/0022_ssh_certificate_sessionsssh_certificates.session_id and reauthorized_at, so each break-glass certificate is re-decided for the session that issued it
core/0023_session_trust_versionnode_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_capabilitiesnode_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_scopesecret_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_setnode_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_hostedjoin_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_allocationsallocations.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

OwnerMigrations
capacity0001_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)
tenancy0001_tenant_states (compute_tenant_states: suspended and deleted IAM tenants, and each teardown's teardown_done_at)
confirmations0001_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)
pki0001_sealed_secrets
quorum0001_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
iam0001_successors
provision0001_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)
metal0001_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)
vps0001_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)
events0001_webhooks, 0002_delivery_retention, 0003_webhooks_by_tenant (an index for a deleted tenant's purge)
fleet0001_names, 0002_applied
rollout0001_rollouts
audit0001_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.

TableRole
leasesname, owner, epoch, expires_at. Names: shard:<n>, pool:<id>, site:<id>:boot, singleton:<job>, gateway:<id>.
workersOne row per worker process with its heartbeat; rendezvous hashing over the live rows assigns the 256 shards.
work_queueThe coalescing : one row per (kind, key) with shard, gen, not_before, claimed_until.
commandsEvery command with its per-node seq, state, deadline and signature.
outboxEvents written in the transaction that caused them: seq, txid (pg_current_xact_id()), topic, payload.
outbox_cursorsEach sink's position, saved after every delivered batch.
authz_changesThe authorization projection's change feed, in the outbox's (txid, seq) shape.
gatewaysThe 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, seq

Nothing 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_rollup

Reserve 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/pglite is an optional peer dependency, loaded on the first statement. btree_gist is always included.
  • Without dataDir the database lives in memory. BETTER_COMPUTE_DATABASE_URL=pglite:DIR or pglite:memory selects 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 ANALYZE after 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.

Was this page helpful?

Better Compute is created by Sean Filimon

Last updated

On this page