- wrap writePixels in state.storage.transactionSync so a partial multi-chunk failure doesn't leave the canvas half-written or out of sync with the WS broadcast - size new chunk buffer against chunkSize(chunkId) instead of the persisted blob's length so writes after a canvas-grow no longer silently drop OOB bytes in the formerly-last short chunk - refund the cooldown row when writePixels throws so transient storage errors stop soft-DOSing the user (and halving image-uploader throughput) - bound readAllChunks by chunk_id < CHUNK_COUNT and trim oversized blobs so orphan rows from a future shrink no longer crash GET /api/canvas - require a positive Content-Length on /api/place (411) and reject above the pre-parse cap (413); previously a missing or zero header bypassed the cap - drop String(err) from the 500 response body - drain the INSERT cursor symmetrically with the UPDATE branch in tryAcquire - assert CHUNK_BYTES <= 2 MB at module load (DO SQLite per-cell BLOB cap) - correct the inverted webSocketClose comment and guard the re-close call - add tests for missing / zero / oversized Content-Length Plan: plans/260510-0232-fix-do-migration-followups/phase-01-do-storage-atomicity.md
7.9 KiB
phase, title, status, priority, effort, dependencies
| phase | title | status | priority | effort | dependencies |
|---|---|---|---|---|---|
| 1 | DO storage atomicity & correctness | pending | P1 | 4h |
Phase 1: DO Storage Atomicity & Correctness
Overview
Fix the four correctness bugs in the Durable Object storage path: non-atomic multi-chunk writes, BLOB-grow data loss after canvas resize, cooldown burned on storage failure, and orphan-row crash on /api/canvas. Sanitize the 500 response body. Replace the bypassable content-length cap.
Context Links
- Reports:
plans/reports/code-reviewer-260510-0211-rplace-do-migration.md(C3, H1, H2, H3, H5) - Reports:
plans/reports/debugger-260510-0211-rplace-edge-cases.md(C1, C2, M5)
Key Insights
- "No
awaitbetween statements" ≠ atomic. Eachsql.execauto-commits unless wrapped instate.storage.transactionSync(...). new Uint8Array(typedArray)copies but preserves the source's length, so growing a short BLOB requires explicit re-allocation againstchunkSize(chunkId).- Cooldown UPDATE must roll back if the subsequent write throws — otherwise transient storage flakes silently halve image-uploader throughput.
Requirements
Functional
- Multi-chunk batch writes atomic: all chunks commit, or none do.
- After
CANVAS_WIDTH/CANVAS_HEIGHTgrow + redeploy, new pixels in the formerly-last chunk persist correctly. - Cooldown row reverted when
writePixelsthrows. GET /api/canvasreturns 200 even with orphanchunk_id ≥ CHUNK_COUNTrows present (post-shrink).POST /api/placerejects requests with missing or zerocontent-length(or reads body with hard byte cap).- 500 responses do not echo raw error message.
Non-functional
- No regression in happy-path latency (single-chunk batch).
- Static assertion
CHUNK_BYTES <= 2_000_000near constants to prevent future BLOB-cell overflow.
Architecture
worker.js (edge)
├─ guard: content-length present AND > 0 AND ≤ MAX_BODY_BYTES (else 413)
└─ forwards to DO
CanvasRoom.placePixels (canvas-room.js)
├─ tryAcquire(userId) → reserves cooldown
├─ TRY:
│ state.storage.transactionSync(() => writePixels(sql, edits))
│ broadcast(edits)
└─ CATCH:
sql.exec('DELETE FROM cooldowns WHERE user_id = ?', userId) // rollback
return Response.json({ error: 'storage_failed' }, { status: 500 })
// no message field; full err logged separately
chunk-storage.writePixels rewritten so next is sized to chunkSize(chunkId), with buf.subarray(0, min(buf.length, expected)) copied into it.
chunk-storage.readAllChunks filters WHERE chunk_id < ? bound to CHUNK_COUNT.
Related Code Files
Modify
src/worker.js— content-length validator (require > 0 OR switch to boundedarrayBuffer)src/durable-objects/canvas-room.js— wrap write in transactionSync, rollback cooldown on error, dropmessagefrom 500src/durable-objects/lib/chunk-storage.js— fixwritePixelsBLOB-grow, boundreadAllChunks, replacenew Uint8Array(buf)aliasing comment with.slice()or explicit allocationsrc/lib/constants.js— add static assertion forCHUNK_BYTESsrc/durable-objects/canvas-room.js:119— fix the inverted "pre-2026-04-07" comment (M2 in code-review)
Create — none
Delete — none
Implementation Steps
-
chunk-storage.writePixelsBLOB-grow fix (review C3)- In the per-chunk loop, compute
expected = chunkSize(chunkId). const next = new Uint8Array(expected); next.set(buf.subarray(0, Math.min(buf.length, expected)));- Update inline comment: "size against chunkSize, never trust persisted blob length".
- In the per-chunk loop, compute
-
chunk-storage.writePixelsatomicity (debugger C2)- Wrap the entire
for (const [chunkId, edits] of grouped)block instate.storage.transactionSync(() => { ... }). - Verify
transactionSyncexists at compat date2025-04-01; if not, fall back to asyncstate.storage.transaction(async () => {...})and propagate rejection. - Update the comment at L86-87 to describe the transactional invariant, not "no await".
- Wrap the entire
-
chunk-storage.readAllChunksorphan guard (review H3)- Change SQL to
SELECT chunk_id, bytes FROM canvas_chunks WHERE chunk_id < ?bound toCHUNK_COUNT. - Optional secondary defense: skip rows where
chunkId * CHUNK_BYTES + view.length > out.length.
- Change SQL to
-
canvas-room.placePixelscooldown rollback (review H1, debugger C1)- Add try/catch around the
writePixelscall. - In catch:
sql.exec('DELETE FROM cooldowns WHERE user_id = ?', userId). Rethrow or return 500. - Add a one-line comment:
// refund cooldown so transient storage errors don't soft-DOS user.
- Add try/catch around the
-
500 response sanitization (review H2)
- Drop
message: String(err)from the response JSON. - Keep the
console.errorcall so debugging info stays in logs.
- Drop
-
worker.jscontent-length guard (review H5)- Require
content-lengthheader present AND > 0 AND ≤MAX_BODY_BYTES. Reject with 411 (length required) or 413 (too large). - Alternative: read raw body via
c.req.arrayBuffer(), checkbyteLength, thenJSON.parse(decoder.decode(buf)).
- Require
-
Static
CHUNK_BYTEScap assertion (debugger M5)- At top of
constants.js(or in a small init):if (CHUNK_BYTES > 2_000_000) throw new Error('CHUNK_BYTES exceeds DO SQLite cell limit'); - Document compat-date-versioned cell limit in the comment.
- At top of
-
WS-close comment correction (review M2)
canvas-room.js:119— replace misleading comment with:// Required because compatibility_date 2025-04-01 predates the 2026-04-07 default-close cutoff. Remove if/when wrangler.json bumps past that date.- Add try/catch around the
ws.close(...)call to handle already-closed sockets (debugger M7).
-
Compile + smoke
npm run build— must pass.- Local
wrangler dev, hitPOST /api/placewith a 2-pixel batch spanning 2 chunks; verify success. - Force a write error (e.g. temporarily throw inside
writePixels); verify cooldown row deleted, 500 returned without raw message. - Hit
GET /api/canvasafter manually inserting an orphan row; verify 200.
Todo List
- Implement BLOB-grow fix in
chunk-storage.writePixels - Wrap multi-chunk write in
transactionSync(or async fallback) - Bound
readAllChunksquery bychunk_id < CHUNK_COUNT - Add cooldown rollback in
canvas-room.placePixelscatch path - Drop
messagefrom 500 response - Replace
content-lengthguard with present+nonzero check or bounded body read - Add
CHUNK_BYTES <= 2_000_000static assertion - Fix inverted WS-close comment + add try/catch
npm run buildpasses- Manual smoke: 2-chunk batch success
- Manual smoke: forced write error → cooldown refunded, no message leak
- Manual smoke: orphan row →
/api/canvas200
Success Criteria
- No regressions in existing 94-test suite
transactionSync(or async transaction) confirmed available at compat date 2025-04-01- All bullets in Todo List checked
- PR includes file:line evidence linking each change to the originating finding
Risk Assessment
- Risk:
transactionSyncnot available at our compat date → fix back-compat. Mitigation: Verify viawranglerruntime docs first; fall back tostate.storage.transaction(async () => {...})and ensureplacePixelsis awaited end-to-end. - Risk: Cooldown rollback DELETE race against concurrent insert from same user. Mitigation: Single-DO is single-threaded per request; rollback runs synchronously before response. No lock needed.
- Risk: Bounded body read changes Hono request-handling shape; could break the existing tests.
Mitigation: Keep
c.req.json()if guard is sufficient; only refactor toarrayBuffer()if guard alone leaves a gap.
Security Considerations
- 500 sanitization closes a low-severity info-leak.
- content-length guard removes a DOS-amplification vector against the JSON parser.
- Atomicity fix prevents broadcast/persistence divergence that could be probed for state inference.