Skip to content

fix: capture reliability — job/write lane split, network-idle precondition, and realistic budgets - #14

Merged
Seanathon merged 3 commits into
mainfrom
fix/write-lane-head-of-line-blocking
Aug 19, 2026
Merged

fix: capture reliability — job/write lane split, network-idle precondition, and realistic budgets#14
Seanathon merged 3 commits into
mainfrom
fix/write-lane-head-of-line-blocking

Conversation

@Seanathon

Copy link
Copy Markdown
Owner

Investigating the two links that failed (twenty.com, stigg.io) turned up two independent bugs. Both are reproduced with numbers.

1. Adding a link blocked on the previous capture

Jobs ran on the same promise chain as SQLite writes, so a capture held the write lane for its whole duration — Chrome launch, page load, and a ~30s LLM round-trip, none of which write anything. addItemSkill's one-row INSERT queued behind all of it.

Measured on the real server, four links added half a second apart:

before after
add #1 60ms 59ms
add #2 42,360ms 54ms
add #3 41,845ms 55ms
add #4 41,342ms 59ms

The two constraints collapsed into that one chain were never the same constraint: concurrency 1 for jobs bounds memory (two Chromiums OOM the LXC, NFR-1); single-writer for SQLite bounds write interleaving, and every such write is sub-millisecond. Split into two lanes. Jobs still run strictly one at a time — verified max concurrent processing stayed 1 across four queued adds, no leaked Chrome.

This revises AD6, which documented the job queue as being the SQLite single-writer guard. Architecture doc updated. worker.test.ts's "no double serializer" case is deleted rather than weakened, since it asserted exactly the shared lane that caused this.

Note the smaller-looking fix is the unsafe one: addItemSkill awaits its write before firing the job, and that ordering guarantees the row exists before the job mutates it. Simply not awaiting would leave the job's status UPDATE hitting a missing row.

Separate lanes do open a window the shared lane had closed — capture and enrichment read the item, await the LLM, then write the full row back, so a mid-flight edit would be silently reverted. Both call sites now re-read inside a single enqueued write op. Covered by a test that patches notes during the LLM call and asserts they survive.

2. "Timed out." — the reported failure

Capture navigated with waitUntil: 'networkidle2', making a quiet network a precondition for getting an item at all. Any page holding a connection open (analytics, chat widget, video preload, websocket) never satisfies it, and a slow link does the same to an ordinary page.

Reproduced under CDP throttling: twenty.com rejected at 30755ms with TimeoutError, which cleanErrorReason maps to 'timed out' and the card renders capitalized as "Timed out." — the exact reported string, and it fires well before the job budget, matching "before actual timeout was triggered".

browser.ts's renderPageText had already learned this and carries a comment saying so; the screenshot path never got the same treatment.

Now navigates on domcontentloaded and waits for the network to settle separately and best-effort — if it expires we shoot a beat early rather than lose the item. Screenshots verified fully rendered (fonts, images, product mockups).

3. Budgets sized for what this app is

Splitting the settle out exposed the nav budget, and 30s alone still wasn't enough for a heavy page on a slow link. board-oss is single-tenant and self-hosted: nobody is queued behind you competing for the worker, so a capture that takes two minutes and succeeds beats one that fails fast and leaves you re-adding the link by hand. These are ceilings for a wedged job, not latency targets.

before after
navigation 30s 120s
network settle 30s (new, best-effort)
og:image 8s 30s
snapshot 45s 180s
CLI agent 120s 300s
capture job 180s 600s

The job budget must exceed the sum of its parts or it silently truncates them; config.test.ts asserts that relationship now instead of a magic number.

Raising the job ceiling also made one previously-survivable gap dangerous: captureLibrary's direct fetch had no timeout at all, so a hanging server would have stranded an item in processing for 10 minutes and held the serial lane. Longer budgets are only safe for bounded waits — it now has its own 60s ceiling.

Same throttle that killed twenty.com at 30.9s now captures it in 78s.

Verification

  • 595 tests + typecheck green
  • End-to-end against a copy of the real DB: four concurrent adds, all done, 21 enriched fields and a screenshot asset each
  • Throttled captures of both reported URLs now succeed
  • Live readable path re-checked (listmonk 872ms, GitHub repo 1448ms)

🤖 Generated with Claude Code

https://claude.ai/code/session_01C83mrW9X8zBLY1sSZRgdCa

Seanathon and others added 3 commits August 19, 2026 14:04
Adding a link blocked for as long as the previous capture took. Measured on
the real server: with one capture in flight, POST /api/collections/:cid/items
returned in 42s; back-to-back adds all stalled until the first job finished.
Worst case is CAPTURE_TIMEOUT_MS (180s) of an apparently-hung request.

Jobs ran on the same promise chain as writes, so a capture held the SQLite
write lane for its full duration — Chrome launch, page load, and a ~30s LLM
round-trip, none of which write anything. addItemSkill's one-row INSERT queued
behind all of it.

