Harden the streaming-ingest tier's bounds and add a kill switch - #229
Merged
Merged
Conversation
A review of the built staging tier (docs/streaming-ingest-design.md) found
that one anonymous POST /api/session bought a capability to force up to
10,000 trials x 64 KiB = 640 MB into RTDB for a single session id, that the
sweep's assembleSession read a whole session into memory before its
MAX_ASSEMBLED_BYTES cap applied (and compared that cap against UTF-16
`.length` rather than real bytes), and that nothing bounded how many
sessions one experiment id -- public by construction -- could have open at
once.
Rules caps: database.rules.json's $seq pattern is now 1-3 digits (1,000
trials) and the per-trial `.length` cap is 16384, both hand-copies of
MAX_TRIALS_PER_SESSION / MAX_TRIAL_BYTES in the shared constant module
(functions/src/staging-assembly.ts), which api-session-start.ts already read
to answer the client. __tests__/rules-constants.test.js parses the rules file
and asserts its literals still match those constants. The rules and code
comments now say plainly that RTDB's `.length` counts UTF-16 code units, not
bytes, and that MAX_ASSEMBLED_BYTES (measured with Buffer.byteLength) is the
byte-accurate backstop.
lastFlushAt: was `newData.isNumber()`, letting a client write any timestamp
including one far in the future -- which made disconnectedSince() treat
every later disconnect stamp as already answered, so such a session could
never be swept as abandoned. Now requires `newData.val() == now`, like the
other server-stamped timestamps in the tree.
Bounded assembly read: assembleSession no longer does one `.get()` of a
session's whole trials node. It now pages through
orderByKey().startAfter(lastKey) via a new pure accumulator,
assembleTrialsPaged (staging-assembly.ts), which stops asking for another
page the moment the accumulated byte size would cross MAX_ASSEMBLED_BYTES --
so an over-cap session is truncated without ever being pulled into memory in
full. Both assembleTrials and assembleTrialsPaged now cost each trial with
Buffer.byteLength instead of `.length`.
Per-experiment concurrency cap: openSessionCounts/{experimentId}
(functions/src/staging.ts) is a transactional counter, incremented in
openSession() and decremented in discardSession(), capping an experiment at
MAX_OPEN_SESSIONS_PER_EXPERIMENT (500) concurrently open sessions. It
self-heals negative drift in the transaction itself and the sweep runs
reconcileOpenSessionCounts each pass, scoped to that run's candidate
experiments, to correct a missed decrement. Deliberately not a per-IP
counter. A refusal returns the existing 503 SESSION_START_ERROR shape, which
the plugin already falls back from.
Kill switch: STREAMING_ENABLED=false makes POST /api/session return that
same 503 shape without touching Firestore or RTDB. Default (unset) is
enabled, so no existing deployment's behavior changes.
Updated pages/docs/api.js's example response and the design doc (new
"Hardening pass" section) to match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8xf16ov88M3KojPVfQwJT
…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
…fixture Two of the new "per-experiment concurrency cap" tests (staging-emulator.test.js) failed against a real emulator run: "lets a new session in once a prior one completes" routed the release through a full /api/data completion, which also exercises validation, the provider write and the metadata pipeline -- none of which this test is about -- so its pass/fail depended on that unrelated round trip instead of on discardSession's own slot release. Calls discardSession() directly now, the same way sweepAbandonedSessions is already exercised directly elsewhere in this file. "lets a new session in once an abandoned one is swept" seeded openSessionCounts directly with no matching openSessions entries behind it, then ran a real sweep. reconcileOpenSessionCounts recomputes an experiment's counter from the real entries under openSessions on every run -- correct behavior, since the counter has no legitimate reason to differ from what is actually open -- so it rightly overwrote the unsupported seeded value back to the true (lower) count. The fixture was wrong, not the reconcile: a new seedOpenSessions() helper seeds real (if synthetic) openSessions entries to match, parked a year out on expiresAt so they never crowd a same-run real session out of listOldestOpenSessions' oldest-30 window. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8xf16ov88M3KojPVfQwJT
Combines this branch's rules-cap reduction, paged assembly read, per-experiment concurrency cap and streaming kill switch with the sibling PR's sweep hardening (paged candidate scan past a wall of live sessions, discard-outcome-aware recovered/discarded counting, a bounded-concurrency mapWithConcurrency for live-sessions reconciliation, and a session-id hash suffix on recovered partial filenames to stop same-name collisions). Conflicts, all resolved by keeping both sides: - functions/src/staging-assembly.ts: the `createHash` import for the filename hash suffix, alongside this branch's shared-constant-module comment block. partialFilenameFor's hash-suffix rewrite and this branch's assembleTrialsPaged/streamingEnabled additions touch disjoint regions and merged cleanly on their own. - functions/src/staging.ts: discardSession now both reads experimentId before the delete (to release the concurrency slot) and returns whether the RTDB removal actually happened (the sibling PR's discard-outcome accuracy fix) -- the slot is released only when `removed` is true, so a failed delete (session still real) does not also undercount the counter. - functions/src/scheduled-staging-sweep.ts: candidateExperimentIds (this branch's scope for reconcileOpenSessionCounts) is now populated per PAGE inside the sibling PR's paged candidate loop, rather than from this branch's superseded single-page candidate read. Two stale CANDIDATES_PER_RUN comment references (renamed to CANDIDATES_PER_PAGE / MAX_PAGES_PER_RUN by the sibling PR) updated to match. - functions/src/__tests__/staging-emulator.test.js: one import-line conflict (both PRs added names to the same require() of staging.js); merged into one destructure. Every other hunk (the sibling PR's ~240 new lines, this branch's concurrency-cap and kill-switch describe blocks) merged automatically since they touch disjoint parts of a large file. `cd functions && npm run build` and `npm run lint` (repo root) are both clean. Pure tests run from the repo root, all passing: staging-assembly (35), rules-constants (3), concurrency-limit (new, 13), live-sessions (11), staging-session-id (4). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8xf16ov88M3KojPVfQwJT
The concurrency-cap tests no longer have anything covering the exact path
that originally read openSessionCounts as 500 instead of 499: the failing
test was rewritten to call discardSession() directly, which proves
discardSession releases a slot but nothing end-to-end proved a real
completion reaches it.
Root cause of the original reading, found while building this test: the
file's shared "staging-testuser" owner fixture sets
connectedAccounts.gdrive = { accessToken, refreshToken }, but
providers/gdrive.ts's resolveToken() reads .encryptedToken and
.tokenExpiresAt -- neither of which that fixture has. Every completion
attempt against it therefore fails at token resolution, before ever
reaching discardStaging -- the "DataPipe itself fails" branch, which by
design leaves the staged copy AND its counter slot in place for the sweep
to recover (see "keeps the staged copy when DataPipe itself fails"). The
500-instead-of-499 reading was that correct, intentional behavior, not a
bug: the test believed it was exercising a real completion when it was
actually exercising a token failure.
Added "releases the concurrency-cap slot on a genuine, non-refused
completion" (functions/src/__tests__/staging-emulator.test.js, top of the
"completion" describe block) with its own owner whose token shape
resolveToken() can actually resolve, so the request clears every gate and
reaches the provider. It cannot get a literal 201: this file has no mock
Google Drive server on GDRIVE_API_BASE (gdrive-emulator.test.js owns that
fixed port for the whole run, and a second listener would collide), so the
unreachable host makes claimFilename's cold-cache rehydration throw and
api-data.ts queues the upload for retry (202) instead -- still a genuine,
accepted completion, and that branch calls discardStaging on the way to
responding exactly as a real 201 would. Asserts the staging node and
capability record are gone and openSessionCounts is decremented (0 or
absent) afterward.
`cd functions && npm run build` and `npm run lint` (repo root) are both
clean. Pure tests re-run from the repo root, all still passing:
staging-assembly (35), rules-constants (3), concurrency-limit (13),
live-sessions (11), staging-session-id (4).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8xf16ov88M3KojPVfQwJT
The staging tier's limits (trial size, trial count, abandonment grace, disconnect slots, per-experiment concurrency, assembled-file size and filename length) were only visible as numbers in the /api/session example response on pages/docs/api.js, with no prose explaining what hitting any of them actually does to a researcher's data. Added a "Limits" subsection to the "Saving as you go" page (pages/docs/experiments/sending-data.js, id "streaming-limits", registered in lib/docs-nav.js) stating each limit's value and consequence, verified against database.rules.json, functions/src/staging-assembly.ts, functions/src/api-session-start.ts and functions/src/scheduled-staging-sweep.ts. The numbers are hardcoded with a comment naming the constant each mirrors, since pages cannot import from functions/src. Also added the missing "maxDisconnects": 20 to the /api/session example response in pages/docs/api.js (the endpoint already returns it) and a GuidanceLine pointing streaming users at the new subsection. `npm run lint` is clean. No test in __tests__ or lib currently exercises docs-nav.js or these two pages directly (grepped for both, per the parent task's instructions); DocsLayout's dev-only console.error assertion is the only existing enforcement of section-id/nav agreement, and the new id is registered to satisfy it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8xf16ov88M3KojPVfQwJT
lib/docs-nav.js's section lists for /docs/experiments/sending-data and
/docs/api were missing two ids that were already being rendered:
"saving-as-you-go" (the "Saving as you go" section, added before this
PR's own "streaming-limits" addition) and "start-session" ("Start an
incremental session" on the API reference page). Both are registered now,
in their rendered positions, with labels matching their section titles.
Checked every other DocsSection id rendered on both pages against the nav
list -- no others were missing.
`npm run lint` is clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8xf16ov88M3KojPVfQwJT
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.
This branch includes the merge of PR #228's branch (
harden-staging-sweep) to resolve overlapping conflicts during development; its diff will shrink to just this PR's own changes once #228 lands, and this PR should be merged after it.A review of the built streaming-ingest tier (
docs/streaming-ingest-design.md) found that one anonymousPOST /api/sessionbought a capability to force up to 10,000 trials × 64 KiB = 640 MB into RTDB for a single session id, that the sweep'sassembleSessionread a whole session into memory before its byte cap applied (and compared that cap against UTF-16.lengthrather than real bytes), and that nothing bounded how many sessions one experiment id — public by construction — could have open at once. This PR closes each of those, plus adds an operational kill switch.Rules caps.
database.rules.json's$seqpattern is now 1-3 digits (1,000 trials) and the per-trial.lengthcap is 16384, both hand-copies ofMAX_TRIALS_PER_SESSION/MAX_TRIAL_BYTESin a shared constant module,functions/src/staging-assembly.ts, whichapi-session-start.tsalready reads to answer the client. A new__tests__/rules-constants.test.jsparses the rules file and asserts its literals still match those constants, so the rules, the endpoint's response, and the docs can't silently drift apart. The rules' own comments now say plainly that RTDB's.lengthcounts UTF-16 code units, not bytes — a trial full of multibyte content can cost up to ~3x as many real bytes for the same.length— and thatMAX_ASSEMBLED_BYTES(measured withBuffer.byteLength) is the byte-accurate backstop.lastFlushAtwasnewData.isNumber(), letting a client write any timestamp, including one far in the future — which madedisconnectedSince()treat every later disconnect stamp as already answered, so such a session could never be swept as abandoned. It now requiresnewData.val() == now, like the other server-stamped timestamps in the tree.Bounded assembly read.
assembleSessionno longer does one.get()of a session's whole trials node. It pages throughorderByKey().startAfter(lastKey)via a new pure accumulator,assembleTrialsPaged, which stops asking for another page the moment the accumulated byte size would crossMAX_ASSEMBLED_BYTES— so an over-cap session is truncated without ever being pulled into memory in full. BothassembleTrialsandassembleTrialsPagednow cost each trial withBuffer.byteLengthinstead of.length.Per-experiment concurrency cap.
openSessionCounts/{experimentId}is a transactional counter, incremented inopenSession()and decremented indiscardSession(), capping an experiment atMAX_OPEN_SESSIONS_PER_EXPERIMENT(500) concurrently open sessions — generously above any lecture-hall or online-panel study DataPipe hosts today. It self-heals negative drift in the transaction itself (a stored value below zero is treated as zero) and the sweep runsreconcileOpenSessionCountseach pass, scoped to that run's candidate experiments, to correct positive drift (a missed decrement). Deliberately not a per-IP counter:pages/docs/privacy.jsstates DataPipe's own code never reads or stores a participant's IP address, and this cap counts sessions per experiment — the same unitmaxSessionsalready limits — carrying no information about who is opening them. A refusal returns the existing 503SESSION_START_ERRORshape, which the plugin already falls back from.Kill switch.
STREAMING_ENABLED=falsemakesPOST /api/sessionreturn that same 503 shape without touching Firestore or RTDB, checked before anything else in the handler. Default (unset) is enabled, so no existing deployment's behavior changes. Documented as a commented example infunctions/.env.datapipe-test.Docs.
pages/docs/api.js's example response and refusal-cause text updated to match (16384/1000, and the new refusal reasons).docs/streaming-ingest-design.mdgets a new "Hardening pass" section covering all of the above.Tests.
__tests__/database-rules.test.js: updated caps, newlastFlushAtstale/future/server-time cases, newopenSessionCountsread/write denial cases.functions/src/__tests__/staging-assembly.test.js:assembleTrialsPagedpaging/truncation/ordering cases (including one asserting the fetcher stops being called once the byte cap is crossed) andstreamingEnabledcases.functions/src/__tests__/rules-constants.test.js(new): the JSON-literal-to-constant pin described above.functions/src/__tests__/staging-emulator.test.js: the per-experiment cap (refusal at the cap, admission once a slot frees up via direct discard and via the sweep), the kill switch's enabled-by-default behavior, and a >1-page real RTDB assembly-paging recovery.Also added, in response to two rounds of emulator-run feedback:
discardSession()directly rather than routing through a full/api/datacompletion, whose pass/fail depended on the mocked provider round trip rather than the slot-release mechanism under test. "lets a new session in once an abandoned one is swept" seededopenSessionCountswith no matchingopenSessionsentries;reconcileOpenSessionCountscorrectly recomputes an experiment's counter from the real entries underopenSessionson every sweep run (the counter has no legitimate reason to differ from what is actually open), so it rightly overwrote the unsupported seeded value — the fixture was wrong, not the reconcile. A newseedOpenSessions()helper seeds matching real entries, parked a year out onexpiresAtso they never crowd a same-run real session out of the sweep's oldest-30 candidate window./api/datacompletion reachesdiscardSession. Building it surfaced the actual root cause of the original 500-instead-of-499 reading: this file's sharedstaging-testuserowner fixture setsconnectedAccounts.gdrive = { accessToken, refreshToken }, butproviders/gdrive.ts'sresolveToken()reads.encryptedTokenand.tokenExpiresAt— neither of which that fixture sets. Every "completion" attempt against it therefore failed at token resolution, before ever reachingdiscardStaging— the "DataPipe itself fails" branch, which by design leaves the staged copy and its counter slot in place for the sweep to recover. That was correct, intentional behavior, not a bug; the test believed it was exercising a real completion when it was exercising a token failure. The new test uses its own owner with a token shaperesolveToken()can actually resolve, so the request clears every gate and reaches the provider; it gets a 202 rather than a literal 201 (this file has no mock Google Drive server on the fixedGDRIVE_API_BASEport thatgdrive-emulator.test.jsowns for the whole run), but that queued branch callsdiscardStagingon the way to responding exactly as a real 201 would, and the test asserts the counter is released afterward.cd functions && npm run buildandnpm run lint(repo root) are both clean.Test plan
staging-assembly,rules-constants,concurrency-limit,live-sessions,staging-session-id— all passingdatabase-rules,session-id-validation-emulator,staging-emulator— 95 tests, all passingcd functions && npm run buildcleannpm run lint(repo root) clean🤖 Generated with Claude Code
https://claude.ai/code/session_01Q8xf16ov88M3KojPVfQwJT