Loading
feat(storage): in-memory stub - session lifecycle (S06 Step 4)
Summary
Implements S06 Step 4: completes the in-memory storage.BlobStore stub with the upload-session lifecycle — ResumeSession, GetSessionStatus, Session.Commit, and Session.Cancel. After this MR the stub satisfies the full storage.BlobStore + storage.Session contract; the parameterized conformance suite at internal/storage/testsuites is the executable contract Steps 7–9 must clear when the real PostgreSQL-backed implementation lands.
Also amends the S06 spec to formalise three naming decisions surfaced during the implementation:
ErrEmptyExpectedDigest— new sentinel for the non-nil-empty-digest.Digestprogramming-error case (distinct fromErrDigestMismatch).ErrSessionTerminated— new sentinel for post-terminalSessionmethod calls.- Zero-length safety assertion — clarified as defensive and unreachable through the public API; conformance covers the happy path, implementations own per-impl tests for the cleanup path.
References
- Spec: docs/specs/S06-storage-layer.md
- Plan: docs/plans/2026-05-15-storage-layer.md, Step 4
- Prior steps: !241 (merged) (Step 1), !255 (merged) (Step 2), Step 3 (pending merge)
What's in this MR
Stub (internal/storage/stub/session.go)
Store.ResumeSession— looks up the session row via the newlookupActiveSessionhelper, restores the SHA-256 hasher viaencoding.BinaryUnmarshaler, and returns a fresh*stubSessionagainst the resumed state. ReturnsErrSessionNotFoundon missing / expired / repository-mismatch rows.Store.GetSessionStatus— read-only(upload_id, size_bytes)lookup with the same session-to-URL binding semantics.lookupActiveSession— concentrates the session-to-URL binding (repository_id mismatch ⇒ErrSessionNotFound, indistinguishable from missing) and the strict->-now expiry check (matchingWHERE expires_at > NOW()PostgreSQL semantics). Called by both Resume and Status so the two paths cannot drift.stubSession.Commit— runs the 8-step Commit Protocol via theverifyCommitGatehelper (empty-pointer rejection → digest verification → zero-length safety assertion) followed by the simulated storage move + DB transaction.slices.Cliptrims the staging buffer's over-allocated capacity when promoting to the committed map.stubSession.Cancel— callsterminateAndCleanupto delete staging + the session row.terminatedflag — set by Commit (before the move, so panics still terminate the in-memory session) and Cancel; checked on every Session method except the pure accessorsUploadID/SizeBytes. Subsequent calls returnErrSessionTerminated.
Error sentinels (internal/storage/errors.go)
ErrEmptyExpectedDigest— programming-error sentinel; distinct fromErrDigestMismatchso the conformance suite can detect each viaerrors.Is.ErrSessionTerminated— terminal-state sentinel for post-Commit / post-Cancel method calls.ErrUnsupportedMethoddocstring no longer references the session-group placeholder (Step 4 removes the placeholder).
Conformance suite (internal/storage/testsuites/session_lifecycle.go)
- New
RunSessionLifecycle(t, factory)entry: 25 conformance cases across 6 subtrees. - ResumeSession: hash-state round-trip across
Close → Resume → Write,ReadFromequivalence preserved across the resume boundary, cross-repo and cross-namespace lookups indistinguishable from missing. - GetSessionStatus: persisted-state read, pre-Close zero-offset visibility, cross-repo lookup.
- Commit: matching digest, nil-digest (Maven path),
ErrDigestMismatch,ErrEmptyExpectedDigestwitherrors.Isdistinctness, zero-length happy path,WithBlobMetadataatomicity, dedup first-write-wins, session-row delete after success. - Cancel: session-row delete, blob never finalized.
- Terminated: subsequent Write/ReadFrom/Close/Commit/Cancel all return
ErrSessionTerminatedafter every terminal Commit outcome and after Cancel. - CtxCanceled: pre-cancelled ctx surfaces as
context.Canceledfrom each Step 4 method.
Stub-internal tests (internal/storage/stub/session_internal_test.go)
Cases that aren't reachable through the public BlobStore API:
TestStubSession_CommitZeroLengthViolationCleanup— constructs the divergent state (hasher advanced,sizeBytesreset to 0) and asserts the defensiveErrZeroLengthViolationcleanup runs, with theerrors.Isdistinctness check vsErrDigestMismatch.TestStub_ExpiredSessionReportsNotFound— drivesWithClock+WithSessionTTLto exercise the!expiresAt.After(now)branch inlookupActiveSession. Step 9's real impl will cover the equivalent contract through an integration test that inserts a row with a pastexpires_atdirectly via SQL.TestStubSession_CommitZeroLengthHappyPath— regression guard against assertion-polarity inversion in future refactors.
Spec amendments (docs/specs/S06-storage-layer.md)
- Commit Protocol step 2 promoted to "Empty-pointer rejection" returning the new
ErrEmptyExpectedDigest; remaining steps renumbered. - Zero-length safety assertion explicitly tagged as defensive / unreachable-through-public-API, with the per-impl-test split named.
- Session Lifecycle ACs: dedicated AC for
ErrEmptyExpectedDigest, rewritten AC for the zero-length defensive assertion (happy path in conformance, violation in per-impl),ErrSessionTerminatednamed in the terminal-states list. - Error Cases table: rows for
ErrEmptyExpectedDigest,ErrZeroLengthViolation,ErrSessionTerminated. - Resolutions: three new entries documenting the dedicated-sentinel decisions and the conformance vs per-impl split for the zero-length assertion.
Review-feedback follow-ups (in the fix(storage) commit on this branch)
emptyContentDigestconverted to a compile-timeconst.- Four British "cancelled" comments → American "canceled" (CLAUDE.md convention).
- Stale "Step 7" plan references in 3 docstrings → Step 8 / Step 9 where the referenced behaviour actually lands.
slices.Clipon the staging-buffer promotion to drop over-allocated capacity.- Expired-session and ctx-cancellation tests added (covered above).
Verification
go test -race -count=1 ./internal/storage/...
go vet ./internal/storage/...
golangci-lint run ./internal/storage/...All green. 98 sub-tests under the stub package pass under -race.
Follow-ups
- The stub is transitional per
docs/dev/development-model.md. Removal lands once format consumers migrate offinternal/storage/stub/, tracked in the roadmap backlog. - Steps 7–9 (real PostgreSQL
BlobStore/Session) implement the same conformance contract this MR pins.