Skip to content

Harden the streaming-ingest tier's bounds and add a kill switch - #229

Merged
jodeleeuw merged 7 commits into
testfrom
harden-streaming-bounds
Sep 14, 2026
Merged

jodeleeuw merged 7 commits into
testfrom
harden-streaming-bounds

Conversation

@jodeleeuw

Copy link
Copy Markdown
Member

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 anonymous POST /api/session bought a capability to force up to 10,000 trials × 64 KiB = 640 MB into RTDB for a single session id, that the sweep's assembleSession read a whole session into memory before its byte 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. This PR closes each of those, plus adds an operational kill switch.

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 a shared constant module, functions/src/staging-assembly.ts, which api-session-start.ts already reads to answer the client. A new __tests__/rules-constants.test.js parses 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 .length counts 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 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. It 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 pages through orderByKey().startAfter(lastKey) via a new pure accumulator, assembleTrialsPaged, 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} is a transactional counter, incremented in openSession() and decremented in discardSession(), capping an experiment at MAX_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 runs reconcileOpenSessionCounts each pass, scoped to that run's candidate experiments, to correct positive drift (a missed decrement). Deliberately not a per-IP counter: pages/docs/privacy.js states DataPipe's own code never reads or stores a participant's IP address, and this cap counts sessions per experiment — the same unit maxSessions already limits — carrying no information about who is opening them. 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, checked before anything else in the handler. Default (unset) is enabled, so no existing deployment's behavior changes. Documented as a commented example in functions/.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.md gets a new "Hardening pass" section covering all of the above.

Tests. __tests__/database-rules.test.js: updated caps, new lastFlushAt stale/future/server-time cases, new openSessionCounts read/write denial cases. functions/src/__tests__/staging-assembly.test.js: assembleTrialsPaged paging/truncation/ordering cases (including one asserting the fetcher stops being called once the byte cap is crossed) and streamingEnabled cases. 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:

  • The first two concurrency-cap tests were rewritten after a real emulator run: "lets a new session in once a prior one completes" now calls discardSession() directly rather than routing through a full /api/data completion, 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" seeded openSessionCounts with no matching openSessions entries; reconcileOpenSessionCounts correctly recomputes an experiment's counter from the real entries under openSessions on 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 new seedOpenSessions() helper seeds matching real entries, parked a year out on expiresAt so they never crowd a same-run real session out of the sweep's oldest-30 candidate window.
  • A new "releases the concurrency-cap slot on a genuine, non-refused completion" test closes the resulting gap: nothing end-to-end proved a real /api/data completion reaches discardSession. Building it surfaced the actual root cause of the original 500-instead-of-499 reading: this 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 sets. Every "completion" attempt against it therefore failed 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. 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 shape resolveToken() 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 fixed GDRIVE_API_BASE port that gdrive-emulator.test.js owns for the whole run), but that queued branch calls discardStaging on the way to responding exactly as a real 201 would, and the test asserts the counter is released afterward.

cd functions && npm run build and npm run lint (repo root) are both clean.

Test plan

  • Pure tests (repo root): staging-assembly, rules-constants, concurrency-limit, live-sessions, staging-session-id — all passing
  • Emulator suites: database-rules, session-id-validation-emulator, staging-emulator — 95 tests, all passing
  • cd functions && npm run build clean
  • npm run lint (repo root) clean

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q8xf16ov88M3KojPVfQwJT

jodeleeuw and others added 7 commits September 13, 2026 17:13
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
@jodeleeuw
jodeleeuw merged commit 85fb0e1 into test Sep 14, 2026
1 check passed
@jodeleeuw
jodeleeuw deleted the harden-streaming-bounds branch September 14, 2026 14:49
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.

1 participant