Skip to content

Post-merge fixes from the #150/#151/#152 reviews#153

Merged
htsukamoto5 merged 14 commits into
testfrom
fix/metadata-followups
Jul 7, 2026
Merged

Post-merge fixes from the #150/#151/#152 reviews#153
htsukamoto5 merged 14 commits into
testfrom
fix/metadata-followups

Conversation

@htsukamoto5

Copy link
Copy Markdown
Member

Summary

Follow-up fixes and hardening from the code review of #152 and #150 (plus two small #151 leftovers), landed as one commit per item so any can be reverted independently. Sequenced correctness -> queue semantics -> perf -> cleanup -> frontend.

  • Crash recovery layout-aware (3f4621a): recovered submissions for metadata-active experiments now land at data/raw/, and the queue dedup key matches api-data.ts's, eliminating a double-upload window. Shared via a new uploadPathFor helper.
  • Stop widened metadata failures from destroying data (f7dd079): D2 decision - keep the 400 but stop deleting the pending-data copy, so scheduled-pending-recovery can salvage it. Plus two hardening checks (empty variableMeasured, non-array JSON payload) that previously threw confusing errors.
  • Collision-proof flattened filenames (79016a6): D1 decision - encode researcher subfolder prefixes into the flattened filename (condition-A/data.json -> condition-A-data.json) instead of discarding them, so two submissions with the same leaf name in different subfolders no longer collide at data/raw/<leaf>.
  • Stop the create-only queue from dropping dataset_description updates (1ade20f): the uploadQueue only supports create-PUT, which is guaranteed to 409 against an existing dataset_description.json - stopped queueing dead update entries, and made pending create-queue entries refresh their payload instead of silently dropping newer content.
  • Parallelize derived uploads, resolve data/ once (73bda2d): Promise.allSettled over derived file uploads instead of serial, with the data/ folder resolved once up front instead of per-file.
  • Parse each payload exactly once (76b6804): eliminates the double-parse of both JSON and CSV payloads in produceMetadata.
  • Replace the sentinel-abort transaction with a pre-read (7b4d00a, 56f281c): removes the reference-identity sentinel/double-transaction-run pattern in metadata-block.ts in favor of a non-transactional pre-read.
  • FAQ deep-link scroll robustness (a756618): stops relying on Chakra's private data-controls attribute format and a fixed timer; adds a DOM node we own and a hashchange listener.
  • Test fix (891312a): corrects a recovery test's expected path to match the D1 encoding behavior.

Two decisions made explicitly during this work (see commit messages for detail): D1 = encode subfolder into the flattened name; D2 = land the smaller "keep the 400, stop deleting pending copy" change now, defer the larger "accept the data anyway" behavior change to a product decision.

Test plan

  • tsc build passes for functions/
  • npm run build passes at repo root (frontend)
  • Full emulator suite (npm run test-ci, Firestore + Storage + Functions emulators): 18/18 suites, 120/120 tests passing
  • Emulator run itself caught two real issues that were fixed as follow-up commits: a regression where metadataMessage went empty on the OSF-download failure path, and a stale test expectation predating the D1 encoding change
  • /code-review on the branch diff (recommended before merge)

htsukamoto5 and others added 14 commits July 6, 2026 16:05
Recovered submissions for metadata-active experiments were re-enqueued
under their original filename instead of data/raw/<name>, producing no
derived files and never merging into dataset_description.json. Worse,
the queue doc was keyed off the original filename while api-data.ts
keys off the transformed one, so a crash between queueUpload and
cleanupPending could upload the same file twice under two names.

Extract the metadataActive ? rawDataPath(filename) : filename decision
into uploadPathFor() and use it in both api-data.ts and
scheduled-pending-recovery.ts's promoteToQueue, for both the queued
filename and the dedup key, eliminating the double-upload window as a
side effect. Metadata/derived files are not regenerated for recovered
sessions (documented as a known limitation): recovery has no metadata
pipeline and the raw file is the source of truth.
Two cheap hardening fixes, plus the D2 decision (option b): keep
returning 400 on a metadata-block failure, but stop deleting the
pending-data copy so scheduled-pending-recovery can salvage it later
instead of losing the submission outright. Graceful-degrade (accepting
the data anyway) is a product decision left for a follow-up.

- metadata-production.ts: variableMeasured is now length-checked
  (`?.length`) so an empty array throws the intended clean error
  instead of a TypeError on `variableMeasured[0]`.
- metadata-production.ts: parseJsonData's result is checked for
  Array.isArray so a bare JSON object throws a clear "Data must be an
  array of trials" instead of flowing into generate()/mainRows and
  failing deeper with a confusing message.
Two submissions with the same leaf name in different subfolders
collided at data/raw/<leaf> (the second got a 409 -> 400
OSF_FILE_EXISTS rejection): flattenName discarded the subfolder prefix
instead of encoding it.

flattenName now encodes path separators as `-` (condition-A/data.json
-> condition-A-data.json) instead of dropping everything before the
last `/`. The derived main CSV/sidecar stem follows the same encoded
name automatically, so those stay collision-free too. Flat data/raw/
still matches the CLI's layout (the alternative — nesting subfolders
under data/raw/ — was considered and rejected: it would diverge from
the CLI and still need the encoded stem for derived files).
…iption updates

Two related defects, one root cause: the uploadQueue was designed for
immutable per-submission files, but dataset_description.json is mutable.

