feat(storage): in-memory stub - chunked upload (S06 Step 3)
Summary
S06 plan Step 3 — in-memory stub gains the chunked-upload Session surface (NewSession, Session.Write, Session.ReadFrom, Session.Close) and the conformance suite gains RunChunkedUpload. ResumeSession, GetSessionStatus, Session.Commit, and Session.Cancel still return storage.ErrUnsupportedMethod and ship in Step 4.
What changes
internal/storage/stub/session.go—NewSessiongenerates a UUIDv4, inserts anupload_sessionsrow (size_bytes=0,hash_state=nil,expires_at,created_at,updated_at) and seeds a*bytes.Bufferstaging entry.Writeappends to the staging buffer + SHA-256 hasher.ReadFromusesbytes.Buffer.ReadFromfor in-place buffer growth (one fewer byte copy thanio.ReadAll+Write) and hashes only the newly-read tail.Closemarshals the hasher viaencoding.BinaryMarshalerand mirrorssize_bytes+hash_state+updated_atinto the session row.binaryHashinterface — bundleshash.Hash,encoding.BinaryMarshaler, andencoding.BinaryUnmarshaler.stubSession.hasheris typedbinaryHashso the marshal/unmarshal pair is part of the method set; the single-formsha256.New().(binaryHash)assertion at construction trips fast on any stdlib regression. Step 4ResumeSessionconsumes the same field viaUnmarshalBinary.internal/storage/stub/stub.go—objectStore.stagingis nowmap[sessionKey]*bytes.Buffer(was[]byte);Write/ReadFrommutate the buffer in place across calls without re-assigning the map entry. Staleunusedlints onsessionKeyare dropped;sessionRowlints updated to reference Step 4 consumption.internal/storage/testsuites/chunked_upload.go(new) —RunChunkedUpload(t, factory)with four groups:- NewSession: fresh session has
SizeBytes()==0+ non-nilUploadID(); two sessions have distinct upload IDs. - Write: single write returns full byte count; multiple writes accumulate (Step 3 acceptance criterion); zero-byte write is a no-op.
- ReadFrom: streams reader bytes; matches an equivalent
Writesequence on the same payload; empty reader is a no-op. - Close: preserves
SizeBytes()accuracy (Step 3 acceptance); does not finalize the blob —BlobInfomisses andOpenBloberrors (Step 3 acceptance); succeeds on an empty session. - All assertions are interface-level; Step 8's real PostgreSQL impl will re-run the same suite unchanged.
- NewSession: fresh session has
internal/storage/stub/stub_test.go—TestStub_ConformanceSuitenow runs bothRunSimpleOpsandRunChunkedUploadas parallel subtests; the placeholder `ErrUnsupportedMethod` test now covers the four remaining session-lifecycle methods (Resume/GetStatus/Commit/Cancel) and is deleted in Step 4.
Refs
- Spec: docs/specs/S06-storage-layer.md (sections: Session, Deferred DB Writes, Acceptance Criteria → Session Lifecycle)
- Plan: docs/plans/2026-05-15-storage-layer.md (Step 3)
Test plan
-
go build ./... -
go test -race ./internal/storage/... -
mise exec -- golangci-lint run ./internal/storage/...— 0 issues - Conformance suite passes against
stub.Newfactory (visible in test output)
MR size
Reviewable LOC: ~915 (~736 from the initial commit + a smaller follow-up addressing review feedback). This exceeds the project's 500-LOC ceiling from docs/dev/development-model.md.
The size is split roughly two-thirds tests / one-third implementation:
- ~470 LOC is the new
internal/storage/testsuites/chunked_upload.go— the parameterized conformance suite for the Session surface. The plan's Step 3 acceptance criterion requires the conformance cases to land with the implementation so that Step 8's real PostgreSQL impl re-runs the same contract. - ~180 LOC is the stub implementation (
session.goWrite/ReadFrom/Close,binaryHashinterface, the*bytes.Bufferstaging refactor instub.go). - The remaining LOC is the test wiring (
stub_test.go) and a small dispatcher-line intestsuites.go.
Splitting the conformance suite into a follow-up would leave the stub unverified at merge time and would invert the plan's "freeze the contract before Step 8" property. Reviewer ack on the size is requested in lieu of a split.
Review feedback (follow-up commit)
fix(storage): address review feedback on Step 3 stub resolves W1 (Session.ReadFrom partial-bytes / hasher-staging divergence), W2 (CtxCanceled subtests for the new Session surface), W3 (failing-reader subtest), and O1 (drop the post-lock ctx.Err() check in NewSession for consistency with the other methods).