feat(oci): Step 15 part 1 - the blob miss fill path (S16 plan: 15/26)

Why

Step 15 of the S16 container remote plan. Step 11c landed the blob arm's cache-hit half and left the miss path answering an interim 501. This is the first of three MRs that replace it with a real fill: the handler and its suite, with nothing wired to reach it yet.

The three-MR chain

Step 15 ships as three MRs. Merge bottom-up; each targets the one below it.

Part MR LOC What
part 3 !1904 837 Wires the arm into the composition root — the MR that turns it on
part 2 !1903 1,422 The two guards a review found, plus the comment and coverage fixes
part 1 !1894 (merged) 2,256 The fill path itself: three answers and the refusal's framing
main

LOC counts insertions against each row's own target. Part 1's figure is measured on the rebased head; parts 2 and 3 have not been rebased onto it, so theirs predate the comment-caps compression noted under Diff size.

The first two are inert in production. oci.NewRemoteBlobHandler takes the fill seam as an option, so an arm built without it answers every cache miss with the interim 501. Nothing reaches the fill path until !1904 supplies the seam, which is what makes it safe to land the arm and its guards separately.

Two MRs sat alongside rather than in the chain, and both have merged: !1893 (merged) (the S16 spec amendment this arm implements, whose merge was the approval signal) and !1896 (merged) (a WriteError fix this work surfaced). Neither was a code dependency of any of the three.

What

Three answers come out of one fill, and which one a read gets is decided by what it asked for:

Read Answer
Full GET Streams the upstream body to the client while the same read tees it into the cache, so the 200 commits before the fill has a verdict.
Cold HEAD Drains the fill to end-of-body against io.Discard, then answers from the committed row. No upstream HEAD is issued.
Ranged GET Fetches the whole blob with no Range forwarded, then serves the range out of local storage — including the unsatisfiable range, which fills before it answers 416.

A refused fill cannot be framed as a complete transfer. On the streamed path the 200 and its headers are long gone by the time a digest mismatch or a body-cap overrun surfaces at end-of-body, and the spec forbids an error envelope after that point. abortBlobMissStream backdates the response write deadline so net/http cannot write the terminating chunk, and the client reads an unexpected EOF instead of accepting bytes this service refused. No cache row commits either way, so no later read is served them.

The framing carries that signal rather than the byte count, and the two refusals differ on the count. A capped body stops short; a digest mismatch does not — the comparison runs after the tee has read the last byte, so every byte is already relayed and only the missing terminating chunk distinguishes the response from a clean one.

miss carries the surface the read was looked up through, re-read once the fill commits, so the deferred answers cannot resolve against a different repository's rows.

What is deliberately not here

  • The two guards a review of this arm found — the 499 for a departed client and the response write deadline — are !1903. The arm is inert until !1904, so no request reaches either gap in the meantime.
  • The composition root is !1904. WithRemoteBlobFill is exported API with no production caller in this MR.

Verification

  • go test -count=1 ./internal/format/oci/... passes.
  • TestRemoteBlobFill_RefusedStream_IsNotFramedAsCompleteToTheClient drives a real server and a real client, because httptest.NewRecorder models no response framing: only the end-to-end pair shows the client reading io.ErrUnexpectedEOF.
  • Three coalescing acceptance criteria ship skipped, naming #752. Their skips say explicitly not to satisfy them with a per-request single-flight: one coalesces nothing while compiling and passing, so the green would assert the opposite of the rule.
  • e2e scenario catalogs: docs/testing/e2e/oci.md puts virtual and remote (proxy/cache) repositories out of scope until the capability ships, so no scenario is added or affected.
  • Conformance: the OCI conformance suite runs against hosted repositories and cannot reach a kind=2 route, so it cannot cover this behaviour. scripts/conformance/provision.sh seeds kind = 0 only, so no MR in this chain can reach a remote route with that harness — a green run on the wiring MR would read as coverage of the fill path without being any. This is the reason rather than a run deferred to !1904.

Overlap with the S16 Step 14 stack

Step 14 and Step 15 are sibling-parallel in the plan and share the same composition glue on purpose — remote_operations.go's own comment says keeping them on one composition "is what stops the manifest, blob, and list arms drifting apart". The cost of that sharing is that parallel development collides.

!1869 (merged) and !1872 (merged) have both merged, and this branch is rebased onto main past them. The forecast below was measured with git merge-tree when !1872 (merged) was still open; part 1's share is now resolved on this branch, and the other two rows are still owed and fall due when each is rebased onto this head.

conflicts with !1872 (merged)
!1894 (merged) (this chain's base) ~31 LOC, resolved
!1903 ~150 LOC
!1904 ~603 LOC

Diff size

2,256 LOC, past the 500 LOC ceiling docs/dev/development-model.md sets. The split by file group:

File group LOC
internal/format/oci/remote_blob_test.go 1,800
internal/format/oci/remote_blob.go 385
five other files, comment and signature edits only 71

The figures are lower than the ones this section carried before the rebase (3,210 total, 2,255 and 849 in the two large files) because lint:comment-caps landed on main in b47c32fc3. It caps a comment block at 3 lines on an exported top-level doc, 1 on an unexported one, and 2 everywhere else and everywhere in a _test.go file, as a ratchet on the blocks a diff touches. It reported 102 over-cap blocks here, and one style(comments) commit compresses all of them: 229 insertions against 1,418 deletions, with no line of Go changed. requireRemoteBlobFillServedHeaders' doc claimed it asserts Content-Length and a withheld allowlist header; it asserts neither, so that claim is deleted rather than compressed.

Splitting a suite from the handler it covers would ship the largest handler step in the plan unverified. The three cuts that were available have already been made: the guards into !1903, the wiring into !1904, and the spec amendment into !1893 (merged).

Related to #288

🤖 Generated with Claude Code

Edited by Sylvia Shen

Merge request reports

Loading
Loading