fix: capture reliability — job/write lane split, network-idle precondition, and realistic budgets - #14
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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
processingstayed 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:
addItemSkillawaits 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, whichcleanErrorReasonmaps 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'srenderPageTexthad already learned this and carries a comment saying so; the screenshot path never got the same treatment.Now navigates on
domcontentloadedand 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.
The job budget must exceed the sum of its parts or it silently truncates them;
config.test.tsasserts 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 inprocessingfor 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
done, 21 enriched fields and a screenshot asset each🤖 Generated with Claude Code
https://claude.ai/code/session_01C83mrW9X8zBLY1sSZRgdCa