Skip to content

Wire DataPipe egress with per-round incremental saves (C&WG) - #16

Open
htsukamoto5 wants to merge 2 commits into
feat/prolific-identifiers-and-codesfrom
feat/datapipe-egress-and-identifiers
Open

Wire DataPipe egress with per-round incremental saves (C&WG)#16
htsukamoto5 wants to merge 2 commits into
feat/prolific-identifiers-and-codesfrom
feat/datapipe-egress-and-identifiers

Conversation

@htsukamoto5

@htsukamoto5 htsukamoto5 commented Jul 31, 2026

Copy link
Copy Markdown
Member

Closes #3.

Adds the save target this file has never had. Until now the only egress was a download button the participant had to choose to click, on a screen that also said "You may close this tab" — so every dropout, lobby timeout, and spectator contributed nothing. Those are precisely the sessions needed to characterise attrition.

Stacked on #14 (feat/prolific-identifiers-and-codes), not main. It builds on the CONFIG block and identifiers that PR adds rather than duplicating them — the save-policy values join that block, each annotated with its DECISIONS.md row. Review #14 first; this diff is only the egress layer and its tests.

C&WG only. Hawkins is untouched on purpose (A2).

The decisions this encodes are suggestions, not settled calls

  • A4 — per-round chunks, nonce-suffixed filenames (SAVE_PER_ROUND)
  • A3 — the abort path flushes unconditionally (FLUSH_ON_ABORT)
  • B4 — assumes limitSessions is off. DataPipe's counter increments per save call, so per-round saving spends ~7 sessions per participant; a limit sized to participants would cap the run about a seventh of the way through and drop every later save silently.

Changing any of these is a one-line edit in CONFIG; nothing below hardcodes an equivalent literal.

The retry classifier is the part worth reviewing

Written against DataPipe's server source (jspsych/datapipe, functions/src/api-data.ts) rather than the plugin's example snippet, because the response semantics decide whether retrying is correct or actively harmful:

Response Meaning What we do
201 Uploaded to OSF Success
202 OSF upload failed, but DataPipe persisted the data and queued its own server-side retry Success. Retrying would duplicate rows
400 OSF_FILE_EXISTS Filename collision. Not queued — the one path where data is genuinely dropped Terminal (except the replay case below)
400 others Session limit, validation, unknown experiment, PROVIDER_NOT_CONNECTED — configuration errors, not transient faults Terminal; retrying burns the redirect budget and still loses the data
429, 5xx, network Genuinely transient Retry with backoff, honouring Retry-After

Classification keys on status class, not error strings. That is what makes it robust to the in-flight provider-migration branch upstream, which moves duplicate detection into a Firestore collision cache and makes OSF one provider among several — the client contract is unchanged, and any new 4xx is terminal by construction.

The subtle one

The nonce is fixed for the lifetime of a save, so all retries within one save post the same filename — that is what makes them idempotent. But if attempt 1 lands at OSF and only its response is lost to a network fault, attempt 2 gets OSF_FILE_EXISTS. Classifying that as terminal requeues the rows and re-sends them under a fresh nonce, duplicating them in the dataset — exactly what the nonce exists to prevent.

A collision from attempt 2 onward now counts as saved. A collision on the first attempt is a genuine clash with another session and stays terminal. This was found by writing the reassembly test, not by reading the code.

