Skip to content

Serve the feed timeline from D1; Fly proxy becomes an ingest-push crawler - #348

Merged
disnet merged 8 commits into
mainfrom
radial/impl-4fe9c0d77460
Aug 19, 2026
Merged

Serve the feed timeline from D1; Fly proxy becomes an ingest-push crawler#348
disnet merged 8 commits into
mainfrom
radial/impl-4fe9c0d77460

Conversation

@disnet

@disnet disnet commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Implements the D1-served feed timeline and Fly ingest-push crawler architecture, rebased onto current observability and standard.site teardown work.

Delivered

  • Serves refreshes from GET /api/v2/timeline with read state joined in D1; Fly leaves the read path and pushes crawl deltas through authenticated internal endpoints.
  • Preserves paged cold starts, archive recovery, crawler-heartbeat gating, fair subscription backfills, pull-through ingest, archival retention safeguards, and rollout E2E coverage.
  • Integrates Worker request tracing, Sentry wrappers, operational metrics, crawler heartbeat monitoring, and scrubbed error reporting with the new ingest paths.
  • Reconciles the completed standard.site document teardown and current linkblog/publication behavior.

Review fixes (latest commit)

  • writeMetricsSnapshot no longer queries the dropped feed_metadata. It counts feeds, and takes feeds_with_errors from the proxy's feedsInError in the proxy_stats row the cron already writes. Migration 0069 drops that column's NOT NULL so a stale/unreachable proxy records null instead of a healthy-looking zero; the admin type and trend label follow.
  • Restored main's observability guidance in backend/CLAUDE.md (the section, the health/telemetry route rows, system_status/metrics_snapshots, and the secrets block) on top of the branch's feed-architecture rewrite, plus the same class of staleness in ARCHITECTURE.md.
  • Renumbered 0061_feed_timeline.sql0068_feed_timeline.sql so it no longer ties with main's 0061_linkblog_publication.sql.
  • RUNBOOK pass for the new read path: a §4d on ingest health (crawler heartbeat, outbox depth, what last_ingest_at actually means), the feed-freshness SLO renamed to crawl freshness with the unmeasured second hop stated plainly, and the log-query example moved off the legacy batch route.

Checks

  • Backend Vitest: 539 passing across all 52 spec files (run in batches — workerd exhausts this runner's PID limit in one go).
  • Backend tsc --noEmit and prettier --check: pass.
  • Admin npm run check and npm test: pass (23 tests).
  • Feed-proxy tests: not run (Bun not installed in this runner); no feed-proxy files changed in the latest commit.

Radial artifact

claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) and others added 8 commits August 12, 2026 22:41
…wler

A refresh was `1 + ceil((N-8)/50)` sequential batch calls, each hopping
Worker -> Fly and then paying ~18 chunked D1 queries to annotate read
state. Now the proxy pushes new/edited items into a central D1 archive
and a refresh is one `GET /api/v2/timeline` — a single query that joins
subscriptions and read state, so `getReadKeys` leaves the feed path
entirely and reads never touch Fly.

Backend: migration 0061 drops the dormant pre-Fly trio and creates
`feeds` + `feed_items` (monotonic seq, unique (feed_url, guid), NOT NULL
content_hash) plus the items_generation token; `routes/ingest.ts` adds
the secret-authenticated, fail-closed `POST /api/internal/ingest` and
`GET /api/internal/crawl-set` with an idempotent upsert, an 8 KB stored
content cap and the 5,000-item per-feed sanity cap (the only pruning —
D1 is an archive); `routes/timeline.ts` serves incremental drains and
per-feed cold starts; `/api/v2/feeds/fetch` is re-backed with D1 plus a
pull-through for a feed nobody has crawled yet.

Proxy: the durable log is the outbox. `push_state` marks what reached
D1, dirty rows drain in seq order every 15 s with capped backoff, the
crawl set is pulled back every 5 min to keep feeds warm now that reads
no longer stamp them, and push_state cascades on every feed_items
delete. Everything is gated on INGEST_URL, so an environment without it
crawls exactly as before.

Frontend: one drain loop against a single global cursor in Dexie
metadata, with the legacy batch path kept as a fallback for an old
backend, a rollback, or an environment whose crawler isn't pushing yet.

