Gateway
The gateway holds daemon sessions in memory and writes what matters to PostgreSQL. Its loops, dispatch by join, group commit, lease, control API, tunnels and metrics.
The is the process every daemon connects to. It terminates the daemons' mTLS,
keeps their sessions, heartbeats and lane counters in memory, dispatches commands, ingests results, events,
status and inventory, relays Join, Renew and secret requests, and relays interactive tunnels. It holds no CA and
no signing key (principle P14), and PostgreSQL stays the only source of truth (P1): restarting a gateway loses open
streams and nothing else.
@better-compute/gateway implements it in TypeScript on node:http2. It depends on core, protocol,
inventory, pki and the server's types only; the server never imports it.
Running a gateway
better-compute serve runs the gateway role from the deployment's configuration; see
deployment. Embedded in your own process:
import { createGateway, serveGateway, watchTlsFiles } from '@better-compute/gateway';
const gateway = createGateway({
region: 'eu-1',
database, // the compute store: postgres() or pglite()
services: compute.gatewayServices, // JoinService, renewal, secret broker
work: compute.context.work,
events: compute.context.events,
revocations, // pki RevocationSet: refused at open, pushed to live sessions
reports: gatewayReportHandlers, // allocation transitions from @better-compute/server
});
await gateway.start(); // directory row and the gateway:<id> lease
const served = await serveGateway(gateway, {
port: 443,
tls: { key, cert, ca: nodeCaPem },
nodeRoots: [nodeCa.certificate], // certificateAuthenticate; there is no weaker default
revocations,
});
// A renewed serving certificate (pki issue-gateway) reaches new handshakes; open sessions keep theirs.
watchTlsFiles({ key: '/etc/better-compute/tls/gateway.key', cert: '/etc/better-compute/tls/gateway.crt' }, (tls) =>
served.setTls(tls),
);serveGateway, serveGatewayWs and serveInternal each return setTls({ key, cert, ca? }), a hot reload of the
listener's certificate over its original TLS options; a key that does not match its certificate throws and changes
nothing. watchTlsFiles (from @better-compute/gateway) polls the files every 60 seconds by default and reports a
broken set to onError. serve does this for you with host.gateway.tlsFiles; see
PKI.
| Option | Default | Meaning |
|---|---|---|
id | a fresh gw_... id | One per process |
region | Required | |
internalUrl | Where the api reaches this gateway's control API (gateways.internal_url) | |
maxStreams | 5,000 | Sessions this gateway admits |
heartbeatIntervalMs | 10,000 | Sent in Welcome |
sessionLeaseTtlMs | 40,000 | The daemon liveness lease, sent in Welcome |
credits | control 8, provision 4, bulk 2 | Commands in flight per node and lane |
handshakesPerSecond | 200 | Session opens and joins admitted per second |
maxPendingHellos | 64 | Hello transactions in flight at once |
protocol | PROTOCOL_VERSION (1..1) | The { min, max } protocol range this gateway speaks, for skew tests and staged bumps; see version skew |
duplicateHellos | { max: 3, windowMs: 600000 } | More than max duplicate Hellos (a second live session from another boot) within windowMs quarantine the certificate; see duplicate identity |
gatewayLeaseTtlMs, leaseRenewMs | 20,000, 5,000 | The gateway:<id> lease |
livenessFlushMs | 15,000 | How often ages are flushed |
dispatchIntervalMs, dispatchBatch | 1,000, 1,000 | The dispatch loop |
pokeWindowMs | 10 | Minimum spacing of poke-driven dispatch runs |
specSweepMs | 10,000 | How often open sessions are checked for a newer generation |
busyBackoff | 1,000 to 30,000 ms | Back-off for commands answered BUSY |
trustBundle | A function returning the pre-signed trust bundle for Welcome, read per session | |
revocations | Revoked serials; without it, start() warns that revoked certificates keep their sessions until they expire | |
tunnels | Exec-ticket verification and re-authorization (v0.3) | |
reports | Report and status handlers, below | |
commandOwner | Maps a command's idempotency key (<reconciler>:<key>:…) to the work item that owns it, which is woken when the command settles, so a reconciler waiting on a result sees it at once. better-compute serve and the dev stack pass settledCommandOwner(compute) (exported from the CLI), which accepts only reconcilers registered in the deployment | |
manual | false | No timers: tests and the simulator step gateway.jobs themselves |
Memory and PostgreSQL
| In memory, lost on restart | In PostgreSQL |
|---|---|
| Open sessions: node, epoch, role, boot and journal id, peer identity | node_sessions: routing, epoch, boot and journal id, event ack, last seen, versions |
| Last-seen time per session | Flushed as ages every 15 s |
| Lane counters and in-flight command ids | commands: state, epoch, attempt, result |
| The generation last sent per session | nodes.generation, nodes.spec, nodes.observed_generation |
| The latest status report per session | Written about once a second |
BUSY counts, cancels already sent | commands.not_before, cancel_requested_at |
| Open tunnels | nothing: a tunnel dies with its gateway |
| The lease token | leases row gateway:<id>; gateways directory row |
Everything in the left column is rebuilt by the next Hello, which is why a gateway crash costs a reconnect and
nothing more; see failure modes.
The gateways table is the directory: id, region, internal_url, software version, max_streams, current
sessions, draining, started_at and heartbeat_at. start() upserts the row, and every lease renewal updates
the session count and drain flag, which doctor reads for headroom.
The loops
| Job | Period | Does |
|---|---|---|
lease | 5 s | Renews gateway:<id>; fences the gateway when it cannot |
dispatch | 1 s, and on pokes | Sends pending commands; at most once per interval, sends Cancel for in-flight commands whose cancel was requested |
liveness | 5 s | Closes silent sessions (three missed heartbeats) and sessions with expired certificates; sweeps tunnels; every 15 s, flushes ages and drops zombie sessions |
specs | 10 s | Pushes a full spec to sessions whose node's generation moved past what they hold and were sent |
status | 1 s | Writes each session's latest status report and runs the status handlers |
gateway.status() reports each loop's consecutive failures, total failures, last clean run and last error, so a
stuck loop can be alerted on. GET /v1/health on the control API returns the same object.
Dispatch by join
Routing is a join between commands and node_sessions at dispatch time, never a column copied onto commands:
SELECT c.*, s.session_epoch FROM commands c JOIN node_sessions s USING (node_id)
WHERE s.gateway_id = $me AND c.state = 'pending'
AND c.not_before <= compute_now() AND c.not_after > compute_now()
ORDER BY c.node_id, c.seq LIMIT 1000
FOR UPDATE OF c SKIP LOCKED FOR SHARE OF sSKIP LOCKED lets several gateways and runs work side by side, and the FOR SHARE lock on the session row makes a
dispatch and a concurrent Hello epoch bump serialize. A row is marked dispatched only when its session epoch
matches the gateway's live session and the lane has credit; a session that closed after commit leaves the row
dispatched, and the next Hello replays it. Because routing is a join, a reconnect needs no rewrite and a command
enqueued during a reconnect cannot be stranded. Enqueues poke the gateway after commit; pokes are coalesced, so a
burst costs a few dispatch passes, and a Hello pokes only when the node has pending commands, so a reconnect storm
does not turn every Hello into a pass over every session.
Delivery rules (credits, cancel, refusals) are in delivery.
Group commit
A fleet sends many small frames. Writing each in its own transaction would serialize the gateway on the database, so command updates, event batches and inventory work are group-committed: frames from every session that arrive in the same I/O turn share one transaction, at most 500 entries each. A session still waits for its own entry before its next frame is handled, so per-session order holds, and a lone frame never waits for company.
Update transactions lock every command involved in id order, so two concurrent flushes never deadlock; event
transactions lock session rows in node-id order. When a batch fails, each entry is retried alone, so one bad frame
cannot fail the others. Inventory sections are not awaited by the frame queue at all: a section is an idempotent
upsert, so the sections of every session in one flush (a node's sections sent back to back after Welcome, and
other nodes' at the same time) share one transaction. They are ingested set-wise through the inventory port's
ingestSections (inventory's ingestInventorySections): one INSERT … SELECT FROM unnest for the whole batch and
one enqueueMany of the distinct nodes, where a corrupt section or an unknown node fails only its own slot. Heartbeat
hash reports, and sections when the port has no ingestSections, still run each under its own savepoint. A section
lost to an error is simply requested again at the next Hello.
The lease and fencing
Each gateway holds a leases row named gateway:<id>, renewed every 5 seconds with a 20-second TTL. The epoch of
the lease rises whenever its owner changes. Every routing transaction (the Hello transaction, dispatch) starts
by re-reading the lease FOR SHARE:
SELECT 1 FROM leases
WHERE name = $name AND owner = $me AND epoch = $epoch AND expires_at > compute_now()
FOR SHAREA zombie either still sees its lease as valid, or aborts; a takeover blocks until the zombie's transaction ends. If
another owner holds the lease, or renewals have failed for half the TTL (10 seconds), the gateway fences itself:
it forgets its lease and closes every stream UNAVAILABLE ("gateway fenced; reconnect"). Daemons redial, possibly to
another gateway. Separately, the per-node session epoch fences routing and liveness writes; see
epochs and fencing.
stop() closes every session UNAVAILABLE with a disconnect write, marks the gateway draining and releases the lease
so a successor need not wait for the TTL. drain() sends every daemon Drain with a random delay up to 60 seconds
and refuses new sessions, for rolling deploys: PLAN.md upgrades gateways one at a time with Drain.
Report and status hooks
The gateway imports no domain module. State transitions driven by what nodes report are injected by the host:
| Hook | Called with | Typical wiring |
|---|---|---|
reports.allocationBound | an AllocationBound | capacity's markAllocationBound: the binding and the signed report are stored for the reservation worker to verify |
reports.allocationReleased | an AllocationReleased | capacity marks the binding lost or released |
reports.allocationFailed | an AllocationFailed | capacity retries or fails the binding |
reports.status | the coalesced StatusReport | xidStatusHandler (XID conditions to inventory's recordXid), VPS instance status |
Each report hook runs in the transaction that stores the event and advances the ack, so a report is applied exactly
once, and receives { nodeId, tenantId, journalId, seq, bootId, signed, attested, work }. signed says whether a
node_sig came with it; attested is the signed envelope as received (the AttestedReport body bytes, kid and
sig) for the worker to verify. Workers verify signatures; the gateway never treats one as proof. Status handlers
may be a list, each run under its own savepoint. A failing hook rolls back to its savepoint and is published as
node.report-failed; ingestion goes on. @better-compute/server exports gatewayReportHandlers with the capacity
hooks, and better-compute serve always adds xidStatusHandler.
gatewayReportHandlers accepts a report only about an allocation placed on the reporting node: a report naming
another node's allocation is ignored like an unknown allocation, so node A's session can neither bind, release nor
fail node B's. AllocationBound passes the session's node id and the signed envelope to markAllocationBound,
which stores the report as received (allocations.bound_report, bound_sig_kid, bound_sig); with
attestedBindings the reservation worker verifies it before the reservation goes active. See
attested bindings.
Certificate authentication
Join accepts clients without a certificate. Renew, Session and Tunnel need one that the authenticator
accepts, and the node id always comes from the certificate. serveGateway requires either nodeRoots (for the
production certificateAuthenticate) or an explicit authenticate hook; anything else is INVALID_CONFIG.
certificateAuthenticate verifies the chain to the node CA with the clientAuth usage, refuses revoked serials,
optionally requires a SPIFFE trust domain, and reads the node id and site from the single SPIFFE URI SAN. It returns
the serial, the role and the expiry, which drive node_certs.allowed_kinds, revocation, the Hello role check and
the close at expiry, plus tenantControlled for a node-tenant certificate: such a session gets no secrets and no
destructive platform commands. unsafeSanOnlyAuthenticate reads only the SAN and skips all of that; it is for tests,
as is insecure: true (cleartext h2c).
Revocation
Revocations reach live sessions within about a second. In better-compute serve, pki's createRevocationFeed
polls node_certs.revoked_at into the revocation set the gateway enforces, and the gateway subscribes to it: a
session whose certificate serial is revoked receives RevocationNotice{reason, serials}, its tunnels close, and the
stream ends UNAUTHENTICATED. New opens with that serial are refused at the handshake, and the Hello transaction
re-checks node_certs itself, since the database is the revocation truth and the in-memory feed may lag a poll.
The control API's revoke does the same for one node on demand. The gateway also revokes by itself in one case: a
certificate that keeps alternating between two machines is quarantined with reason duplicate-identity (see
duplicate identity).
Control API
Each gateway serves a small internal API on gateways.internal_url, HTTP/2 over the internal CA's mTLS, called by
api and worker processes. Every call but tunnels is an accelerator: dispatch polls every second, specs are re-pushed
by the sweep, revocations arrive through the feed and cancellations from the database, so correctness never depends
on it.
| Call | Body | Answer |
|---|---|---|
GET /v1/health | GatewayStatus: id, region, started, draining, sessions, maxStreams, lease epoch, loops | |
POST /v1/poke | { nodeIds? } | 202; dispatch now, and a spec check for the named nodes |
POST /v1/revoke | { nodeId, reason, serials?: hex[] } | { revoked } |
POST /v1/drain | { reason? } | 202 |
POST /v1/tunnels/close | { tunnelId?, nodeId?, subject?, agentId?, delegationId?, sandboxId?, tenantId?, anyAgent?, reason } | { closed }; the filter must name an id key, or tenantId with anyAgent: true |
POST /v1/tunnels/reauthorize | the same filter | { checked }; re-authorizes the matching tunnels now |
POST /v1/tunnels | a duplex stream | The api's side of one tunnel |
Host wiring uses three helpers. gatewayControlClient(...) calls one gateway. gatewayNotifier(...) implements the
core Notifier: publish('gw:<id>') pokes that gateway, and the runtime publishes it after an enqueue or a spec
change commits. gatewayTunnelRelay(...) is the server's TunnelRelay: it opens a tunnel through the gateway named
by node_sessions.gateway_id, caching gateway URLs for 30 seconds; an unregistered gateway is UNAVAILABLE.
Tunnels (v0.3)
The api authorizes the caller and issues a 60-second single-use exec ticket for one node, then asks the owning
gateway to open a tunnel. The gateway verifies the ticket (signature against the api's JWKS, audience, its own
jti set, which fails closed when full), sends OpenTunnel on the node's session, and waits up to 10 seconds for the
node's Tunnel stream. The daemon verifies the ticket again before opening it (bcd and the FakeDaemon both do, each
with its own jti set), and a daemon that refuses simply opens nothing. The gateway then only relays frames,
with backpressure in both directions. It re-authorizes the caller every 60 seconds: a refusal closes the tunnel, and
2 minutes of IAM failures close it too. A tunnel lives at most 12 hours, and revocation or
POST /v1/tunnels/close ends it at once. See tunnels.
Admission and scale
Three bounds protect a gateway from a reconnect storm: the handshake token bucket (200 opens and joins per second),
maxStreams, and maxPendingHellos. The last one exists because the bucket can admit Hellos faster than the
database commits them; an unbounded queue once starved the lease renewal until the gateway fenced itself and dropped
every stream. With the bound, a refused Hello retries with jitter and the database queue stays short.
Each of these refusals (RESOURCE_EXHAUSTED) tells the daemon when to come back, so a herd spreads by the gateway's
measure rather than by each daemon's doubling back-off. GrpcError carries retryAfterMs, sent as the standard
grpc-retry-pushback-ms trailer:
| Refusal | retryAfterMs (jittered) |
|---|---|
Handshake bucket (Session, Join) | 250 ms up to refusals this second / handshakesPerSecond (at least 1 s) |
maxPendingHellos | 250 ms to 2 s |
maxStreams | 5 to 30 s |
The same hint reaches every transport: the HTTP/2 trailers-only or trailing status; the WebSocket upgrade refusal,
as retry-after (seconds) and grpc-retry-pushback-ms headers, which connectGatewayWs turns into a
RATE_LIMITED ComputeError with details.retryAfterMs (from grpc-retry-pushback-ms, else retry-after); and
attachFrameConn, whose close(reason, { retryAfterMs }) hands it to simulated FakeDaemons. bcd and the FakeDaemon
wait exactly that long; see opening a session.
PLAN.md's v0.1 gate is one Node gateway holding at least 5,000 mTLS sessions at 10-second heartbeats for 30 minutes,
with dispatch p99 under 500 ms and RSS under 2 GiB; if it fails, the gateway is rewritten in Go on the same proto
(decision D20). On a development workstation (PGlite, 60 seconds), the gateway in its own process held 5,000 mTLS
sessions, one TLS 1.3 connection per daemon, with dispatch p99 26 ms, a gateway process RSS of 1,240 MiB (PGlite
included) and 0 drops, and survived a reconnect storm with 5,000 of 5,000 back within 39 seconds. A 30-minute run of
the same split setup (14ff010) held 5,000 sessions with dispatch p99 24 ms over 90,000 commands, 0 drops and every
command of the storm succeeded, but the gateway process's RSS grew from 1,240 to 2,016 MiB while the JS heap stayed
at 235 MiB; a 5-minute run with memory attributed traced the growth to PGlite's WebAssembly memory inside the gateway
process, not the gateway's own. Every
criterion is met on PGlite, and the gateway's own RSS stays near 1.1 GiB; the run with Postgres out of process, on
the reference host, is still to confirm it. See
scaling and bench/gateway-streams.
Several gateways
A region runs three or more gateways behind the L4 load balancer. Nothing is shared between them but PostgreSQL:
each dispatches only the nodes routed to it (node_sessions.gateway_id), and a node that reconnects elsewhere
simply moves its row with the next Hello. In the fleet simulator with the real control plane
(bench/multi-gateway), killing one of three gateways re-homed its nodes with p99 of 1 second at 1,000 nodes and
13.25 seconds at 10,000, with no invariant violations. Peering between gateways is planned for v0.4.
Metrics
With a metrics registry, better-compute serve wraps the gateway services with meteredGatewayServices and
registers gatewayCollector, which export (all prefixed better_compute_, no tenant labels):
| Metric | Labels | Meaning |
|---|---|---|
gateway_open_sessions | gateway, role | Open sessions by agent role |
gateway_stream_headroom | gateway | Streams the gateway can still admit |
gateway_draining | gateway | 1 while draining |
gateway_lease_held | gateway | 1 while it holds its lease, 0 while fenced |
gateway_commands_in_flight | gateway | Commands in flight on its sessions |
gateway_sessions_upgrade_required | gateway | Sessions told to upgrade |
gateway_loop_consecutive_failures, gateway_loop_failures, gateway_loop_last_ok_age_seconds | gateway, loop | Loop health |
gateway_service_calls_total | method, outcome | Join, Renew and secret calls |
gateway_service_seconds | method | Their duration |
The collector samples gateway.status() and gateway.sessions() at every scrape. See
observability.
Testing without a network
gateway.open(peer, sink) drives a session without any transport, and attachFrameConn(gateway, peer, conn) serves
one over the testing kit's in-memory frame stream, so the fleet simulator runs real FakeDaemons against a real
gateway on simulated time. gateway.crash() simulates process death: loops stop and every session is forgotten
without a database write, so the lease simply expires. See testing.
With a network, createDevStack({ externalTls }) serves the same gateway a second time over mTLS, with a dev serving
CA and client certificates checked against the dev node CA, for daemons outside the test process.
tests/e2e/bcd.test.ts runs the real Go bcd against it (see the Go daemon).
Better Compute is created by Sean Filimon
Last updated
Site agent and ramdiskexperimental
How bcd site runs the boot server, proxy DHCP, TFTP, the image cache and BMC drivers for a site, and how bcd ramdisk joins from its kernel command line, wipes disks, writes and verifies images and kexecs.
Protocolexperimental
Transport, the proto packages, the AgentGateway service and its frames, signed envelopes, message size bounds and the version-skew rules.