Repository navigation
fix(service-storage): chunks sent in parallel to one chunked upload each record their part (#22332) - #22348
Conversation
…rt (red on main) Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…and-set, so concurrent chunk PUTs record every part Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…pdate with its matched-row count Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
… boot Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…and-set progress write Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…nditional progress write Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ab4870b39f83791115b20a3cb15308471b476085 && git checkout ab4870b39f83791115b20a3cb15308471b476085
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5ff7cbe364f939a55633a04891650163c9e8884e 006344eb50aad472b817b8fdc56f72392c3569db && git checkout -B drift-repro 5ff7cbe364f939a55633a04891650163c9e8884e && git merge --no-ff 006344eb50aad472b817b8fdc56f72392c3569db
node scripts/docs-audit/affected-docs.mjs --json 5ff7cbe364f939a55633a04891650163c9e8884e
|
Fixes #22332
Clause-②: no
What was wrong
The chunk door (
PUT /storage/upload/chunked/:uploadId/chunk/:chunkIndex) read the session row, merged its part into the parts list in memory (recordChunk) and wrote the whole record back with an unconditional by-id update. Two PUTs to one upload at once both read the same record, and the later write erased the earlier part: both answered200,sys_upload_session.partskept one part,uploaded_chunks/uploaded_sizecounted one chunk, and the completion door refused the upload409naming the lost chunk.The atomic route taken, and why
A compare-and-set on the row's three progress columns, with a bounded re-read-and-merge retry. This is the route the store already supports; it needs no new object, no new field and no
packages/specchange.engine.update('sys_upload_session', progress, { where: { parts, uploaded_chunks, uploaded_size, id }, multi: true, context }). The update-dispatch module (resolveEngineUpdateDispatch,@objectstack/metadata-core) names exactly this shape, an id inwherebeside further keys withmulti: true, as "the compare-and-set spelling". It routes todriver.updateMany, which evaluates the guard in the same statement that writes and answers the matched-row count. driver-sql issues oneUPDATE … WHEREwith the tenant scope applied; driver-memory matches and writes in one synchronous step. Every driver'supdateMany(sql, memory, turso, mongodb) resolves a count.updated_atas a version has millisecond grain, so two writes in one millisecond would still match. A per-part row would be a new object, a schema change, and was not needed.The store side is
updateSessionProgressIfUnchangedinmetadata-store.ts. It is a module export that is not re-exported from the package entry, following theorganizationOutOfWriteReachpattern, soStorageMetadataStore's public face does not grow.The door merges and writes up to
CHUNK_RECORD_ATTEMPTS(16) times. A conditional write loses only to a write that landed between its read and itself, so each lost attempt is another chunk recorded.409 RESOURCE_CONFLICTwitherror.details: { chunkIndex, attempts }. The chunk's bytes are stored but the record does not hold them, and re-sending the chunk replaces its slot. It never answers a silent200.500at once. It does not loop into a409that would tell the uploader to retry something that cannot succeed.Measured: what the store supports (H2)
A probe against the real
ObjectQLoverSqlDriver(better-sqlite3,:memory:) ate36ee5351b:partsstringIS NULL)Measured: the reproduction, before and after
pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2 src/chunk-part-record-concurrency.test.tsBefore, the pins on
mainate36ee5351b(which carries the completion guard): 8 failed and 2 passed; the 2 that passed are the sequential controls.every concurrently sent chunk is in the record: expected [ 1 ] to deeply equal [ +0, 1 ](2 concurrent), andexpected [ +0 ] to deeply equal [ +0, 1, 2, 3, 4, 5, 6, 7 ](8 concurrent).409withmissingChunks: [1].{"success":true,…}(expected 200 to be 409), and the record held[ +0 ]: the competitor's part was erased.After, at
006344eb50: 24 passed (24). Both stores pass the 2 and 8 concurrent pins (every part, every eTag and size,uploaded_chunks,uploaded_size, and the progress door), the parallel completion200with the whole file, the sequential control (the same answers and record, one session write per chunk), and the exhaustion pin (409 RESOURCE_CONFLICTafter exactly 16 reads, with every competitor's part kept). The store-level pins also pass on both stores: the write lands on an unchanged row, misses once any one progress column moved, and misses on a gone row. On the real engine, the call shape is pinned (dispatch verdictmulti, the same{ tenantId, isSystem }context), another organization's row is not reached, a non-count answer is refused loudly, and an unreachable row answers500once.Over HTTP, a real boot (
bootStack, sqlite-wasm):packages/qa/dogfood/test/storage-chunked-parallel-parts.dogfood.test.tspasses 2 of 2 (2 concurrent PUTs, then progress and completion200; 8 concurrent PUTs, then progress). With the siblingstorage-chunked-resume-integrity.dogfood.test.ts, 6 passed (6) at006344eb50.Ablations
Each mutation went through
scripts/ablation-replace.mjs: the anchor hit 1 to 0, the blob changed, and the restore was proven with blob == HEAD and an emptygit diff HEAD. The runner also carried its ownEXIT/INT/TERMrestore over absolute paths. The control run with no mutation passed 24 of 24.500200The dogfood pin reads
@objectstack/service-storagefromdist/, so its ablation included a rebuild.ablation-dist-preflightfound the marker indist/index.jsanddist/index.cjs, and 2 of 2 failed (the progress fell short ofuploadedChunks).Tests and gates (HEAD
006344eb50)@objectstack/service-storage: 46 files and 773 tests passed.typecheckpassed (tsc, the scripts project, andcheck:test-typecheck: OK).--listFilesshows the new test file is in the test-layer program.@objectstack/dogfood:typecheckpassed.tenant-audit-update-delete-half-repairs.test.tsandstorage-routes.metadata-outage.test.ts. The chunk-door pin in the first now expects the conditionalwherewithmulti: true, under the same context.check:tenant-audit-census: the new conditional write is one more engine write call site (236 to 237). The census was regenerated (node scripts/tenant-audit-census.mjs --write), and the page's hand-written prose figures were moved with it. The gate and its self-test pass.eslint.config.mjsglob**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}. ②--format jsonlinted 6 files with 0 errors and 0 warnings. ③ Invariance: the config sets noparserOptions.project(--print-configgives{"ecmaVersion":"latest","sourceType":"module"}), so linting is not type-aware and this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.dispatch-gates --commands, derived at006344eb50) were run after the last commit, with exit codes captured before any pipe.dispatch-gates --ranreads97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN(a derived zero: every family recordedexit 0).pnpm check:error-status-conformancewas run by hand and exited 0:✓ every derivable runtime status is documented, and every documented status is reachable.A few verdict lines:check-engine-double-contract: OK — 983 pinned, 129 in the DEBT ledger, 3 exempt.check-nul-bytes: OK (scanned 10310 text file(s) …; no raw ASCII control bytes).✓ check-tenant-audit-census: OK -- 237 write call sites certified …check-test-source-alias OK — 73 packages with tests scanned …✓ check:dual-build-cjs-loads — 106 published require entry point(s) across 66 package(s) load …. Its first run answeredPREREQUISITE NOT MET(nodist/for 8 packages), which is not a measurement. It passed after a full cache-backedturbo run build.majorbump. (check-changeset-no-major)Acceptance notes
no, as the claim declared. No accept set, export or public signature moves. The one new answer,409after 16 lost record writes, replaces a200that had recorded nothing.data.records.updatedevent (a count) where it publisheddata.record.updated, and its after-hooks go through the bulk per-row path. Nothing in the tree registers an update hook onsys_upload_sessionor subscribes to its record events, and the config-change audit excludes the object.maxUploadBytes. The completion door still refuses any upload whose held bytes differ from its declared size, and the init door bounds that size.Generated by Claude Code