fix(libs): follow-ups to PR #604 review (build break + Container disposer + queryPage memory profile)#605
Merged
sroussey merged 4 commits intoJul 2, 2026
Conversation
…derThan
`RunPrivateTaskOutputRepository.clearOlderThan` invoked
`this.storage.search(...)`, but `ITabularStorage` has no `search` method — the
non-empty-`excludeRunIds` branch would throw `TypeError` at runtime. Swap in
`this.storage.query({...})`, which has the same criteria shape.
Add a smoke test `clearOlderThan with a non-empty excludeRunIds does not throw`
so the regression stays regressed.
… fast-path The optional `liveRunIds` callback let callers silently opt into a sweep that could reap in-flight runs' cache rows — a one-time `console.warn` isn't sufficient. Make the option required so the empty-list case is a conscious choice at every call site. Also drop the `excludeRunIds.size === 0` early-return branch in `RunPrivateTaskOutputRepository.clearOlderThan`. The distinct-runId loop below is correct for the empty-set case: it iterates every stale runId and calls `deleteRunOlderThan`, so no rows are missed. Removing the fast-path centralizes the eviction path so the streaming rewrite in the follow-up commit is the sole implementation. Existing tests updated to pass `liveRunIds: () => []` explicitly. New tests: - `omitting liveRunIds is a compile-time error` (`@ts-expect-error`). - `empty liveRunIds still respects the per-run indexed delete path` — seeds 3 stale runs + 1 live-but-excluded run and asserts the excluded rows survive. README example updated to match the new signature.
…egisterInstance `register()` and `registerInstance()` fired eviction disposers with `void`, so `dispose()` (and the caller) had no way to observe when the released resource actually shut down. A fast re-register / dispose sequence could clear container state while the async disposer was still holding a DB connection open. Track in-flight eviction disposals in a `pendingDisposals` map, expose `awaitReplacement(token)` / `awaitRegistrations()`, and drain the map at the top of `dispose()`. Entries are identity-guarded via `.finally()` so a subsequent replacement's promise doesn't get erased. Also: `registerInstance()` now clears any previously-registered factory for the token. Leaving the stale factory behind meant a later `remove()` + `get()` could resurrect the factory-built object instead of failing loudly. New tests: - `register schedules eviction dispose and awaitReplacement resolves after it completes` - `container.dispose() drains pending disposals from prior register replacements` - `registerInstance eviction is drained by awaitReplacement` - `registerInstance clears any previously-registered factory for the token`
The previous implementation of `RunPrivateTaskOutputRepository.clearOlderThan`
issued `storage.query({ createdAt: { < } })` for the ENTIRE stale row set in
one call, then walked the buffer to extract distinct runIds. On a table with
millions of stale rows this materializes an O(row-count) buffer of Entity
objects (including the blob column) — a memory hazard proportional to how
stale-friendly the caller's cutoff is.
Rewrite the sweep to iterate the stale set via cursor-paginated `queryPage`
in bounded 500-row batches. `seenRunIds` is bounded by distinct runId count
(typically much smaller than row count), and each page carries a `limit` so
SQL backends push both the WHERE and the keyset predicate into the SELECT.
Default ordering is PK ASC (runId-leading), which groups a run's rows
contiguously and lets distinct runIds accumulate quickly.
Also introduce a private `deleteRunOlderThanAt(runId, cutoff)` helper so the
sweep can dispatch per-run deletes against a single pre-computed cutoff
without triggering the per-run `output_pruned` emit. The public
`deleteRunOlderThan` still emits per call; the all-runs sweep emits exactly
once at the end.
New tests:
- `sweepStaleRunPrivate does not materialize the full stale row set at once`
seeds 5000 stale rows across 3 runs and asserts `queryPage` is called ≥10
times with a bounded `limit` on every call.
- `clearOlderThan preserves live runs even when they have stale rows` (in a
new `RunPrivateTaskOutputRepository.test.ts`) verifies the excludeRunIds
contract when the excluded run's rows all predate the cutoff.
Coverage Report
File CoverageNo changed files found. |
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.
Stacked on top of #604 — this branch is not a rewrite of that PR, it applies four independent fixups on top of it. Base is
claude/beautiful-mayer-74ff08so the diff is scoped to the follow-up work.Commits
1.
fix(task-graph): swap nonexistent storage.search for query in clearOlderThanRunPrivateTaskOutputRepository.clearOlderThaninvokedthis.storage.search(...)in the non-empty-excludeRunIdsbranch.ITabularStoragehas nosearchmethod — the correct symbol isquery. The call wouldTypeErrorat runtime as soon as the janitor was configured with a live-run exclude set (which the very next commit in fix(libs): janitor live-run guard, Container dispose-on-replace, vector default parity, StreamPump cancel #604 made the default). Type-checking missed it because the backing was typed atRunPrivateTaskOutputBacking = ITabularStorage<...>but the solesearchcall routed through the interface's method table at runtime.search→querywith the same criteria shape ({ createdAt: { value: cutoff, operator: "<" } }), matching the operator syntax already used by thedeleteSearchcalls on the same file.clearOlderThan with a non-empty excludeRunIds does not throw(inpackages/test/src/test/task-graph-cache/CacheJanitor.test.ts) exercises the previously-broken branch end-to-end.2.
fix(task-graph/cache): require liveRunIds callback and drop unguarded fast-pathCacheJanitorOptions.liveRunIdswas optional. Callers who omitted it silently opted into a sweep that could reap in-flight runs' cache rows — a one-timeconsole.warnisn't enough to make that safe. Related,RunPrivateTaskOutputRepository.clearOlderThanhad anexcludeRunIds.size === 0early-return that bypassed the streaming path entirely and issued a single unboundeddeleteSearch— a second code path with different memory characteristics from the exclude-set path.liveRunIdsrequired (drop the?on the option, drop the default and the one-time warn). Callers that genuinely never sweep concurrent with a live run pass() => []explicitly. Also drop theexcludeRunIds.size === 0fast-path so the distinct-runId loop is the sole eviction implementation (correct for the empty-set case: it reaps everything). README example updated to match.CacheJanitor.test.tscases to passliveRunIds: () => []explicitly.omitting liveRunIds is a compile-time error(uses@ts-expect-error).empty liveRunIds still respects the per-run indexed delete path— seeds 3 stale runs + 1 live-but-excluded run, callssweepStaleRunPrivatewith() => ["live"], asserts live-run rows survive and 3 stale sets are deleted.3.
fix(util/di): drain eviction disposals and clear stale factories on registerInstanceregister/registerInstancefired the previous singleton's disposer withvoid. The caller — anddispose()itself — had no way to observe when the released resource actually shut down. A fast re-register + dispose sequence could clear container state while an async disposer was still holding a DB connection open. Separately,registerInstance()left any previously-registered factory in thefactoriesmap; a subsequentremove()+get()could resurrect the factory-built object instead of failing loudly.pendingDisposals: Map<string, Promise<void>>.awaitReplacement(token: string): Promise<void>— resolves once the pending disposal (if any) fortokensettles.awaitRegistrations(): Promise<void>— awaits every pending disposal.dispose()callsawaitRegistrations()at the top before iterating live singletons and clearspendingDisposalsalongside the other maps infinally..finally()clears each entry only if it hasn't been superseded by a newer replacement.registerInstance()now also callsthis.factories.delete(token)alongside its other state mutations.packages/test/src/test/util/Container.test.ts):register schedules eviction dispose and awaitReplacement resolves after it completes— gated disposer; assertsdisposeCalledis false synchronously afterregister, then true after resolving the gate and awaitingawaitReplacement.container.dispose() drains pending disposals from prior register replacements.registerInstance eviction is drained by awaitReplacement.registerInstance clears any previously-registered factory for the token— asserts(container as any).factories.has(token) === falseafter aregister→registerInstancesequence.4.
perf(task-graph): stream distinct runIds via queryPage in clearOlderThansearch→querytypo, the still-buggy shape remained:clearOlderThanissued onequery(...)for the ENTIRE stale row set (no limit) and walked the buffer to extract distinct runIds. On a table with millions of stale rows this materializes an O(row-count) buffer of Entity objects (blob column included) — memory hazard proportional to how stale-friendly the caller's cutoff is.queryPagein bounded 500-row batches.seenRunIdsis bounded by distinct runId count (typically much smaller than row count), not row count. Every page carries alimitso SQL backends push both the WHERE and the keyset predicate into the SELECT (BaseTabularStorage's composite-PK fallback still fetches per-page viaquery; SQL backends overridequeryPagewith tuple-comparison pushdown). Default ordering is PK-ASC (runId-leading), so a run's rows sit contiguously and distinct runIds accumulate quickly. Also introduce a privatedeleteRunOlderThanAt(runId, cutoff)helper so the sweep dispatches per-run deletes against a single pre-computed cutoff and emitsoutput_prunedexactly once at the end (the publicdeleteRunOlderThanstill emits per-call).sweepStaleRunPrivate does not materialize the full stale row set at once(inCacheJanitor.test.ts) seeds 5000 stale rows across 3 runs, wrapsqueryPagein a spy, and asserts (a) ≥10 pagination calls (5000 rows / 500 per page), (b) every call carries a boundedlimit, (c) the sweep completes and reaps every stale row.clearOlderThan preserves live runs even when they have stale rowsin a newpackages/test/src/test/task-graph-output-cache/RunPrivateTaskOutputRepository.test.ts— verifies the excludeRunIds contract when every row on the excluded run also predates the cutoff.Verification
Run from
/home/user/libs:Base
Stacked on #604 (base branch
claude/beautiful-mayer-74ff08, head SHAb33165d). Do not squash — the 4 commits are intentionally separate so bug fixes and perf improvements can be reviewed independently.Generated by Claude Code