blog

Leases and Watermarks: How Rindle's HTTP Control Plane Works

The HTTP surfaces around Rindle's WebSocket data plane: how the api-server turns a named query into a lease, how writes stay exactly-once, and how errors tell you what to do.

Matt Wonlaw

A Rindle app streams its data over one WebSocket. Around that data plane sits a small set of HTTP surfaces: the control plane. The previous post explained how a browser finds its read follower. This post explains what the HTTP calls on that path do. One example sits at the center: how the api-server creates a query lease for a client.

Three surfaces, one hard rule

The control plane is three HTTP surfaces.

The api-server endpoints are the only HTTP a browser sees: POST /api/rindle/query to lease a named query, POST /api/rindle/mutate to deliver mutations, and POST /api/rindle/read for a server-rendered first read. The api-server ships as a library. You mount its handlers on your own routes, under your own authentication, so the identity model is yours.

The follower read plane is where the api-server manages live queries: /materialize, /query for a one-shot read, /execute-sql-read, /schema, and /dematerialize. It carries a Bearer token. A daemon that binds beyond loopback without a token refuses to start.

The master write ingress is where every write enters: /execute-sql-txn, the /mutate-session/* endpoints, /migrate, and the raw change-apply endpoints. It holds its own token, separate from the public SQL plane’s token, and it refuses to run with the two equal.

The hard rule: a follower serves no writes. If a write reaches a follower, the follower answers with a 409 that names the master and says why. A write that skips the master’s log diverges the fleet in silence, so the refusal is loud instead. The api-server’s daemon client enforces the same split from its side: writes go to the master’s URL, and reads go to the fleet.

Creating a lease

The client sends POST /api/rindle/query with {name, args}. The client never sends SQL and never sends a query AST. It names a query that your server defined.

The api-server runs your authorizeQuery hook with the user, the name, and the args. Then it resolves the name and args into the approved query AST — the authoritative, parsed form of the query. An unknown name is a 404.

Next, the api-server calls /materialize on a follower with that AST and a retention policy. The follower canonicalizes the AST and hashes it, together with the schema version and a visibility key, into a query key. A known key reuses the existing pipeline, and the reply says so with reused: true. A new key builds one. Either way, the follower mints a fresh lease and returns {leaseToken, materializationId, queryKey, reused}.

The api-server hands the lease to the browser. The browser subscribes on its WebSocket with the lease token alone. No names and no ASTs travel on the public wire. A subscribe frame carries a token, or the follower refuses it.

browser ── POST /api/rindle/query {name, args} ──▶ api-server
api-server: authorizeQuery(user, name, args)
api-server: resolve (name, args) ──▶ approved AST
api-server ── POST /materialize {ast, policy} ──▶ follower
follower:   query key lookup ──▶ reuse or build the pipeline
follower:   mint lease ──▶ {leaseToken, queryKey, reused}
browser ◀── {leaseToken, ...} ── api-server
browser ── ws {t:"subscribe", leaseToken} ──▶ follower

What a lease is

A lease is a short-lived handoff, not a session. The token is rindle-lease-<n>-<128 random bits>, and the random bits are what stop one client from guessing another client’s stream. The lease lives in the follower’s RAM and expires after five seconds by default. There is no renewal endpoint. A renewal is a fresh /materialize, which does one dedup lookup and mints one new token.

Three things kill a lease: expiry, removal of its materialization, and a follower restart. A dead lease is a retryable condition, not a terminal one. The subscribe answers with a lease_expired error and a retry delay, and the client requests a new lease through the normal path.

The pipeline behind the lease has its own lifecycle. The default policy is whileSubscribed: the pipeline stays warm for 15 seconds after its last subscriber detaches, then reclaims itself. A pinned query stays warm under a stable name. When two callers ask for the same query with different policies, pinned wins over idle-based retention, and the longer idle time wins.

The one-shot read shares the machinery

Server rendering uses the same path. /query materializes or reuses the same query key, reads the assembled view once, and returns rows, a schema block, and a change-version baseline. It registers no subscriber and discards the lease it minted, so an abandoned request leaks nothing. The pipeline it leaves warm is the one the browser’s follow-up subscribe attaches to. The first paint and the live view share one materialization.

Writes ride a watermark

The write side of the control plane is built on a per-client watermark. Every mutation carries a clientID and a mid, a client-local sequence number. The master stores the highest applied mid for each client inside the same transaction as the mutation’s effects. The watermark is data: it replicates through the same ordered stream as the rows, so followers and browsers learn it the same way.

The watermark makes /execute-sql-txn exactly-once. The master absorbs a replay of an applied mid: it answers 200 with applied: false and changes nothing. A mid that skips ahead is a 409, and the caller must not retry the same bytes. Delivery can repeat. Application happens once, in order.

Interactive mutators use /mutate-session/*. A begin carries the accumulated write prefix and the first read, so a mutator that upgrades to an interactive session pays one round trip. An open session holds the writer, and a five-second deadline bounds that hold: a wedged api-server costs the write head five seconds, not forever. A call against a dead session answers 410, and the caller re-enters at begin, where the watermark absorbs an already-committed outcome.

Conflicts stay inside the master when replay is safe. A write-only transaction that loses optimistic validation replays mechanically, up to five attempts. A transaction that already returned rows cannot replay safely. In that case the master answers a 409 marked retryable: true, and the api-server re-runs the whole mutator, up to five attempts.

Rejection is a write too. When a mutator refuses a mutation, the api-server calls /reject-mutation, and the master commits the watermark advance alone. That small commit is what keeps the client’s pending queue draining after a refusal.

Errors tell you what to do

Most errors are one shape: {"error": "a plain-English message"}. Structure appears exactly where a machine has a decision to make. A retryable conflict is a 409 with code: "retryable-conflict". An overloaded or shedding node is a 503 with a Retry-After header. A dead session is a 410. The status tells you whether to retry, and the body tells you why.

Two details do quiet work here. First, every follower response carries a Rindle-Boot-Id header, stamped at reply time. The api-server watches that header and learns about a follower restart from traffic it already sends, with no polling. Second, a reply cannot vanish inside the daemon: if the engine abandons a request, a destructor sends the 503.

The project fence runs before authentication, on purpose. A request from the wrong project answers with a 409 that explains the mismatch. An auth-first order answers with a bare 401 and hides the real cause, because two projects usually hold different tokens.

Backpressure and limits

Each tier caps its concurrent requests at 1024 in flight, and answers 503 with Retry-After: 1 past the cap. Follower reads hold their own budget, so a read flood cannot starve the rest of the intake. The master caps request bodies and separates two overload answers. It answers an impossible body with a 413 and a temporarily full memory budget with a 503. One is permanent, one is retryable, and the status says which. Raw SQL reads run under a watchdog: when a read passes five seconds or 50 million VM steps, the watchdog interrupts it.

What is built, and what is not

An honest list, in the spirit of the routing post.

/materialize and /query wait on the follower’s engine without a deadline, and the daemon client sets no request timeout. So today, a wedged follower can hang a lease request instead of failing it. We wrote a change that puts a deadline on every hop, and it is not released yet.

consistency: "strong" on a SQL read is a client-side routing directive, not a server-side fence. The daemon client routes a strong read to the master, which reads committed state on a dedicated connection. No endpoint waits on a replication cursor for you: read-your-writes holds because the caller sends the read after the write returns. The public SQL transaction plane is the exception — a read-only transaction can carry a session cursor, and the master fences on it.

Our docs call a lease a one-shot token. The code enforces expiry, not single use. The five-second TTL does that job today.

Bottom line

The control plane is small on purpose. The browser sees a few endpoints on your server, and the api-server holds all authority. It speaks plain HTTP to two daemons: reads to a follower, writes to the master. A lease is a five-second, random, in-RAM handoff from the authorized HTTP world into the streaming one. A watermark makes writes exactly-once over repeated delivery. And when something goes wrong, the status code tells you whether to retry.