Also: a staging Fly proxy (fly.staging.toml + CI job + staging
FEED_PROXY_URL) so the two environments stop sharing one machine, and
admin feed health re-pointed at the archive with storage/churn alerts.

Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) <claudebot.disnetdev.com@noreply.radial>
…lout gate, and paged cold starts

Ten findings from the review of the D1 timeline change, most consequential
first.

The subscribe-time pull-through ingested a proxy feed FORWARD. A proxy feed is
newest-first and seq is assigned in insert order, so the newest item got the
lowest seq — and since a re-push of an unchanged item is a no-op, that inversion
never healed: every later per-feed cold start served the feed's oldest entries,
and the sanity-cap trim would have deleted its newest. `ingestProxyFeed` now
walks the array backwards, as the proxy's own `writeFeedItems` does.

The rollout gate was inferred from an empty archive, which one pull-through
write falsified: a client could commit a cursor against a D1 nothing was
crawling and stop refreshing 40 feeds in silence. Both internal endpoints now
stamp `sync_state.crawler_heartbeat_at` (the crawl-set pull runs every 5 minutes
regardless of feed activity), the timeline reports `ingestActive` from it, and
the client stays on the batch path until the crawler is demonstrably alive.

Cold starts were bounded only by 500 feeds x 30 items — up to 15,000 rows
buffered in one Worker response, for everyone at once after a generation bump.
They now page: feeds in a stable order, an item budget per page, continuation
via `cold_offset`/`nextColdOffset`. The client keeps the FIRST page's cursor
(the head read before the slices, so concurrent ingest can't be skipped) and
commits it only once the last page merges.

Also:

- A cursor above the archive head now cold-starts that client, the D1 twin of
  the proxy's snapshot-restore guard — a Time Travel restore no longer stalls
  every client forever pending a manual `UPDATE sync_state`.
- `contentTruncated` is no longer inert: it rides into the Article row and
  ArticleCard extracts the original when such an article is expanded, so a
  full-text feed doesn't quietly degrade to a two-sentence RSS summary.
- Subscriptions that arrive from another device sit below the global cursor, so
  each is backfilled once through the per-feed endpoint (<= 10 per sync, tracked
  in Dexie).
- New-subscription backfill no longer forces a crawl per feed and is paced
  (3 at a time, 1 s apart); subscribe time crawls straight into the archive
  (`warmFeedIntoArchive` replaces the old warm-and-discard), and the endpoint
  gets its own light rate limit. A 250-feed OPML import stops 429ing halfway.
- The pull-through requires the caller to subscribe to the feed, so the shared
  never-pruned archive isn't an open write surface.
- Staging keeps pointing at the prod proxy until Phase 3 provisions its own Fly
  app, and the staging deploy job skips itself with a notice until then, instead
  of taking staging's extract/discovery down on merge.
- The incremental drain's scan cost is documented where it lives.

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>
Co-Authored-By: codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie) <codexbot.disnetdev.com@noreply.radial>
The merge of main into the D1 timeline branch left main's hourly metrics
writer querying `feed_metadata`, which 0061_feed_timeline drops. Backend
CI was red on it and, once the migration reached prod, `metrics_snapshots`
would have stopped gaining rows entirely — every admin trend sparkline
frozen and an hourly Sentry issue, with the cron still reporting healthy.

- ops-metrics: count `feeds` instead of the dropped `feed_metadata`, and
  source `feeds_with_errors` from the proxy's `feedsInError` (already in
  the `proxy_stats` row the cron writes every 5 minutes). Migration 0069
  drops the column's NOT NULL so an unreachable proxy records null rather
  than drawing a healthy zero through its own outage.
- admin: `feeds_with_errors` is nullable, and the trend is labelled
  "Proxy Feeds in Error" to match the tile it now shares a source with.
- backend/CLAUDE.md: restore the Observability section, the health and
  telemetry route rows, the `system_status`/`metrics_snapshots` tables and
  the secrets block, all dropped when the conflict took the branch side.
- ARCHITECTURE.md: same class of staleness — it still listed
  `feed_metadata`/`feed_cache` as live tables and described the proxy as
  the read path.
- Renumber 0061_feed_timeline -> 0068 so it no longer ties with main's
  0061_linkblog_publication (wrangler orders by the numeric prefix, so a
  tie falls back to directory order on a fresh apply).