- metadata-block.ts: when updateFileOSF throws, the catch no longer
  queues a create-PUT — it was guaranteed to 409 against the existing
  file, and the retry worker would mark that dead entry completed
  without ever applying the update. Firestore is the source of truth
  and every submission re-merges and re-mirrors, so the next submission
  repairs OSF instead (matches the code's own existing comment).
- queue-upload.ts: while an entry is "pending", a newer submission with
  fresher content used to return early without ever queueing it, so the
  eventual retry pushed stale metadata. Now a "pending" re-queue
  overwrites the Cloud Storage payload (keeping the Firestore
  doc/status/retry schedule), so the retry uploads the freshest
  content. "processing" entries are left alone since the retry worker
  owns that payload right now.
uploadDerivedFiles previously uploaded N derived files serially, each
independently walking (and possibly racing to create) the OSF data/
folder path — ~2(N+2) sequential OSF round-trips added to the response
path before the participant's 201.

- uploadDerivedFiles now resolves the data/ folder once up front and
  fans out the actual uploads with Promise.allSettled. Per-file 409 and
  queue-on-failure handling was already concurrency-safe; folder-create
  races among concurrent submissions still resolve via subfolder.ts's
  existing 409-re-list branch.
- putFileOSF takes an optional pre-resolved startUrl to upload directly
  into (or walk any remaining segments from), skipping the redundant
  walk for files that share an already-resolved folder.
produceMetadata parsed JSON payloads twice (an isCsv() probe, then
parseJsonData) and CSV payloads twice (once inside generate(), once
via parseCSV for mainRows).

Replace the isCsv probe with parseJsonData itself in a try/catch:
success is the JSON path with the parsed array already in hand; a
throw means CSV. For CSV, parseCSV runs once up front and its rows are
passed into generate() as a pre-parsed array (confirmed in
functions/metadata/dist/index.js: generate() short-circuits on
Array.isArray(data) for both formats before any internal parsing) and
reused as mainRows, instead of generate() re-parsing the same text.
Golden metadata-production.test.js output (including byte-verbatim CSV
mainContent) is unchanged.
…-read

blockMetadata threw a module-level Error singleton out of the merge
transaction and compared it by reference identity to detect the
bootstrap case (Firestore empty, OSF populated) needing an OSF
download before merging — discarding and re-running the whole
transaction in that case. osfMetadataId is already known before the
transaction even starts, so the download can be decided up front.

Replaced with a non-transactional pre-read of metadata_doc_ref: if
Firestore is empty and osfMetadataId exists, downloadMetadata runs
first; the transaction then runs exactly once. It still re-reads
Firestore inside, so a concurrent populate between the pre-read and
the transaction still resolves correctly via
firestoreMetadata ?? osfMetadata. Deletes NEEDS_OSF_METADATA, the inner
runMergeTransaction closure, and the identity-check try/catch. The
existing metadata-emulator suite's "in OSF but not in firestore" case
already covers the bootstrap path.
…e hash changes

Scroll targeting relied on Chakra v3's private data-controls id format
and a 350ms timer, and only ran on mount, so in-page hash changes (e.g.
clicking another #item-N link without a full navigation) did nothing.

Each FAQItem now wraps its Accordion.Item in a <Box id={value}> — a DOM
node we own that's present regardless of expand/collapse state, so
scrollIntoView-style positioning no longer needs to wait on the
accordion's expand animation or depend on Chakra's internal attribute
naming. The scroll effect also listens for hashchange, not just mount.
Running the emulator suite surfaced a regression from the prior
sentinel-abort-transaction removal: the OSF download (which can throw,
e.g. on a 404) now happened before metadataMessage was ever set, so a
download failure produced an empty metadataMessage instead of
reporting which of the four Firestore/OSF states triggered it — unlike
before, where the transaction always got to set metadataMessage on its
first pass before the sentinel throw.

metadataMessage is now set from the pre-read immediately, before the
download attempt. The transaction still recomputes it from a fresh
read (harmless, and correct if a concurrent write races the pre-read).
Caught by functions/src/__tests__/metadata-emulator.test.js's "in OSF
but not in Firestore" case under the full emulator suite.
The recovery test's expected raw-data path was written before the D1
(encode) commit landed and still expected the old lossy-flatten
behavior (data/raw/data.json). Update it to the encoded name
(data/raw/condition-A-data.json), matching flattenName's actual
current behavior. Caught by running the full emulator suite.
…sion

The "resolve data/ once up front" change moved resolveFolder outside the
per-file try/catch, so an OSF/network failure threw out of
uploadDerivedFiles — which api-data awaits un-try/caught after the raw
file already landed and pending was cleaned up. That lost the derived
files (never queued) and returned 500 instead of 201, breaking the
best-effort "never fails the submission" contract.

Wrap the up-front resolveFolder in try/catch and fall back to an
undefined folder link, so each under-data/ file re-walks the path inside
its own per-file catch (which queues on failure). Add an emulator test
that drives an unreachable OSF and asserts every file is queued.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…letion

The pending-refresh path read status once, then saved to Cloud Storage
non-atomically. If the retry worker finished the doc (-> completed/failed,
which deletes the storage object) in that window, the fresh payload was
written to an orphaned path and silently lost.

Re-read after the save: if the doc is still pending/processing we're done;
otherwise fall through to a clean full re-queue so the fresh data isn't
dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ssage

The parse-once path signalled "valid JSON but not a trial array" by
throwing an Error and re-matching its message text, which is fragile if
the vendored library ever throws that same string. Use a private
NotATrialArrayError class instead; external behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
derivedFiles can now only exist on a success result, so the 400 path in
api-data can send the failure response verbatim without risking a leak —
the guarantee is compiler-enforced rather than resting on a defensive strip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@htsukamoto5 htsukamoto5 merged commit 5349776 into test Jul 7, 2026
1 check passed
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