feat(storage): real BlobStore chunked upload + deferred persistence (S06 Step 8)
What
Extends the real PostgreSQL BlobStore/Session with chunked upload and deferred persistence, per S06 Step 8 of docs/plans/2026-05-15-storage-layer.md. Second real-implementation MR in Stage 2 Track A; builds on Step 7 (!510 (merged)) and re-runs the conformance suite extended by the in-memory stub's chunked-upload MR (Step 3).
PgBlobStore.NewSession— creates anupload_sessionsrow (size_bytes=0,hash_state=NULL, UUIDv4upload_id) and returns a realSession.Session.Write/Session.ReadFrom— stream bytes to the staging object via the driver and feed the SHA-256 hasher in lockstep (ReadFromviaio.TeeReader, so the hasher sees exactly the bytes theFileWriterconsumes, including the partial prefix before a reader error). Zero DB round-trips.Session.Close— flushessize_bytes+ serializedhash_statetoupload_sessionsin one UPDATE, advancingupdated_at, via asize_bytescompare-and-swap that returnsErrSessionConflicton a zero-rowcount update. The flush precedes the UPDATE so the DB never records an offset the backend has not durably received (cross-cutting invariant #1); once the staging flush has succeeded theFileWriteris terminal, so a flush failure (G1a), a CAS conflict, or a transient UPDATE error all terminate the session rather than leaving it half-usable.Commit/Cancel/ResumeSession/GetSessionStatusremainErrUnsupportedMethodplaceholders — Step 9. Thedirtypoison-pill on the CAS-loser path is likewise Step 9; Step 8 surfaces the conflict only.
Resolved spec ambiguities
- Deferred Persistence AC-1 ("Per-Write DB hits = 0; per-Close DB hits = 1") is asserted via observable
upload_sessionsrow-state (unchanged acrossWrite, advanced exactly once onClose), not a literal query counter — the labkit client exposes no injectable pgxQueryTraceranddatabase_queries_totalis deferred (S03 follow-up). An UPDATE necessarily mutates the row, so row-state is a faithful and mechanically feasible proxy. - hash_state round-trip "through Close/ResumeSession" —
ResumeSessionis Step 9, so the round-trip is asserted at the marshalling boundary Step 8 owns:Closeserializes the hasher intohash_state; the persisted bytes deserialize into a fresh SHA-256 hasher whose finalized digest equals the digest over the exact bytes written. No dependency on the Step 9 method.
Deferred to later steps (out of Step 8 scope)
| # | Criterion | Lands in |
|---|---|---|
| Observability AC (Close) | upload_session_hash_state_size_bytes observation per Close |
Metrics framework (S03 follow-up) |
| Deferred Persistence AC-2 | Crash between Write and Close; ResumeSession restore + offset-divergence | Step 9 |
| Session Lifecycle AC-4+ | ResumeSession / GetSessionStatus / Commit / Cancel | Step 9 |
Database Review Evidence
Note
The two statements this MR introduces live in internal/storage/pg_session.go
(the S06 "Upper-Level Blob Storage Service" owns upload_sessions), which is
outside /db-review-prep's internal/datastore/ scope — so this evidence was
collected manually following the same methodology. Tracked for a skill
follow-up to cover internal/storage/.
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL
17 container (matching GL_PG_CURR_VERSION in
.gitlab-ci-other-versions.yml), with the S06 migrations applied and seed data
rolled back per run. upload_sessions was seeded with 20,001 in-flight rows
in a single namespace (so they hash to one partition) to make the index path the
planner's clear choice. Numbers reflect moderate cardinality, not production scale.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Exec time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
storage.pgSession.Close (size_bytes CAS UPDATE) |
Index Scan (under Update) | upload_sessions_pNN_namespace_id_upload_id_idx |
1 / 1 | 8.31 | 0.303 ms | 3 / 0 (scan); 23 / 0 (total) | 1 of 64 (pruned) |
storage.PgBlobStore.NewSession (INSERT) |
Insert → Result | n/a (single-row insert) | 1 / 1 | 0.02 | 0.036 ms | 1 / 0 (10 / 0 total) | 1 (tuple-routed) |
storage.pgSession.Close — size_bytes compare-and-swap UPDATE
Summary: Plan matches intent. The namespace_id literal prunes to a single hash partition (upload_sessions_p59 of 64), and the (namespace_id, upload_id) unique index drives a single-row Index Scan; the size_bytes CAS guard applies as a residual Filter on that one row. Planner estimate matches reality (1 / 1) and execution is 0.3 ms at 20k same-partition rows — the index lookup touches 3 buffer pages, not a scan of the partition. No anomalies.
Seed shape: upload_sessions=20001 (all in the target namespace / partition)
Rendered SQL:
UPDATE public.upload_sessions
SET (size_bytes, hash_state, updated_at) = ($1, $2::bytea, NOW())
WHERE ((upload_sessions.namespace_id = $3::uuid) AND (upload_sessions.upload_id = $4::uuid)) AND (upload_sessions.size_bytes = $5);Bound args: [4096, <108-byte hash_state>, <namespace_id uuid>, <upload_id uuid>, 0]
Plan (EXPLAIN (ANALYZE, BUFFERS)):
Update on upload_sessions (cost=0.29..8.31 rows=0 width=0) (actual time=0.104..0.104 rows=0 loops=1)
Update on upload_sessions_p59 upload_sessions_1
Buffers: shared hit=23
-> Index Scan using upload_sessions_p59_namespace_id_upload_id_idx on upload_sessions_p59 upload_sessions_1 (cost=0.29..8.31 rows=1 width=58) (actual time=0.014..0.014 rows=1 loops=1)
Index Cond: ((namespace_id = '6d0f522b-de2c-4c7e-9d18-b97d43dc84fc'::uuid) AND (upload_id = '046f180c-93b3-4c60-83e8-9307bec891ea'::uuid))
Filter: (size_bytes = '0'::bigint)
Buffers: shared hit=3
Planning:
Buffers: shared hit=351
Planning Time: 1.382 ms
Execution Time: 0.303 msTimings: planning 1.382 ms, execution 0.303 ms, total 1.685 ms.
storage.PgBlobStore.NewSession — upload_sessions INSERT
Summary: Single-row insert into the hash-partitioned parent, tuple-routed to one partition; size_bytes, created_at, updated_at, dirty take schema defaults and hash_state stays NULL. No scan, 0.036 ms. No anomalies.
Seed shape: upload_sessions=20001
Rendered SQL:
INSERT INTO public.upload_sessions (namespace_id, repository_id, upload_id, expires_at)
VALUES ($1::uuid, $2, $3::uuid, $4::timestamp with time zone);Bound args: [<namespace_id uuid>, 7, <upload_id uuid>, <expires_at = now()+24h>]
Plan (EXPLAIN (ANALYZE, BUFFERS)):
Insert on upload_sessions (cost=0.00..0.02 rows=0 width=0) (actual time=0.027..0.027 rows=0 loops=1)
Buffers: shared hit=10
-> Result (cost=0.00..0.02 rows=1 width=113) (actual time=0.004..0.004 rows=1 loops=1)
Buffers: shared hit=1
Planning Time: 0.011 ms
Execution Time: 0.036 msTimings: planning 0.011 ms, execution 0.036 ms, total 0.047 ms.
Query notes:
- No anomalies. The
CloseUPDATE prunes to one partition and uses the(namespace_id, upload_id)unique index (single-row lookup, CAS guard as a residual filter); theNewSessionINSERT is a trivial single-row tuple-routed insert. - Benign observation: the UPDATE's planning time (1.38 ms) exceeds its execution time (0.30 ms), with 351 planning buffer hits — the expected catalog cost of planning against a 64-partition table. Partition pruning keeps execution to one partition, and in production the prepared-statement plan cache amortizes planning across a session's chunk-append requests.
Checklist
-
go build ./...clean -
go test -tags=integration ./internal/storage/...green (conformance + mechanism; verified under-race) -
golangci-lint0 issues on changed packages - full
pre-commitchain green on the implementation and simplification commits -
/validate-step→/review-branch→/validate-stepAI pre-review (pending — kept in Draft) - Database review evidence for the
CloseUPDATE /NewSessionINSERT (collected manually — see Database Review Evidence below)
Related to #157 (closed)