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
499for 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.
WithRemoteBlobFillis exported API with no production caller in this MR.
Verification
go test -count=1 ./internal/format/oci/...passes.TestRemoteBlobFill_RefusedStream_IsNotFramedAsCompleteToTheClientdrives a real server and a real client, becausehttptest.NewRecordermodels no response framing: only the end-to-end pair shows the client readingio.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=2route, so it cannot cover this behaviour.scripts/conformance/provision.shseedskind = 0only, 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