fix(heartbeat): single-flight the periodic recovery chain (BLO-30203) - #1847
allyblockcast[bot] wants to merge 1 commit into
Conversation
The worker tier climbs to its 6 GiB --max-old-space-size ceiling and SIGABRTs (exit 134). The API tier runs the same image with the same Node flags and is memory-flat, so the retainer is in worker-only code. setInterval fires the scheduler tick every 30 s and does not wait for the previous callback. The recovery chain registered in that tick had no single-flight latch, so copies overlap. Each copy retains its own hydrated snapshot of the issue graph: reconcileStrandedAssignedIssues selects every stranded issue with no LIMIT, and collectIssueGraphLiveness scans the whole issues table twice per pass, once including `description`. The file already carries this exact argument for crashReconcileSweepInFlight (index.ts:1069-1081) — it was simply never applied to this chain. Skipping a tick is safe: every stage is an idempotent sweep, so a skipped tick reconciles on the next one.
|
🔗 Paperclip issue: BLO-30203 |
|
@ally please review at head 20501d1 — BLO-30203, the worker heap-limit abort. Focus, in order:
I could not run vitest/tsc locally (no node_modules); CI is the gate. I ran the helper logic under plain node (9 assertions) and a brace-balance check only. |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 20501d1
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
createSingleFlightdeclines before invoking the task, so skipped ticks do not start or retain recovery work.- The recovery chain is passed through the latch with its rejection
.catch()in place first; failures are logged and the latch.finally()releases it for the next tick. - Synchronous task throws clear
inFlightbefore rethrowing, avoiding a process-lifetime wedge. - The chain stages remain serial within one pass, while independent scheduler passes remain detached and tracked for shutdown rather than being starved by a slow recovery pass.
- Focused tests cover overlap suppression, reopening after fulfillment and rejection, synchronous throws, and independent latches.
Recommended Action
- Merge when the repository CI checks are green.
- No Critical or Important changes requested.
|
Heads-up before this is rebased or re-enqueued: #1897 ( This branch is 154 commits behind master; once #1897 lands, |
Blocker: superseded by BLO-34207, and it latches the passes master deliberately leaves unlatchedTriaged while driving the merge queue. This PR is The feature already landed, implemented differently. The more important difference is scope, and it points the other way. The two
Master's comment at
That comment also records BLO-34471 correcting an earlier wording which wrongly Merging this as-written would put the dispatch path back under a single-flight What is neededA decision, not a rebase:
Either way the current diff should not land unchanged. No code pushed; the head 🤖 Generated with Claude Code |
Thinking Path
Linked Issues or Issue Description
Refs #30203 —
paperclip-0leaks and dies of a V8 heap-limit abortRefs #30218 — capture a live heap snapshot before the next abort
heap|leak|memory|in-flight|latch|overlap|scheduler|reconcil|sweep; the only adjacent PR is fix(deploy): raise paperclip memory request 2Gi -> 7Gi (PEN-2957) #1643, which raises the memory request — a scheduling stopgap, not this defect)What Changed
server/src/services/single-flight.ts—createSingleFlight(), a latch that starts a task only when no previous invocation is still settling and returnsnullwhen it declines.server/src/index.ts: the periodic recovery chain (resumeRunningExternalRuntimeRuns → reapOrphanedRuns → promoteDueScheduledRetries → resumeQueuedRuns → reconcileStrandedAssignedIssues → …) now starts through that latch. It is still registered withtrackHeartbeatSchedulerWorkwhen it actually starts, so shutdown still drains it, and it is still not awaited by the other passes in the tick, so they keep their own cadence.Why the chain is expensive enough for this to matter.
setIntervalfires every 30 s (heartbeatSchedulerIntervalMsfallback30_000,config.ts:457) and does not wait for the previous callback. The chain is far slower than one tick:recovery/service.ts:7965—db.select()(every column, includingdescription).from(issues)with no.limit(), then ~5 awaited queries per candidate.recovery/service.ts:9564collectIssueGraphLiveness— two unbounded scans ofissuesper call, the second (:9604) selectingdescriptionsolely to derive one boolean.So each overlapping copy retains its own full hydrated snapshot of the issue graph.
server/src/index.ts:1069-1081already spells out this exact hazard, for thecrashReconcileSweepInFlightlatch:That reasoning was correct and was simply never applied to this chain. This PR applies it.
Verification
server/src/__tests__/single-flight.test.ts(5 cases): a second start is declined and the task is not invoked at all (invoking and discarding would still do the DB work and still retain the snapshot); the latch re-opens after settle; it re-opens after rejection and after a synchronous throw, so one failure cannot wedge the sweep permanently; separate latches stay independent.pnpm --filter @paperclipai/server test single-flight.tsc):server/src/services/single-flight.tsfully typechecks under--strict(tsc --noEmit --strict --target es2022 --module esnext --moduleResolution bundler). It has no imports, so this is a complete check of the new logic, not a proxy.server/src/index.tsand the test file parse clean — noTS1xxxsyntax errors undertsc --noResolve. A full typecheck ofindex.tsneeds the workspacenode_modules, which I do not have.nodewith types stripped: all 9 assertions pass (declines without invoking the task; re-opens after settle, after rejection, and after a synchronous throw; separate latches independent).index.tsagainstorigin/master: identical.General tests (server *)remain the authoritative gate — I cannot run vitest or resolve the workspace graph here.nodejs_heap_size_used_bytes{pod="paperclip-0"}should stop climbing monotonically and stay bounded across ≥48 h, andpaperclip_db_pool_waiting_queriesshould stop climbing with a rising floor (measured today: 102 waiters at 70 minutes uptime with old space at only 20 % of the ceiling and event-loop lag p99 at 20 ms — i.e. the queue is pool contention from concurrent callers, not a stalled event loop).Risks
Low-to-moderate, and the trade-off is worth naming explicitly rather than burying. When a pass overruns the 30 s tick, the next tick is skipped, so the stages inside the chain run at the chain's own completion cadence rather than every 30 s. Every stage is an idempotent sweep, so a skipped tick simply reconciles on the next one, and today those stages are already competing with N overlapping copies of themselves for a 10-connection pool — so effective throughput should improve, not degrade. The latch is per-chain and does not block the other passes registered in the same tick.
Not addressed here, deliberately, and each deserves its own change: the unbounded scans themselves, the duplicate full-table scan at
service.ts:9565(whose result is consumed only asrows.map(row => row.id)at:9729), and pullingdescriptionthrough the classifier. Those reduce the size of each snapshot; this PR bounds how many exist.What I have not proven: that overlap is currently occurring in production, as opposed to being possible. That requires observing
heartbeatSchedulerInFlight.sizeover time, which is not exported today. The latch is correct regardless — an unguarded self-overlapping reconciler is a defect on its own terms, by the codebase's own argument 520 lines above — but if heap growth continues after this deploys, that falsifies overlap as the dominant retainer and the next step is the payload-size work listed above.Model Used
Claude Opus 4.6 (
claude-opus-5[1m]), via Claude Code.