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.Digest programming-error case (distinct from ErrDigestMismatch).
  • ErrSessionTerminated — new sentinel for post-terminal Session method 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

What's in this MR

Stub (internal/storage/stub/session.go)

  • Store.ResumeSession — looks up the session row via the new lookupActiveSession helper, restores the SHA-256 hasher via encoding.BinaryUnmarshaler, and returns a fresh *stubSession against the resumed state. Returns ErrSessionNotFound on 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 (matching WHERE 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 the verifyCommitGate helper (empty-pointer rejection → digest verification → zero-length safety assertion) followed by the simulated storage move + DB transaction. slices.Clip trims the staging buffer's over-allocated capacity when promoting to the committed map.
  • stubSession.Cancel — calls terminateAndCleanup to delete staging + the session row.
  • terminated flag — set by Commit (before the move, so panics still terminate the in-memory session) and Cancel; checked on every Session method except the pure accessors UploadID/SizeBytes. Subsequent calls return ErrSessionTerminated.

Error sentinels (internal/storage/errors.go)

  • ErrEmptyExpectedDigest — programming-error sentinel; distinct from ErrDigestMismatch so the conformance suite can detect each via errors.Is.
  • ErrSessionTerminated — terminal-state sentinel for post-Commit / post-Cancel method calls.
  • ErrUnsupportedMethod docstring 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, ReadFrom equivalence 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, ErrEmptyExpectedDigest with errors.Is distinctness, zero-length happy path, WithBlobMetadata atomicity, 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 ErrSessionTerminated after every terminal Commit outcome and after Cancel.
  • CtxCanceled: pre-cancelled ctx surfaces as context.Canceled from 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, sizeBytes reset to 0) and asserts the defensive ErrZeroLengthViolation cleanup runs, with the errors.Is distinctness check vs ErrDigestMismatch.
  • TestStub_ExpiredSessionReportsNotFound — drives WithClock + WithSessionTTL to exercise the !expiresAt.After(now) branch in lookupActiveSession. Step 9's real impl will cover the equivalent contract through an integration test that inserts a row with a past expires_at directly 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), ErrSessionTerminated named 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)

  • emptyContentDigest converted to a compile-time const.
  • 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.Clip on 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 off internal/storage/stub/, tracked in the roadmap backlog.
  • Steps 7–9 (real PostgreSQL BlobStore/Session) implement the same conformance contract this MR pins.

Merge request reports

Loading
Loading