Consistency
Why double-booking cannot happen in Better Compute, and how one clock, exclusion constraints, the outbox, the work queue and fencing keep the fleet consistent.
A GPU fleet is a distributed system with slow, unreliable members: daemons that reconnect, workers that crash mid-transaction, gateways that lose their database for thirty seconds. Better Compute stays correct through all of that by putting its invariants where they cannot be bypassed, in PostgreSQL, and by making every message safe to deliver twice. This page explains the mechanisms and what they guarantee.
PostgreSQL is the truth
Everything authoritative is a row. Everything in memory is a cache that can be rebuilt:
| In memory | Rebuilt from |
|---|---|
| A gateway's sessions and liveness ages | node_sessions, and the daemons reconnecting |
| The IAM resolver's authorization projection | authz_projection, tailed through authz_changes |
| The 30-second authentication cache and the 10-second read-decision cache | Better IAM, on the next miss |
| Pokes that wake a gateway or a worker early | Nothing: every loop also polls, so a lost poke only delays |
Aggregates carry a version. A transition is UPDATE … WHERE id = $id AND version = $v; zero rows means
someone else changed the record first, and the caller gets CONFLICT. Which transitions are legal comes from the
state tables in @better-compute/core (reservationStateTable, commandStateTable, provisionStateTable, …), so
the rules live in one place.
One clock
Every time comparison in SQL (lease expiry, not_before, not_after, booking ranges, liveness) reads
compute_now(), never now(). The first migration defines it:
-- defines compute_now()
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- Tests only: computeFixture({ clock: 'sim' }) points compute_now() here (useSimClock).
CREATE TABLE compute_sim_clock (id int PRIMARY KEY CHECK (id = 1), ts timestamptz NOT NULL);
CREATE FUNCTION compute_now() RETURNS timestamptz LANGUAGE sql STABLE AS $$ SELECT now() $$;- Simulated time. Tests and the simulator replace the body with
SELECT ts FROM compute_sim_clock WHERE id = 1(useSimClockandsetSimClockin@better-compute/postgres;computeFixture()does it by default). Leases, deadlines and liveness then all expire in simulated time, deterministically. - Enforced.
scripts/check-sql-now.mjsfails CI onnow(),CURRENT_TIMESTAMP,LOCALTIMESTAMP,transaction_timestamp(),statement_timestamp()orclock_timestamp()in any string inpackages/*/src, except in the migration that definescompute_now()and on lines markedcheck-sql-now: allow. - TypeScript time. Business time in TypeScript comes from a
Clockanchored tocompute_now(): sampled atinitialize()and at every lease renewal. A process that drifts more than one second logsClockSkew, andselfCheck()reportsclock.skew. - Gateways report ages, not timestamps. A gateway flushes "last seen 3.2 seconds ago", and the database turns it
into
compute_now() - 3.2 s, so a gateway with a wrong clock cannot fake liveness.
Why double-booking cannot happen
Every claim on a device is a row in device_bookings, and the database refuses overlapping rows whatever the
scheduler, the API or a buggy module does:
CREATE TABLE device_bookings (
device_id text NOT NULL REFERENCES devices,
allocation_id text NOT NULL,
during tstzrange NOT NULL CHECK (NOT isempty(during)),
EXCLUDE USING gist (device_id WITH =, during WITH &&)
);Five rules together close every gap:
- Device-time exclusivity. The exclusion constraint allows one live booking per per instant.
- The atom rule. A flat constraint cannot see that a MIG instance overlaps its parent GPU, so parents and
children are never both bookable: a MIG GPU exposes only its instances, a time-slice or MPS GPU only its slots
(
<uuid>/s<k>). Changing a GPU's mode or layout books every atom of the GPU, parent included, for the change window, so the change commits only when nothing on the GPU is booked. - Scalars. vCPU, memory and disk are counters, changed only by guarded updates
(
allocated + x <= floor(allocatable * ratio)) under row locks, with aCHECKconstraint as the backstop. - Eligibility is linearizable. The placer locks candidate node rows and the chosen atoms
FOR SHARE. Cordoning, tainting, marking a node unreachable and quarantining a GPU are updates to those same rows, so whichever transaction commits second sees the first. - Quota.
UPDATE quota_usage SET used = used + n WHERE … AND used + n <= maxin the same transaction; zero rows isQUOTA_EXCEEDED. A reservation holds its charge in every non-terminal state,pendingincluded.
As defense in depth, the daemon also refuses a device assignment that overlaps one it already holds, and reports the allocation as failed. The capacity torture test attacks all five rules at once: 1,000 concurrent operations with cordons, taints, health flips and releases, checking every invariant.
Conflicts do not abort the request
The guarded statements return zero rows instead of raising a constraint error, and bookings use
INSERT … ON CONFLICT DO NOTHING, which honors exclusion constraints. Each placement attempt runs under a savepoint:
a node, atom or counter conflict rolls back to it and replans without the conflicting node, at most three times.
After that the reservation becomes pending with an Unschedulable condition and is retried every 30 seconds. A
quota failure is final for the request.
Transactions that lock several of these tables take them in one order, which rules out deadlocks between reserve
and release: quota_usage, then pools, nodes, devices, node_resources, pool_capacity_rollup. Reserving
charges quota first; releasing refunds it first.
Side effects leave through the outbox
Nothing external happens inside a transaction. Events are rows in outbox, written in the same transaction as the
change they describe, as { id, type, tenantId, subject, data, time }. Webhook delivery, the SSE stream and
in-process subscribers read them afterwards. If the transaction rolls back, the event never existed.
Commit order is not sequence order, so a naive WHERE seq > $cursor reader could skip a row that commits late. The
reader only takes rows whose transaction is older than every transaction still running:
SELECT … FROM outbox o
WHERE o.txid < pg_snapshot_xmin(pg_current_snapshot())
AND (o.txid, o.seq) > ($cursorTxid, $cursorSeq)
ORDER BY o.txid, o.seqNothing can then appear behind the cursor. Each event sink is delivered by the worker holding that sink's
singleton:outbox-dispatch:<sink id> lease, which saves the sink's position in outbox_cursors after every batch,
so a new lease holder resumes where the last one stopped. Delivery is at least once: consumers deduplicate by event id. The same cursor shape
serves the SSE stream's Last-Event-ID and the authz_changes feed. See events.
The work queue never loses a wake-up
Reconcilers are woken through work_queue, enqueued in the transaction that changed the state:
INSERT INTO work_queue (kind, key, shard, gen, not_before) VALUES (…)
ON CONFLICT (kind, key) DO UPDATE SET gen = work_queue.gen + 1, …A worker claims due rows of its own shards with FOR UPDATE SKIP LOCKED and stamps claimed_until. When the run
succeeds it deletes the row only if gen still equals the value it claimed; a key dirtied again during the run
survives and runs again. Because reconcilers are level-triggered and also resync everything they own every few
minutes, even a lost row costs only time. See desired state and commands.
At least once, made safe
Every hop may deliver twice, so every receiver can tell a duplicate from new work:
| Mechanism | Where | What it prevents |
|---|---|---|
Idempotency keys scoped to (tenant, identity, key) | reservations.create, reservations.extend, instances.create, commands.run; optional on sites.create and pools.create | A retried request booking twice; another identity squatting a key |
UNIQUE (node_id, idempotency_key) | Commands (wf:<workflowId>:<stepId> for workflow steps) | A step sending its command twice |
cmp:<allocationId>:<windowStart> | Usage windows sent to Better IAM billing | Double billing across worker restarts |
| Session epoch | Routing and liveness writes carry AND session_epoch = $epoch; dispatch locks the session row | A zombie gateway routing or marking a node after it lost the session |
| Lease epoch | The first statement of every fenced transaction | A worker acting after its lease was taken over |
| Generation | min_generation on commands, observedGeneration on nodes | A command running against a spec the node has not applied |
| Journal id | A new daemon journal fails in-flight commands that are not retry-safe | A wipe or deploy replayed after the daemon lost its memory |
Leases and fencing
Leases are rows in leases, not advisory locks: session-level advisory locks break under PgBouncer's transaction
pooling, and a table gives both a time to live and an epoch.
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;
-- the first statement of each fenced transaction
SELECT 1 FROM leases WHERE name = $name AND owner = $me AND epoch = $epoch
AND expires_at > compute_now() FOR SHARE;Shard, pool, site and singleton leases live 15 seconds and are renewed every 5; gateway leases live 20 seconds. A
takeover increments the epoch and blocks until the old holder's FOR SHARE transaction ends, so a zombie either
still sees its lease as valid or aborts. A plain EXISTS check would not be a fence under READ COMMITTED; the row
lock is.
Disconnected is not dead
When a daemon stops talking, Better Compute changes a condition, not ownership:
| Transition | When | What happens |
|---|---|---|
connected to disconnected | The session closes or its gateway's lease expires | Nothing is released |
disconnected to unreachable | 40 seconds without a session, plus grace: 120 s after a gateway loss, 5 minutes after a database or controller outage. A node that joined but never opened a session counts from its join (its newest certificate), not from the beginning of time | The node leaves placement (unreachable joins noScheduleReasons, NodeUnreachable is raised); nothing is released |
unreachable to lost | The pool's lostAfter, only for provider and vps-host pools that set it | Never automatic for bare metal |
Workloads on an isolated node keep running, and its reservations keep their devices and keep billing on reserved time. A daemon that comes back supersedes its old session by epoch and replays its journal. If the machine really is gone, an operator decides, by draining, releasing or retiring it. See failure modes.
Better Compute is created by Sean Filimon
Last updated
Desired state and commands
How Better Compute tells a node what to do, with generation-numbered NodeSpecs, level-triggered reconcilers and durable commands for one-shot actions.
IAM integration
How Better Compute uses Better IAM as its only identity provider, policy engine, billing ledger and flag store, and how the IAM bridge wires the two.