Skip to content

feat(agents): replica-safe wake claims and dones — the backend is the claim authority - #4794

Open
adityavkk wants to merge 6 commits into
electric-sql:mainfrom
adityavkk:feat/agents-replica-safe-claims
Open

feat(agents): replica-safe wake claims and dones — the backend is the claim authority#4794
adityavkk wants to merge 6 commits into
electric-sql:mainfrom
adityavkk:feat/agents-replica-safe-claims

Conversation

@adityavkk

Copy link
Copy Markdown

Stacked on #4792 (its two commits are included in this diff until it merges; the four commits after 9e9f593 are this PR).

Problem

With two Agents Server instances behind one load balancer, the runtime's claim callback can land on an instance that never saw the wake's webhook. ClaimWriteTokenStore is process memory, so that instance mints a random token; with fenced session streams the first append is refused 401, and because the runtime had already claimed, its done acks the inbox message away: silent loss of roughly half of all wakes at two instances. A done landing on the non-minting instance also left the entity running forever, because webhook mode never wrote the durable claim row the release path clears. Both are the same root cause: the process, not the backend, was the authority for the claim.

Approach

  • The claim is a backend round trip. The claim callback is forwarded to the Durable Streams backend as a §7.1 non-done ack ({wake_id, generation, acks: []}), and the write_token the backend re-mints on it is what the runtime receives — on any instance. In fenced mode a claim the backend does not confirm is refused (502 WRITE_TOKEN_UNAVAILABLE), never minted locally; with fencing off the local mint remains as the fallback.
  • The claim is durable. The webhook path now materialises consumer_claims / entity_dispatch_state like the pull path always did, so a done on any instance clears dispatch state and settles the status through a conditional UPDATE (stopping → stopped, running → idle, only from those states). A redelivered wake's second claim is fenced 409 WAKE_ALREADY_CLAIMED before the runtime acks anything; the done clears the store per consumer; a refused runtime forward reverts running.
  • Store entries expire at claim lease + 5 s (the backend's write-token TTL), so instances that never see a done do not accumulate dead claims.
  • Runtime (separate, droppable commit): WebhookNotification carries the delivered write_token; a wake whose write failure never reached the stream sends its done with no acks so the backend re-wakes the message instead of consuming it silently; a redelivered wake for a (stream, generation) already in flight on the same process is not started twice.
  • Manifest wake sync (replaceByManifestKey): re-describing an existing registration upserts it in place instead of delete-then-recreate. The round trip's timing exposed a pre-existing window in which an event evaluated against no registration and its wake was lost (runtime-dsl F1 flaked ~40%); the registration is now never absent, pinned by an invariant test that evaluates the event at every statement boundary.

Compatibility

  • Default behaviour with a base backend and fencing off is unchanged apart from one extra non-done ack per wake at claim time (a lease renewal the base protocol defines).
  • With fencedSessionStreams on, a backend that does not return write_token on acks now refuses the claim instead of letting the runtime run with a token the backend will reject.
  • PostgresRegistry.materializeActiveClaim returns boolean and accepts unlessActive; new updateStatusAfterDone and revertRunningStatus; ClaimWriteTokenStore gains an options object and renew(); WakeRegistry.replaceByManifestKey.

Verification

pnpm --filter @electric-ax/agents-server typecheck && pnpm --filter @electric-ax/agents-server stylecheck
pnpm --filter @electric-ax/agents-server exec vitest run   # 538 passed, 41 skipped (docker backend)
pnpm --filter @electric-ax/agents-server exec vitest run test/wake-registry.test.ts   # 36 passed
pnpm --filter @electric-ax/agents-runtime typecheck && pnpm --filter @electric-ax/agents-runtime stylecheck
pnpm --filter @electric-ax/agents-runtime exec vitest run test/process-wake.test.ts   # 59 passed
pnpm --filter @electric-ax/agents-runtime exec vitest run test/runtime-dsl.test.ts    # 92/92, twice; F1 8/8

Two instances behind one proxy were exercised end to end against a backend implementing the Write Fencing extension (wake on A / claim on B, claim on A / done on B), passing after this change and failing before it.

…ssion streams

With fencedSessionStreams on, the append path forwarded the claim's write
token to the backend but still required the in-memory ClaimWriteTokenStore
to know it first. That store is process memory: a claim adopted by another
server instance, or by this one before a restart, was refused 401 while the
backend still considered it live — the process-local write authority electric-sql#4286
describes, one layer up.

In fenced mode the backend minted the token and verifies it atomically with
the append, so the server now defers to it and does not consult the store
on the append path; with the flag off, validation is unchanged. The token
stays opaque (spec §4): nothing inspects its shape.

Because a backend without the extension ignores Write-Fence and creates the
stream unfenced, a client that requires fencing must check for the
`Write-Fence: true` echo before treating the stream as fenced [WF-02].
Fenced creates now HEAD for the echo (the client library does not surface
the PUT response) and fenced forks check the PUT response, and both fail
instead of yielding a stream the fence never protects. The pin against
DurableStreamTestServer flips from "silently unfenced" to "refused".
…leased

The ClaimWriteTokenStore only ever shrank on a done or a kill handled by
the same process. A server instance that received a wake's delivery but
not its claim kept the delivered token forever, and one that minted or
refreshed a claim whose done reached a sibling instance kept the claim
entry — and kept answering isValid() for it — forever.

Entries now carry an expiry of the default claim lease plus the grace a
backend keeps a write token alive past it (PROTOCOL §7 lease_ttl_ms,
30s + 5s), refreshed by every mint and by a new renew() the heartbeat
path calls when the backend answers without re-minting, so a claim this
process is the sole authority for lives as long as it heartbeats. A lazy
sweep on the paths that add entries drops what expired. The public
methods keep their signatures; the constructor takes an optional
{ ttlMs, now } for tests.
…stances

With more than one server instance behind one address, the runtime's
claim callback for a wake need not reach the instance that received the
delivery, and its done need not reach the one that handled the claim.
Three defects share that root, all in the wake-callback route:

- The claim was answered locally and never reached the backend. On the
  instance that had not seen the delivery, takeDelivered() found nothing
  and the store minted a random token; with fencedSessionStreams on, the
  runtime's first append was refused 401, the wake failed, and its done
  — sent at the delivery's tail offset — acked the message away with
  nothing on the stream. Silent loss, not a retry.
- Webhook wakes never materialised a consumer_claims row, so a done on
  an instance that had not minted the token found neither entityCleared
  nor an owning store entry and left the entity `running` (or
  `stopping`, from which nothing else ever leaves).
- The status write was a read of entity.status followed by
  updateStatus(), which a done racing the next wake's delivery could
  clobber.

A claim is now forwarded like a heartbeat: a non-done callback that
renews the lease and, on a backend with the Write Fencing extension,
returns the wake's write token (PROTOCOL §7.1), which the claim adopts
over anything in local memory. A done carries a wake id too, so the
claim path excludes it explicitly — routed as a claim, a done would be
refused for a write token no backend mints for it and would never
release the claim. In fenced mode a claim the backend issues no token
for is refused (502 WRITE_TOKEN_UNAVAILABLE) rather than answered with
a token the backend would reject; the runtime drops the wake unclaimed
and the lease lapse re-wakes it. Without fencing the store is still the
only write authority, so nothing is gained by holding the wake on the
backend's answer: a claim it refuses, or never answers, falls back to a
local mint and the wake runs exactly as it did before claims were
forwarded. The delivered token and the store's own mint remain the
fallback when fencing is off.

Once the backend has accepted the claim it is materialised in
consumer_claims (webhook wakes only — pull-wake claims already were), so
materializeReleasedClaim's compare-and-clear yields entityCleared on any
instance. The same insert fences a redelivered wake's second claim: an
active row for the same (consumerId, epoch) answers 409
WAKE_ALREADY_CLAIMED before any token is minted, so the duplicate never
claims, runs, or acks. The backend's own (generation, wake_id) fence
rejects lapsed and re-armed wakes before this point, so an active row
can only mean a wake another runtime is still processing.

The status transition is one conditional statement — running → idle,
stopping → stopped, nothing else touched — behind the unchanged
`entityCleared || stillOwnsClaim` gate. It is a statement of its own
rather than part of the release: the next wake can be delivered in
between and set `running` again, which this write would report `idle`
under until that wake's own done settles it. The done clears everything
the consumer holds in the store (clearConsumer, consumer-scoped so a
newer wake's entry survives), which also drops the delivered token an
instance kept for a claim it never handled. A runtime refusing a
forwarded wake reverts the entity to idle, as a failed forward already
did, but only while it is still `running` — a rolling runtime restart
refuses wakes in bulk, and an unconditional write would report a
stopping entity, or one a sibling has already woken again, wrong.
…ched the stream

A wake whose appends fail (a deposed or lapsed write token, a lost
connection) records WRITE_FAILED and appends an error event — through
the same producer, so when the failure is a dead token the error event
is lost too. The done then still acked safeAckOffset, and the backend
consumed the triggering message with nothing on the stream to say why
no run answered it.

When a producer batch fails after the error event was appended and no
append has landed since, the done now carries no acks: PROTOCOL §7.1
idles the claim without moving the cursor, and the pending work
re-wakes the entity — the same shape as a crash mid-wake, and a
duplicate run rather than a silent loss. Batches are sent
concurrently, so a batch queued before the failure can report its own
error afterwards; the producer's last written offset is what separates
that from the error event itself being lost, and a wake whose error
event did land keeps acking as before. The ack-less done is logged as
a warning, because it is a full re-run of the handler and a write
failure that is not clearing turns it into a wake loop.

Two smaller guards on the same path: a redelivery of a wake already in
flight for the same stream and generation (the backend's retry of a
delivery whose 2xx it never saw) is acknowledged without starting a
second wake, which would otherwise claim the same generation — the
backend accepts that while the lease is live — and run the handler
twice; and a `write_token` delivered with the wake notification is
adopted when the claim callback returns none, for a server that passes
the field through without adopting it.
…lace

Syncing a manifest entry's wake registration unregistered the entry's
rows and then registered the replacement. A source event evaluated
between the two statements matched nothing, and nothing ever
re-evaluates it: a `runFinished` wake was lost whenever a spawned
child's run completed in the few milliseconds after its parent's run
wrote the child's manifest entry. The parent's end-of-run batch and the
child's `run` update reach the server together, so the two race every
time; forwarding claims to the backend (01825eb) moved the child by a
claim round trip, enough to land its completion in that gap in a
quarter to a half of runtime-dsl F1 runs.

The registry now replaces the registration: the replacement is upserted
first and only then are the entry's other rows removed, so an event in
between matches the old row or the new one, never neither. A manifest
that re-describes the registration spawn already made — the usual case —
resolves to that same row through `uq_wake_registration`, and nothing is
deleted or re-created at all; `include_response`, the one field outside
the constraint, is adopted on the row.
@netlify

netlify Bot commented Sep 2, 2026

Copy link
Copy Markdown

Deploy Preview for electric-next ready!

Name Link
🔨 Latest commit 3124fdc
🔍 Latest deploy log https://app.netlify.com/projects/electric-next/deploys/6a987b2e35ca11000836a5da
😎 Deploy Preview https://deploy-preview-4794--electric-next.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@adityavkk
adityavkk marked this pull request as ready for review September 2, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant