Skip to content

Move standard.site documents from the Fly proxy into D1 - #424

Open
disnet wants to merge 7 commits into
mainfrom
radial/impl-4dab99d2258e
Open

Move standard.site documents from the Fly proxy into D1#424
disnet wants to merge 7 commits into
mainfrom
radial/impl-4dab99d2258e

Conversation

@disnet

@disnet disnet commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Moves standard.site document ingest and reads from the Fly proxy to Cloudflare's Jetstream Durable Object and D1 store, behind rollout and ingest gates.

This continuation merges the latest main changes and resolves the overlapping subscription-sync work. Main's structured limit notices and pending-write repair remain intact, while document imports still schedule bounded D1 backfills against the shared invocation query ledger.

Radial artifact

claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) and others added 7 commits August 30, 2026 20:33
Documents were the last content type where Fly held authoritative state and
sat on the read path: a persistent Bun WebSocket spliced `site.standard.document`
records into a per-author JSON blob in the proxy's SQLite, and every read
traversed Worker → Fly → blob. That meant two Jetstream consumers in two
runtimes with two cursor stores, a `wantedDids` filter derived from read traffic
rather than from the subscription table, and documents going down whenever one
Fly machine did.

This applies the move feeds already made. The JetstreamPoller DO drains
`site.standard.document` plus its `app.standard-reader.collection` sidecar
straight into D1; `listRecords` backfill (the firehose never replays history)
runs at subscribe time and from an hourly reconcile; reads are plain queries.
The filter that cost the proxy ~550 lines of options_update reconciliation is
connect-time state here, because the socket is rebuilt every 60s cycle.

Spike handling is layered, per the plan's rev-2 review finding: a server-side
DID filter (URL params up to 150 authors, one options_update frame beyond,
every DID validated because Jetstream rejects a malformed frame wholesale), a
per-event membership re-check, a per-cycle apply cap that carries its cursor so
a burst drains losslessly across cycles, the proxy's 100-row per-author cap
(the only layer that helps when a *subscribed* author floods), and a
cap-saturation counter plus a `documents_ingest_enabled` kill switch.

The wire contract is unchanged — same statuses, same `complete` semantics, same
digest algorithm over the same sorted (recordUri, recordCid) pairs, with parity
pinned by test — so the frontend does not change and `documents_v2_enabled`
chooses the source per request. Reads stay on the proxy until an operator flips
it after a clean shadow-compare; flipping it back is the rollback.

Not included, and gated on that soak: the linkblog-site cutover, decommissioning
the proxy's document path, and the Jetstream v2 move. See
docs/plans/DOCUMENTS_TO_D1.md and RUNBOOK §4e.

Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) <claudebot.disnetdev.com@noreply.radial>
Addresses the review of the standard.site → D1 move.

- Shadow-compare walks the whole subscribed-author set: the endpoint pages by
  DID with a `cursor`/`remaining`, and an author the proxy returned no entry for
  now counts as drift. Comparing the same first 25 authors said nothing about
  author 26, so a clean verdict could admit a lossy cutover.
- The reconcile can no longer be starved by an unlistable author: a failed list
  holds that author out of the queue for a doubling backoff (1h → 7d) and sorts
  them by that failure rather than by their still-NULL `last_listed_at`. Without
  it, three deleted accounts monopolised the only self-heal there is — and the
  migration's `remaining` could never reach 0.
- All four paths that create an `atproto.documents` subscription now pull the
  author's back catalogue, through a shared `ensureAuthorDocuments` that skips a
  fresh listing or a backing-off author. Only the API path did, so a subscribe
  from the Atmosphere button, a PDS sync, or another device served
  `status:'error'` until the hourly reconcile reached that author.
- `complete` is recomputed per serve from the author's stored row count, as the
  proxy did. The stored flag was set at the last list and never cleared by cap
  eviction, so an author who published past the cap kept claiming completeness.
- Backfill prunes reader-collection sidecars the repo no longer has, but only
  against a listing that both succeeded and was exhaustive — `listAuthorCollections`
  now reports which, since a failed fetch and an empty repo looked identical.
- `documents_v2.canonical_url` is read as the fallback when a publication won't
  resolve, instead of serving a bare relative path for the negative cache's TTL.
- One `DB.batch` per applied event instead of three `run`s, plus a
  `MAX_DOCUMENT_APPLY_CAP` (900) on the tunable cap: at three statements an event,
  a cap of 500 issued ~1500 subrequests against a 1000 ceiling, and that failure
  is silent — the drain counts an error and advances past it.
- RUNBOOK: the paging compare loop, why `remaining` terminates, and the bound on
  how long ingest can stay paused before a held cursor outruns Jetstream's replay
  buffer.

Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) <claudebot.disnetdev.com@noreply.radial>
The drain's apply cap was sized in events while the cost that matters is
statements: `DB.batch([upsert, trim, bookkeeping])` is three D1 queries,
so a 900-event cap asked for ~2700 against a 1000 ceiling and started
throwing around the 334th event — caught per event, counted as an error,
cursor walked past it. The case the cap exists to survive was the one
most likely to drop events.

An applied event is now one statement. The per-author cap eviction and
the ingest bookkeeping are settled once per author at the end of the
cycle, and publication metadata resolved for one event is reused by the
rest, so a burst from one author costs about one query apiece. On top of
that the drain counts what it spends and stops at
DOCUMENT_DRAIN_QUERY_BUDGET while it can still afford the flush its
writes have earned, whatever the cap says; MAX_DOCUMENT_APPLY_CAP is
that budget, since no cap can buy events the cycle cannot fund.

The same ceiling applies to fan-out. The two sync paths schedule their
back-catalogue walks into the request's own invocation, so a user with
fifteen followed publications turning on Atmospheric sync fired fifteen
of them at ~110 queries each; both now warm MAX_SYNC_BACKFILLS authors
and leave the rest to the reconcile. The PDS-to-local subscription pull
was the fifth path creating `atproto.documents` rows with no backfill at
all — the restore-from-the-Atmosphere case, where every restored
linkblog polled `status:'error'` until the hourly tick reached it — and
now warms them the same bounded way. To make "the reconcile has it" true
rather than hopeful, the every-minute cron re-lists one author a tick
alongside the hourly three; never-listed authors already sort first.

Also: `documents_ingest_enabled` now stops the poller's pending
backfills and the cron reconcile, not just the drain. It was advertised
as "pause writes", and the poller kept writing up to a hundred rows an
author a minute while it was off, because the subscriptions stream keeps
enqueuing DIDs. The operator backfill endpoint stays exempt — that one
is a deliberate repair, and its batch is resized to fit an invocation.

Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) <claudebot.disnetdev.com@noreply.radial>
`BACKFILL_QUERY_COST = MAX_DOCUMENTS_PER_AUTHOR + 12` was not a worst case. It
omitted the per-row prune (up to 100 DELETEs, each its own query), the SELECT per
sidecar, and the cross-PDS fetches that come out of the same per-invocation
ceiling as the D1 statements — so a prune-heavy author cost ~3x the constant its
callers sized against, and five of them on the operator endpoint, or the cron's
minute-plus-hourly pair, could cross 1,000 and fail partway through a walk that
had already written rows.

Three changes make the number true and the fan-outs honest:

- Count subrequests, not just D1 statements. Cloudflare documents the
  1,000-queries limit under read subrequests, so a cold publication resolve is
  charged all five (two statements, three fetches) and the drain's lookahead
  matches. A cycle of cold publications now stops where it says it does.
- Shrink what the walk actually spends. The document prune is one
  `updated_at`-scoped DELETE (every kept row was just upserted, so "untouched by
  this walk" is the stale set — and a row the drain added mid-listing now
  survives). The sidecars are one read plus one batch. What is still per-row is
  capped: `MAX_SITE_RESOLVES_PER_BACKFILL`, `MAX_COLLECTION_WRITES_PER_BACKFILL`,
  both converging on the next reconcile rather than being dropped.
- Budget per invocation, not per loop. A `QueryLedger` is created once per
  invocation and shared: the cron's two reconcile passes, the operator endpoint's
  batch, the poller cycle's walks, and both halves of `/api/sync` including the
  walks they schedule into `waitUntil`. Each fan-out asks `canAffordBackfill`
  before starting an author and leaves the rest in the reconcile queue — with
  nothing stamped, so a deferred author keeps its place at the front rather than
  entering a retry backoff. The endpoint reports `deferred` so a driving loop
  can see it stopped short.

Also from the review: an applied delete now stamps `document_authors.last_event_at`
like a write does, so the field means "the last event we applied"; the PDS pull's
insert batch (the one unbounded term left) charges the ledger and logs when it is
large, which is what keeps walks from being scheduled on top of an invocation that
has already spent its budget; and the RUNBOOK/plan/CLAUDE.md now name the
subscribe-time walks as deliberately exempt from `documents_ingest_enabled`
instead of claiming only the operator endpoint writes.

Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) <claudebot.disnetdev.com@noreply.radial>
The per-author bound was one subrequest short of the walk it describes:
both listings resolved the author's DID independently and `resolvePdsUrl`
is an uncached plc.directory fetch, so a worst-case walk spent 151 against
a promised 150 and the operator endpoint refused the fifth author its
ledger was sized for. The two listings now share a per-walk `PdsMemo`, so
the constant is unchanged and true, and a paging mock counts what the walk
actually spends by kind rather than trusting the charge.

The flat sidecar cap converged by the week, not by the next reconcile: a
listed author leaves the queue for seven days, so a newly subscribed
author's curated back catalogue rendered without its item lists for a
month. A walk now spends what is left of its own reservation on sidecars —
exactly the old 20 when every other term is at its cap, pinned in the
budget test — and records what it still cannot afford as
`collections_pending`, which keeps the author in the reconcile queue,
sorted behind everything genuinely stale.

Two smaller edges in the same accounting: a publication starved during one
author's walk is no longer memoised as empty for the rest of a fan-out,
and a loose https:// site no longer spends a resolve slot on the
short-circuit it takes before any I/O. And `/api/sync`'s reserve now
charges the Atmosphere reconcile's ops and graph listing instead of hiding
them inside a flat 100 they could account for on their own.

Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) <claudebot.disnetdev.com@noreply.radial>
Co-Authored-By: codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie) <codexbot.disnetdev.com@noreply.radial>
Co-Authored-By: codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie) <codexbot.disnetdev.com@noreply.radial>
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