Skip to content

fix(libs): follow-ups to PR #604 review (build break + Container disposer + queryPage memory profile)#605

Merged
sroussey merged 4 commits into
claude/beautiful-mayer-74ff08from
claude/beautiful-mayer-q5iltz-pr604-fixups
Jul 2, 2026
Merged

fix(libs): follow-ups to PR #604 review (build break + Container disposer + queryPage memory profile)#605
sroussey merged 4 commits into
claude/beautiful-mayer-74ff08from
claude/beautiful-mayer-q5iltz-pr604-fixups

Conversation

@sroussey

@sroussey sroussey commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

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-74ff08 so the diff is scoped to the follow-up work.

Commits

1. fix(task-graph): swap nonexistent storage.search for query in clearOlderThan

  • Bug: RunPrivateTaskOutputRepository.clearOlderThan invoked this.storage.search(...) in the non-empty-excludeRunIds branch. ITabularStorage has no search method — the correct symbol is query. The call would TypeError at 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 at RunPrivateTaskOutputBacking = ITabularStorage<...> but the sole search call routed through the interface's method table at runtime.
  • Fix: Swap searchquery with the same criteria shape ({ createdAt: { value: cutoff, operator: "<" } }), matching the operator syntax already used by the deleteSearch calls on the same file.
  • Test: New smoke test clearOlderThan with a non-empty excludeRunIds does not throw (in packages/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-path

  • Bug: CacheJanitorOptions.liveRunIds was optional. Callers who omitted it silently opted into a sweep that could reap in-flight runs' cache rows — a one-time console.warn isn't enough to make that safe. Related, RunPrivateTaskOutputRepository.clearOlderThan had an excludeRunIds.size === 0 early-return that bypassed the streaming path entirely and issued a single unbounded deleteSearch — a second code path with different memory characteristics from the exclude-set path.
  • Fix: Make liveRunIds required (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 the excludeRunIds.size === 0 fast-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.
  • Tests:
    • Updated existing CacheJanitor.test.ts cases to pass liveRunIds: () => [] explicitly.
    • New omitting liveRunIds is a compile-time error (uses @ts-expect-error).
    • New empty liveRunIds still respects the per-run indexed delete path — seeds 3 stale runs + 1 live-but-excluded run, calls sweepStaleRunPrivate with () => ["live"], asserts live-run rows survive and 3 stale sets are deleted.

3. fix(util/di): drain eviction disposals and clear stale factories on registerInstance

  • Bug: fix(libs): janitor live-run guard, Container dispose-on-replace, vector default parity, StreamPump cancel #604's disposer fix for register / registerInstance fired the previous singleton's disposer with void. The caller — and dispose() 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 the factories map; a subsequent remove() + get() could resurrect the factory-built object instead of failing loudly.
  • Fix:
    • Track in-flight eviction disposals in a pendingDisposals: Map<string, Promise<void>>.
    • New public awaitReplacement(token: string): Promise<void> — resolves once the pending disposal (if any) for token settles.
    • New public awaitRegistrations(): Promise<void> — awaits every pending disposal.
    • dispose() calls awaitRegistrations() at the top before iterating live singletons and clears pendingDisposals alongside the other maps in finally.
    • Identity-guarded .finally() clears each entry only if it hasn't been superseded by a newer replacement.
    • registerInstance() now also calls this.factories.delete(token) alongside its other state mutations.
  • Tests (in packages/test/src/test/util/Container.test.ts):
    • register schedules eviction dispose and awaitReplacement resolves after it completes — gated disposer; asserts disposeCalled is false synchronously after register, then true after resolving the gate and awaiting awaitReplacement.
    • 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) === false after a registerregisterInstance sequence.

4. perf(task-graph): stream distinct runIds via queryPage in clearOlderThan

  • Bug: After commit 1 fixed the searchquery typo, the still-buggy shape remained: clearOlderThan issued one query(...) 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.
  • Fix: 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), not row count. Every page carries a limit so SQL backends push both the WHERE and the keyset predicate into the SELECT (BaseTabularStorage's composite-PK fallback still fetches per-page via query; SQL backends override queryPage with 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 private deleteRunOlderThanAt(runId, cutoff) helper so the sweep dispatches per-run deletes against a single pre-computed cutoff and emits output_pruned exactly once at the end (the public deleteRunOlderThan still emits per-call).
  • Tests:
    • sweepStaleRunPrivate does not materialize the full stale row set at once (in CacheJanitor.test.ts) seeds 5000 stale rows across 3 runs, wraps queryPage in a spy, and asserts (a) ≥10 pagination calls (5000 rows / 500 per page), (b) every call carries a bounded limit, (c) the sweep completes and reaps every stale row.
    • clearOlderThan preserves live runs even when they have stale rows in a new packages/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:

bun x turbo run build-types --filter=@workglow/task-graph
# → 4 tasks successful, 0 failures.

bun scripts/test.ts graph vitest
# → 76 test files, 741 tests passed (adds 5 new tests: 4 in CacheJanitor.test.ts
#   + 1 in RunPrivateTaskOutputRepository.test.ts).

bun scripts/test.ts util vitest
# → 44 test files, 652 tests passed / 10 skipped (adds 4 new Container tests).

Base

Stacked on #604 (base branch claude/beautiful-mayer-74ff08, head SHA b33165d). Do not squash — the 4 commits are intentionally separate so bug fixes and perf improvements can be reviewed independently.


Generated by Claude Code

claude added 4 commits July 1, 2026 08:28
…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.
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 62.74% 25556 / 40732
🔵 Statements 62.59% 26445 / 42251
🔵 Functions 63.99% 4856 / 7588
🔵 Branches 51.5% 12523 / 24312
File CoverageNo changed files found.
Generated in workflow #2630 for commit 0010a63 by the Vitest Coverage Report Action

@sroussey sroussey merged commit 9ff4449 into claude/beautiful-mayer-74ff08 Jul 2, 2026
10 checks passed
@sroussey sroussey deleted the claude/beautiful-mayer-q5iltz-pr604-fixups branch July 2, 2026 18:09
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.

2 participants