The two constraints collapsed into that chain were never the same constraint:
concurrency 1 for JOBS bounds memory (Chromium is ~400-520MB resident; two
concurrent captures OOM the 512MB-1GB LXC, NFR-1), while single-writer for
SQLITE bounds write interleaving and every such write is sub-millisecond.
Split into two lanes. Jobs still run strictly one at a time — verified end to
end, max concurrent `processing` stayed 1 across four queued adds — and POST
now returns in ~54ms.

This revises AD6, which documented the job queue as *being* the SQLite
single-writer guard. Architecture doc updated; worker.test.ts's "no double
serializer" case is deleted rather than weakened, since it asserted exactly
the shared lane that caused this. The invariants that matter are kept and
asserted separately: jobs stay concurrency 1, and a write no longer queues
behind a job.

Note the smaller-looking fix is the unsafe one. addItemSkill awaits its write
before firing the job, and that ordering is what guarantees the row exists
before the job mutates it; simply not awaiting would leave the job's status
UPDATE hitting a missing row and capture spreading an undefined item, dropping
source/status. Splitting the lanes preserves the ordering — the write returns
in milliseconds off an idle write lane.

Separate lanes do open a window the shared lane had closed: capture and
enrichment read the item, await (tens of seconds for the LLM), then write the
full row back, so an edit landing mid-flight would be silently reverted. Both
call sites now re-read inside a single enqueued write op with no await between
read and write. Covered by a new test that patches notes and a user field
during the LLM call and asserts both survive alongside the enrichment result.

Status writes in runItemJob stay direct on purpose: they are single-column
updates guarded by a synchronous signal.aborted check, and enqueueing them
would split the check from its write, letting abandoned work clobber the
timeout path's terminal error back to done.

593 tests + typecheck green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C83mrW9X8zBLY1sSZRgdCa
This is the failure that was reported: twenty.com and stigg.io died at the
screenshot step showing only "Timed out."

Capture navigated with `waitUntil: 'networkidle2'`, which made a QUIET NETWORK
a precondition of getting an item at all. Any page holding a connection open —
analytics beacon, chat widget, video preload, websocket — never satisfies it,
and a slow link does the same to an ordinary page. goto then rejected with a
TimeoutError, which cleanErrorReason maps to 'timed out' and the card renders,
capitalized, as "Timed out." — the exact string reported.

Reproduced under CDP throttling: twenty.com failed at 30755ms. browser.ts's
renderPageText had already learned this and carries a comment saying so; the
screenshot path never got the same treatment.

Navigate on `domcontentloaded`, then wait for the network to settle separately
and BEST-EFFORT — if it expires we shoot a beat early instead of losing the
item. Verified the screenshots are fully rendered (fonts, images, product
mockups), so nothing is lost by not blocking on idle.

Splitting the settle out then exposed the nav budget: 30s had been absorbing
both, and alone it still wasn't enough for a heavy page on a slow link.
Budgets are now sized for what this app actually is — single-tenant and
self-hosted, with nobody queued behind you competing for the worker. A capture
that takes two minutes and SUCCEEDS beats one that fails fast and leaves you
re-adding the link by hand, so these are ceilings for a wedged job, not
latency targets:

  navigation      30s  -> 120s
  network settle   --  ->  30s  (new, best-effort)
  og:image        8s   ->  30s  (silently dropped hero images on slow links)
  snapshot        45s  -> 180s  (SingleFile inlines every asset; failure is
                                 swallowed, so it just archived nothing)
  CLI agent      120s  -> 300s
  capture job    180s  -> 600s

The job budget has to EXCEED the sum of its parts or it silently truncates
them; config.test.ts now asserts that relationship instead of a magic number,
so raising a part without raising the whole fails the suite. Same throttle that
killed twenty.com at 30.9s now captures it in 78s.

594 tests + typecheck green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C83mrW9X8zBLY1sSZRgdCa
…item

captureLibrary's direct fetch had no timeout and no signal. That was survivable
while the whole capture job expired at 180s; raising the job budget to 600s in
the previous commit turned it into a 10-minute stuck `processing` item, and the
job lane is serial, so every queued add would have waited behind it.

Longer budgets are safe for BOUNDED waits. This was the one step that wasn't
bounded at all, so it inherited the ceiling instead of having its own. Gives it
a 60s AbortSignal.timeout — generous, but finite.

Verified the live readable path still captures (listmonk 872ms, a GitHub repo
1448ms, hero images intact).

Also documents why CapturePage.waitForNetworkIdle is optional: it exists so
hand-written test fakes stay valid, not because production might skip the
settle. puppeteer-core is pinned ^24 and Page has had the method since v5, so
the `?.` at the call site always resolves.

595 tests + typecheck green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C83mrW9X8zBLY1sSZRgdCa
@Seanathon
Seanathon merged commit c994e6e into main Aug 19, 2026
2 checks passed
@Seanathon
Seanathon deleted the fix/write-lane-head-of-line-blocking branch August 19, 2026 21:29
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