Other behaviour

  • Chunks are disjoint and carry row_range, so reassembly is a concatenation and a duplicate chunk is detectable rather than silently merged.
  • Failed chunks return their rows to the queue for a later flush. Duplicates are recoverable in analysis; missing rows are not.
  • flush() races the save against a redirect budget, so no exit path can strand a participant waiting on OSF before they can submit (Prolific plumbing: completion codes and submission redirect #7). The submit button is wired before the flush resolves.
  • pagehide + keepalive catches tab-close, back-navigation, and connection loss (A3). keepalive caps bodies at 64KB, which per-round chunks stay well under — another reason A4's chunking is load-bearing rather than merely tidy.
  • Filename keys prefer PROLIFIC_PID, fall back to the adapter participant id locally, and dyad_id falls back to SEED so a filename never contains the literal null.

Wired exits

Per-round, completion, spectator, and pagehide. The two that don't exist yet are one Pipeline.flush(label) call each once their screens land: the partner-dropped abort (#5) and the no-match lobby exit (#6).

Testing

node tests/pipeline.test.mjs34 checks, no dependencies, no browser. The module is extracted from the experiment file and run against a fake DataPipe that distinguishes attempts from what actually lands at OSF, since a failed POST leaves no file behind.

Covers chunking and disjointness, every response class, requeue-on-failure, concurrent saves not double-sending, filename uniqueness and fallbacks, the redirect budget, idempotent replay, inert behaviour when unconfigured, and end-to-end reassembly of a partial lossy session.

Not browser-tested end to end — that needs a live experiment ID.

For the analysis side

Chunks reassemble by concatenating trials across a dyad's files; the two participants join on dyad_id.

Each chunk carries row_ranges: a list of inclusive [start, end] pairs, not a single pair. A chunk may carry rows reclaimed from an earlier failed save alongside its new ones, and those are not contiguous with each other. The invariant to check is that the union of all row_ranges for a participant covers 0..N exactly once — no gaps, no overlaps. chunk_seq may legitimately gap, since a save that fails outright still consumes a sequence number, so it is not a completeness check.

Before this does anything

CONFIG.DATAPIPE_EXPERIMENT_ID is empty, so saving is inert and warns to the console. Deliberate — safer than a placeholder that uploads to an unintended project. It needs #15, which is now the blocking step for seeing any of this work for real.

Known follow-ups

  • dyad_id and SEED both read ?mp_session=. Firebase swap silently breaks SEED — every dyad would share one trial order #9 re-derives that from the Firebase room id; it should follow automatically, but verify it does — a dyad_id that differs between partners silently unjoins every dyad in the dataset.
  • Chat transcripts are uploaded verbatim. D3 and D6 in DECISIONS.md are open IRB questions on retention and researcher review, and they now have a concrete destination to be answered about.

🤖 Generated with Claude Code


Review follow-ups (5ad35d0)

The blocking bug was real and is fixed. savedThrough -= rows.length assumed saves fail in LIFO order. Reproduced exactly as described: rows 0-1 lost permanently, 3-4 duplicated, ranges overlapping. The compounding problem was the silence — those overlapping ranges break the very contiguity invariant this PR told the analysis side to check, so the detection mechanism stops holding precisely when it is needed.

savedThrough is now a high-water mark that only advances. Failed saves push their [start, end] ranges onto a pending list, and each save claims everything pending plus everything new. That is why chunks now emit row_ranges (a list) rather than row_range (a pair) — a chunk may carry reclaimed rows that are not contiguous with its new ones. The analysis section above is updated accordingly.

The regression test is included. It is exactly the case the old concurrency test missed, because both of its saves succeeded.

  • PID removed from filenames, payload only. Agreed on the reasoning: an OSF listing is browsable without opening a file, which is a broader exposure than the same value inside a row, and D3/D6 are open with IRB. Reconciliation reads the payload and is unaffected.
  • classify() now accepts any 2xx. Agreed — treating an unexpected 200 as failure would requeue rows that landed and duplicate them.
  • The completion screen no longer promises a retry that may not happen, distinguishing a redirect-budget timeout from a genuinely queued 202.
  • configured() uses truthiness; the test's extraction regex now fails with a readable message.
  • dyadKey() is documented as defensive-only, pointing at DYAD_ID's own fallback added on the base branch in c2c69c2.

Tests: 39, up from 34.

@htsukamoto5
htsukamoto5 marked this pull request as draft July 31, 2026 15:25
@htsukamoto5

Copy link
Copy Markdown
Member Author

Marking as draft: this overlaps PR #14, which already closes #4 with an equivalent CONFIG block, identifiers, run provenance, and viewport capture. Restructuring so this carries only the #3 egress work, stacked on #14, rather than duplicating it.

@htsukamoto5
htsukamoto5 force-pushed the feat/datapipe-egress-and-identifiers branch from 7879926 to 6fbae83 Compare July 31, 2026 15:54
@htsukamoto5 htsukamoto5 changed the title Data egress to DataPipe/OSF with per-round saves, and dyad/Prolific identifiers Wire DataPipe egress with per-round incremental saves (C&WG) Jul 31, 2026
@htsukamoto5
htsukamoto5 changed the base branch from main to feat/prolific-identifiers-and-codes July 31, 2026 15:54
@htsukamoto5
htsukamoto5 marked this pull request as ready for review July 31, 2026 15:55
Closes #3.

Adds the save target this file has never had. Until now the only egress was a
download button the participant had to choose to click, so every dropout,
lobby timeout, and spectator contributed nothing — precisely the sessions
needed to characterise attrition.

Builds on the CONFIG block and identifiers from the parent branch rather than
duplicating them; the save-policy values join that block with their DECISIONS.md
rows (A3, A4, B4).

The retry classifier is the part worth reviewing. It was written against
DataPipe's server source (jspsych/datapipe, functions/src/api-data.ts) rather
than the plugin's example snippet, because the semantics decide whether retrying
is correct or actively harmful:

  201 uploaded. 202 means the OSF upload FAILED but DataPipe persisted the data
  and queued its own server-side retry — a success for us, and retrying it would
  duplicate rows. 400 OSF_FILE_EXISTS is the one path that is not queued and
  where data is genuinely dropped, hence a nonce in every filename. Other 4xx
  (session limit, validation, unknown experiment) are configuration errors, not
  transient faults; retrying burns the redirect budget and still loses the data.
  Only 429/5xx/network are retried, with backoff honouring Retry-After.

Classification keys on status class rather than error strings, so upstream
adding a new 4xx — the in-flight provider-migration branch adds
PROVIDER_NOT_CONNECTED — is handled correctly with no change here.

Chunks are disjoint and carry row_range, so reassembly is a concatenation and a
duplicate chunk is detectable rather than silently merged. Failed chunks return
their rows to the queue for a later flush: duplicates are recoverable in
analysis, missing rows are not.

The nonce is fixed for the lifetime of a save, so retries within one save reuse
the filename and are idempotent. If attempt 1 lands at OSF and only its response
is lost, attempt 2 gets OSF_FILE_EXISTS; that now counts as saved, because
treating it as failure would requeue the rows and re-send them under a fresh
nonce, duplicating them. A collision on the first attempt is a genuine clash
with another session and stays terminal.

flush() races the save against a redirect budget so no exit can strand a
participant waiting on OSF, and pagehide uses keepalive to catch tab-close and
connection loss. Wired to the exits that exist: per-round, completion, and
spectator. The abort (#5) and no-match (#6) exits are one flush call each once
those screens exist.

Adds tests/pipeline.test.mjs: 34 checks, no dependencies, no browser. Covers
chunking, every response class, requeue-on-failure, concurrent saves, filename
uniqueness and fallbacks, the redirect budget, idempotent replay, inert
behaviour when unconfigured, and end-to-end reassembly of a partial lossy
session.

For analysis: chunk_seq may contain gaps, since a failed save consumes a
sequence number and its rows reappear later. A gap is not missing data —
row_range is the gap-free contiguity check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Blocking bug from review. `savedThrough -= rows.length` subtracts a count from
a shared cursor, which is only correct if the failing save is the most recent
claimant. Saves do not fail in the order they were issued.

Reproduced: save A claims rows 0-2 and fails slowly, save B claims 3-4 and
succeeds, a third save follows.

  row ranges   [[3,4],[2,5]]      overlapping
  rows landed  [3,4,2,3,4,5]
  MISSING      [0,1]              never re-sent
  DUPLICATED   [3,4]

Rows 0 and 1 are gone permanently. Worse, the failure is silent: the
overlapping ranges are exactly what breaks the contiguity invariant that
analysis was told to check, so the detection mechanism stops holding at the
moment it is needed. And it is the ordinary case, not an exotic one — the
per-round save is not awaited and can retry for seconds, so a slow round-6
save overlapping the end-of-run flush is how most sessions end.

savedThrough is now a high-water mark that only advances. Failed saves push
their [start, end] ranges onto `pending`, and each save claims everything
pending plus everything new. Chunks therefore emit `row_ranges` — a LIST of
inclusive ranges — because a chunk may carry rows reclaimed from an earlier
failure that are not contiguous with its new ones.

Also from review:

- The Prolific PID is out of the filename and lives in the payload only. OSF
  file listings are browsable without opening any file, so a PID in a filename
  publishes a directory of participant identifiers — a broader exposure than
  the same value inside a row, with D3/D6 still open with IRB. Reconciliation
  is unaffected; it reads the payload.
- classify() accepts any 2xx rather than 201/202 exactly. Treating an
  unexpected 200 as failure would requeue rows that did land and duplicate
  them — the exact failure this module works hardest to avoid, triggered by
  nothing worse than an upstream tightening.
- The completion screen no longer promises a retry that may not happen. A 202
  is retried by DataPipe server-side; a flush that merely ran out of redirect
  budget may be a dead network, where nothing retries.
- configured() uses a truthiness check, so an undefined key is falsy rather
  than a TypeError.
- The test's script extraction fails with a readable message instead of a
  destructuring throw if the file gains a second <script> block.
- dyadKey() is documented as defensive-only now that DYAD_ID has its own
  fallback, so it is not preserved as load-bearing.

Tests: 39 checks, up from 34. Adds the out-of-order failure regression (the
case the previous concurrency test missed, because both its saves succeeded),
and asserts the PID is absent from filenames but present in payloads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

Data: attach dyad_id and Prolific identifiers to every row Data: no save target exists — wire up DataPipe with incremental saves

1 participant