Harden the abandoned-session sweep against starvation, mis-reported discards, and filename collisions - #228
Merged
Merged
Conversation
…iscards, and filename collisions Six review findings against the streaming-ingest staging tier (docs/streaming-ingest-design.md), all addressed in this PR: 1. Candidate starvation. scheduled-staging-sweep.ts read one page of 30 oldest-open-session candidates per run and never advanced past it, so a page's worth of long-lived or zombie sessions at the head of the queue (ordered by expiresAt) could block recovery of everything abandoned behind them for up to the 24-hour TTL. listOldestOpenSessions (staging.ts) now takes an optional cursor and the sweep pages through candidates -- startAfter(expiresAt, sessionId) -- until MAX_SESSIONS_PER_RUN sessions are actually recovered or discarded, the table is exhausted, or a hard MAX_PAGES_PER_RUN (20) ceiling is hit. Pages fetched are recorded in SweepStats and systemStatus/staging as `pages`. 2. Discard outcome accuracy. discardSession (staging.ts) swallowed its own RTDB error and returned void, so the sweep reported "recovered" regardless of whether the staging node was actually removed. If the discard fails after a queue entry was written, the next run reassembles and re-queues the same session under the same (now deterministic) filename; if that first entry had already completed, a duplicate partial reaches the provider. discardSession now returns a boolean (still never throws); the sweep only counts/logs "recovered" or "discarded" when the removal actually succeeded, otherwise counting it as an error so a persistently failing discard path is visible. recoverSession also checks for an existing uploadQueue document with the same deduplication key (queue-upload.ts's new queueDocIdFor helper) that is already "completed" before queueing, and skips the re-queue if so. 3. Concurrency limiter. The live-sessions reconciliation pass ran an unbounded Promise.all over up to 500 getSessionMeta calls. Replaced with mapWithConcurrency (new functions/src/concurrency-limit.ts), a small local worker-pool helper with no new dependency, at a concurrency of 20. 4. Partial filename collisions. partialFilenameFor (staging-assembly.ts) used only the client-supplied filename, so two abandoned sessions named e.g. "data.csv" produced the identical experimentID:filename deduplication key and the second recovery silently overwrote the first's payload in Cloud Storage. A client-supplied name now always gets an 8-hex-char suffix (a sha256 of the session id); the id-only fallback for sessions with no filename is unchanged, since it is already unique. Updated the filename description in pages/docs/experiments/sending-data.js and docs/streaming-ingest-design.md. 5. Salvage instead of discard on data-content refusals. api-data.ts discarded the staged copy on all eight refusal branches, including INVALID_DATA and both duplicate-filename refusals (the collision cache's "duplicate" verdict and the provider's own NAME_CONFLICT dual-run backstop) -- refusals about THIS SUBMISSION, not about the experiment's willingness to accept data. Those three branches now leave staging alone; the sweep's second door (finalized/active re-check) still covers a since-closed experiment. The four experiment-state gates (finalized, inactive, session-cap, unknown experiment) are unchanged. 6. Confirmed (read, did not need to change) that a missing or unreachable RTDB is caught at every call site in the sweep and only ever surfaces as a recorded systemStatus/staging error, never an uncaught throw. Added a cheap regression test pointing STAGING_DATABASE_URL at a refused local port. Tests added, all in functions/src/__tests__/: - concurrency-limit.test.js (new, pure, no emulator): mapWithConcurrency ordering, concurrency ceiling, exhaustiveness, rejection propagation, edge cases. - staging-assembly.test.js: partialFilenameFor cases updated for the new hash suffix, plus a same-filename/different-session collision case. - staging-emulator.test.js: candidate-paging-past-a-wall-of-live-sessions, same-filename distinct queue entries, discard-failure not counted as recovered, no re-queue of an already-completed session after a discard failure, unreachable-database survival, and INVALID_DATA leaving staging in place (existing finalized-still-discards test serves as the control). Existing p07/p09 filename assertions updated for the new hash suffix. `cd functions && npm run build` and `npm run lint` (repo root) are both clean. Emulator suites were not run here per the parent's instructions; see the report for the exact files to run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8xf16ov88M3KojPVfQwJT
4 tasks
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.
Summary
Six review findings against the streaming-ingest staging tier's abandoned-session sweep (
docs/streaming-ingest-design.md), each addressed here:Candidate starvation.
scheduled-staging-sweep.tsread one page of the oldest open-session candidates per run and never advanced past it, so a page's worth of long-lived or zombie sessions at the head of the queue (ordered byexpiresAt) could block recovery of everything abandoned behind them for up to the 24-hour TTL.listOldestOpenSessions(staging.ts) now takes an optionalstartAfter(expiresAt, sessionId)cursor, and the sweep pages through candidates untilMAX_SESSIONS_PER_RUNsessions are actually recovered or discarded, the table is exhausted, or a hardMAX_PAGES_PER_RUN(20) ceiling is hit. Pages fetched are recorded aspagesinSweepStatsandsystemStatus/staging.Discard outcome accuracy.
discardSessionswallowed its own RTDB error and returnedvoid, so the sweep reported "recovered" regardless of whether the staging node was actually removed — if the discard failed after a queue entry was written, the next run would reassemble and re-queue the same session, landing a duplicate partial if the first entry had already completed.discardSessionnow returns aboolean(still never throws); the sweep only counts/logs "recovered" or "discarded" when the removal actually succeeded, otherwise counting it as an error.recoverSessionalso checks for an existinguploadQueuedocument with the same deduplication key (queue-upload.ts's newqueueDocIdForhelper) that is already"completed", and skips the re-queue if so.Concurrency limiter. The live-sessions reconciliation pass ran an unbounded
Promise.allover up to 500getSessionMetacalls. Replaced withmapWithConcurrency(newfunctions/src/concurrency-limit.ts), a small local worker-pool helper with no new dependency, at a concurrency of 20.Partial filename collisions.
partialFilenameForused only the client-supplied filename, so two abandoned sessions named e.g.data.csvproduced the identicalexperimentID:filenamededuplication key and the second recovery silently overwrote the first's payload in Cloud Storage. A client-supplied name now always gets an 8-hex-char suffix (a sha256 of the session id); the id-only fallback for sessions with no filename is unchanged, since it's already unique. Updated the filename description inpages/docs/experiments/sending-data.jsanddocs/streaming-ingest-design.md.Salvage instead of discard on data-content refusals.
api-data.tsdiscarded the staged copy on every refusal branch, includingINVALID_DATAand both duplicate-filename refusals (the collision cache's"duplicate"verdict and the provider's ownNAME_CONFLICTdual-run backstop) — refusals about this submission, not about the experiment's willingness to accept data at all. Those three branches now leave staging alone; the sweep's second door (finalized/active re-check) still covers a since-closed experiment. The four experiment-state gates (finalized, inactive, session-cap, unknown experiment) are unchanged and still discard.Unreachable database. Confirmed (by reading the code, and with a cheap regression test) that a missing or unreachable RTDB is caught at every call site in the sweep and only ever surfaces as a recorded
systemStatus/stagingerror, never an uncaught throw.Deliberately untouched
assembleSession(staging-assembly.ts),api-session-start.ts, anddatabase.rules.jsonare untouched on purpose — a sibling PR is concurrently changing them, and this scoping is what lets the two merge cleanly.Tests added
functions/src/__tests__/concurrency-limit.test.js(new, pure, no emulator):mapWithConcurrencyordering, concurrency ceiling, exhaustiveness, rejection propagation, edge cases.functions/src/__tests__/staging-assembly.test.js:partialFilenameForcases updated for the new hash suffix, plus a same-filename/different-session collision case.functions/src/__tests__/staging-emulator.test.js: paging past a wall of live sessions to recover an abandoned one behind them; two same-filename sessions surviving as distinct queue entries; a discard failure not being counted as recovered; no re-queue of a session whose recovery already completed before a discard failure; the sweep surviving an unreachable staging database; andINVALID_DATAleaving the staged session in place (the existing finalized-still-discards test serves as the control). Existingp07/p09filename assertions updated for the new hash suffix.Test plan
cd functions && npm run buildcleannpm run lint(repo root) cleanstaging-emulator,staging-assembly,concurrency-limit,session-id-validation-emulator,data-emulator,collision-integration-emulator(98 tests)🤖 Generated with Claude Code
https://claude.ai/code/session_01Q8xf16ov88M3KojPVfQwJT