Skip to content

perf: drive the worker off HN's updates feed instead of re-walking every thread - #29

Merged
ericboehs merged 2 commits into
masterfrom
feat/updates-driven-worker
Aug 30, 2026
Merged

ericboehs merged 2 commits into
masterfrom
feat/updates-driven-worker

Conversation

@ericboehs

Copy link
Copy Markdown
Owner

The worker had never completed a single cycle. In 40 minutes of uptime it logged 1 cycle start, 0 completions, and 33,123 API requests.

Why

The cause was the traversal, not the volume. prefetch_children called Item.prefetch on every kid, and prefetch re-descended even on a cache hit, so each cycle walked all ~47,000 cached nodes just to discover changes. The 10-minute freshness window then expired mid-walk, so everything was stale again before the walk came back around.

Measured over one 10-minute window:

Rows created (new discovery) 210
Rows updated (re-fetch of items it already had) 5,518

96% of the work was rediscovering things it already knew. HN publishes exactly this information: updates.json returned 40 ids when sampled.

The new cycle

  1. Refresh the ids in the updates feed that we actually cache
  2. Re-check a slice of the least-recently-updated rows (see below)
  3. Backfill descendants we're missing
  4. Prune to the current top-story list

Discovery of missing comments moved from Ruby recursion into one recursive CTE. Batching all roots into a single query measured 1.7 s across all 504 stories, versus ~135 ms per story (67 s) one at a time.

prefetch no longer recurses — it refreshes top-story rows only, bounding it at ~500 requests per cycle.

The gap review caught

updates.json is a snapshot, not a replayable cursor. If a reply lands between polls, the parent's entry leaves the feed before we poll, the cached kids list stays stale, and since discovery reads our own cached kids, that reply could never be found.

Item.reconcile closes it by re-fetching the least-recently-updated rows each cycle. Cost is fixed per cycle rather than proportional to cache size, and RECONCILE_BATCH trades convergence time against API load.

This deliberately broke my "steady state costs zero requests" test. Replaced with the invariant that actually matters: cost tracks the reconcile budget, not the number of cached comments — 60 cached comments with a budget of 5 costs 5 requests.

Net effect per cycle

Before After
API requests ~47,000+, never finishing ~1,000, bounded
Comment discovery Ruby walk of every node 1 SQL query
Convergence Never Every cycle

Tests: 103 runs, 261 assertions, green.

…ery thread

The worker had never completed a single cycle. In 40 minutes of uptime it
logged 1 cycle start, 0 completions, and 33,123 API requests, and 96% of
that work was re-fetching items it already had (5,518 rows updated versus
210 created in a 10 minute window).

The cause was the traversal, not the volume. prefetch_children called
Item.prefetch on every kid, and prefetch re-descended even on a cache
hit, so each cycle walked all ~47,000 cached nodes to discover changes.
The 10 minute freshness window then expired mid-walk, so everything was
stale again before the walk came back around -- a treadmill that could
not converge.

HN publishes what changed. updates.json returned 40 item ids when
sampled, against the 5,518 rows the worker was re-fetching to find the
same information. The cycle is now:

  - refresh the ids in the updates feed that we actually cache
  - backfill descendants we are missing
  - prune to the current top-story list

Discovery of missing comments moved from Ruby recursion into one
recursive CTE. Batching all roots into a single query measured 1.7s
across all 504 stories, versus ~135ms per story (67s) one at a time.

prefetch no longer recurses; it refreshes the top-story rows only, which
bounds it at ~500 requests per cycle.

Tests cover the property that regressed: a fully cached 60-comment tree
now costs zero per-comment requests in a steady-state cycle, and a cycle
with one new reply fetches exactly that one reply.
Review caught a real correctness gap in the updates-feed design.
updates.json is a snapshot of what changed at poll time, not a cursor
that can be replayed. If a reply lands between polls, the parent's entry
leaves the feed before the worker sees it, so the cached kids list stays
stale -- and because discovery reads our own cached kids, that reply
could never be found.

Item.reconcile re-fetches the least recently updated rows each cycle, so
every cached row is eventually re-checked regardless of what the feed
dropped. Cost is fixed per cycle rather than proportional to cache size,
and RECONCILE_BATCH tunes convergence time against API load.

The steady-state worker test asserted zero requests, which the sweep
deliberately breaks. Replaced with the invariant that actually matters:
cost tracks the reconcile budget, not the number of cached comments.

Also from review: an unavailable feed logs at error rather than warn and
explains that the sweep is the fallback, failed refresh ids are logged
instead of collapsed into a count, and exhausting the backfill round cap
warns instead of silently returning a partial result.

Comment fixes: updates.json is not a "since last publish" delta, and
tombstones are cached (a non-nil payload with deleted: true), so the
claim that deleted items stay uncacheable was wrong.

Tests: the updated-parent-reveals-new-reply integration path, refresh
failure preserving cached data, reconcile ordering, backfill cycle
termination, an explicit round cap, and the real updates.json shape.
@ericboehs
ericboehs merged commit e029b0f into master Aug 30, 2026
2 checks passed
@ericboehs
ericboehs deleted the feat/updates-driven-worker branch August 30, 2026 02:12
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