- RUNBOOK: a §4d on ingest health (crawler heartbeat, outbox depth, what
  `last_ingest_at` does and doesn't mean), the feed-freshness SLO renamed
  to crawl freshness with the second hop stated as unmeasured, and the log
  query example moved off the legacy batch route.

Backend: 539 tests pass, tsc and prettier clean. Admin: check and tests pass.
Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) <claudebot.disnetdev.com@noreply.radial>
Timeline reads are served from the D1 archive and never touch the crawler,
so a feed that fails to crawl simply goes quiet — indistinguishable from one
that hasn't published. feedFetcher only ever called markReady for feeds that
delivered items, so after the cutover the sidebar badge and Manage Sources
popover went blank and stale errors from the batch era were never cleared.

The crawler now reports the feeds it is having trouble with every 5 minutes
(POST /api/internal/feed-health, right after the crawl-set pull so a feed
registered this cycle is already in the set). Two distinct faults ride the
same report:

  - error_count: the fetch fails. Readers see this, via feedHealth on the
    timeline response and health on GET /api/v2/feeds/fetch.
  - crawl_stale: the feed is in the crawl set but unfetched for 2h — starved
    by a saturated warm loop rather than failing. Operator signal only, and
    kept out of feed_health_rev so capacity churn can't make every client
    re-download the reader payload.

The payload is the COMPLETE trouble set, not a delta: recovery is inferred
from a feed's absence, so nothing has to announce that it started working.
The recovery sweep is a set difference against the currently flagged feeds
rather than a NOT IN list of every healthy feed, which at ~1,300 feeds is the
bound-parameter wall /batch already hit once. A feed-health rev token gates
the reader payload so a steady-state poll stays at one query; a cold start
always gets it, since it replays archived items from feeds that may have
broken since and its markReady pass would otherwise clear a live error.

Admin: health is now the crawler's verdict instead of an inference from
last_ingest_at, which only moves when a fetch yields a NEW item and so
flagged every low-frequency feed as broken. "Subscribed Feeds Not Ingesting"
(permanently warning, meaningless) becomes "Subscribed Feeds Erroring" and
"Subscribed Feeds Not Being Crawled". The Feeds page filters on
All/Erroring/Not Crawled/OK and shows the crawler's actual message, failure
count, retry time and last good fetch; last_ingest_at stays, relabelled
"Last Item", as the publishing cadence it actually measures. The proxy-wide
"Proxy Feeds in Error" tile and its trend series are untouched — different
population, and the series is documented as matching its tile.

Also fixes a pre-existing unit bug: updateFromV2Result treated nextRetryAt as
seconds and rescaled it, but the crawler sends Date.now() + backoff in ms.
That put every retry ~50,000 years out, so canFetch retired a feed
permanently after one transient error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fresh crawler heartbeat only says a crawler is attached. The proxy pulls the
crawl set immediately at boot, so the first stamp lands seconds into a backfill
that takes hours -- which meant `ingestActive` switched every reader onto the
timeline at the moment the archive was emptiest, and each of them would then drag
the entire backfill through the incremental drain: the fan-out-on-read scan at its
worst case, for the duration, surfacing back-catalogue items as unread along the
way. There was also no way back short of a Worker rollback or stopping the crawler
for 30 minutes.

`sync_state.timeline_enabled` (migration 0071) separates the two. `ingestActive`
is now the AND of a fresh heartbeat and this flag, so ingest and admission can be
sequenced: enable INGEST_URL, let the archive fill, watch ingest.pending trend to
~0, then open the gate with one UPDATE. Setting it back to '0' returns every
client to the legacy batch path at its next poll, with no deploy.

The wire contract is unchanged -- `ingestActive: false` has always meant "stay on
/batch" -- so no frontend change. A gated request short-circuits rather than
building a page the client is about to discard, which is what keeps the gated
window (most of the rollout) at one sync_state read per poll.

Only an explicit '0' gates; an absent row is open, so a hand-built schema or a
future environment is never silently held back. The migration writes '0' for a
database that already has users, so prod and staging start shut while local dev,
e2e and CI start open. dev-local.sh and the e2e seed set it anyway, since a local
database you have logged into before would otherwise quietly serve the path we
are retiring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@disnet
disnet merged commit 5652ef5 into main Aug 19, 2026
11 checks passed
@disnet
disnet deleted the radial/impl-4fe9c0d77460 branch August 19, 2026 16:33
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