fix(npm): re-arm the hosted body write deadline from the document size (npm-hosted-fill-body-write-deadline plan: 2/2)
What
Step 2 of docs/plans/2026-08-31-npm-hosted-fill-body-write-deadline.md, and the fix step 1 made observable.
A cold hosted npm metadata read arms one 65 s write deadline before its inline
fill, then streams the document it just committed under whatever is left of
that instant. A packument that took most of the budget to render is also a
large document to stream, so the body is written against a 5 s tail and a
deadline firing mid-io.Copy truncates a committed 200: the status line is
already out, and no 503 with a Retry-After is reachable.
buildInline now arms a second time, on the stream arm alone, once the
document is resolved:
min(inlineBuildResponseGrace + size / inlineBuildBodyFloorRate, inlineBuildBodyMaxBudget)512 KiB/s floor rate, 5 minute ceiling, measured from the
HostedDocument.ReadyAt the same function has just stamped. The arm records
max(firstArm, readyAt + budget), so it can only ever extend: below the
60 s x 512 KiB/s break-even, roughly 30 MiB, a fast fill's remaining margin
is the later instant and the arm is inert. That max is the whole design.
Arming readyAt + budget outright would replace about 64 s of margin with
5.195 s on a 100 KiB document and cut slow clients that succeed today, and it
is also what makes the ceiling safe, since a capped document is never given
less than it had before this step.
The redirect and 304 arms do not re-arm. A Location header and an empty
304 are the envelope inlineBuildResponseGrace already covers, and a 304
resolved no body to derive a window from.
Worked budgets
| Document | Rate term plus grace | After the cap |
|---|---|---|
| 100 KiB | 5.195 s | 5.195 s |
| 4 MiB | 13 s | 13 s |
| 64 MiB | 2 m 13 s | 2 m 13 s |
| 147 MiB | 4 m 59 s | 4 m 59 s |
| 147.5 MiB | 5 m | 5 m, where the cap begins to bind |
| 500 MB (477 MiB, the configured ceiling) | 15 m 59 s | 5 m |
Both columns are budgets, not armed instants: under the break-even the first
arm's instant is the later one and is what the row is actually held for. The
size is clamped to 150 MiB before the multiply, because
time.Duration(size) * time.Second wraps negative past roughly 8.5 GiB and a
wrapped instant is one already in the past. That clamp is an overflow guard
and not the ceiling; the min is what binds from 147.5 MiB up.
packument_cache.md carries the derivation, what would falsify the floor
rate, what the cap costs and buys, the ceilings that bind before it (the 25 s
shutdown drain above 12.5 MiB, and a platform-managed GKE Gateway
timeout), and the store-phase bound this deliberately is not.
What moves for an operator
Step 1's kill_outcome split keeps its two values and its cardinality, but the
reading changes on the stream arm: it now says which of the two arms set the
window a response was cut on, rather than naming two comparable populations. A
successful second arm on a document of positive size puts
budget_exhausted_before_body out of reach there, so a tick on it means the
fill spent close to its whole 60 s and then either the second arm failed or the
store reported no length. Both halves need the slow fill: after a fast one the
max keeps the first arm's instant. metrics.md,
observability.md and packument_cache.md all carry that reading. Nothing in
this repository alerts on the label, and the family shipped with step 1, so no
dashboard predates the change.
On the dist-tags route the label's meaning changes the same way, and only the
magnitude of the widening is route-specific. npm.max_tags_per_package bounds
that document at tens of KiB against a 30 MiB break-even, so the derived window
is about 5 s and the first arm's instant is the later one on the ordinary path.
That is not unconditional: a fill that spent close to its whole 60 s leaves
about 5 s, which the derived window beats, and the re-arm moves the instant
there too.
Review findings fixed in this MR
A branch review found five, fixed in 44e546446 and 9cf6224f5 before this MR
opened. One was a real defect in the first commits:
armInlineBuildBodyDeadline read the clock a second time instead of measuring
from the ReadyAt buildInline had just stamped, and inlineBuildKillOutcome
subtracts that same pair. The two therefore disagreed by the nanoseconds
between the reads. On a document whose Size a store could not report the
window is exactly the grace term, and those nanoseconds pushed it across the
<= the label turns on, so the response booked body_write_overran (which
says a window was available and the write did not fit) when the body in fact
never had one. All three documents described that ending as reachable, so the
code was the half that was wrong. Measured across fill elapsed 0 s, 30 s, 59 s,
62 s and 64.5 s before the fix: body_write_overran every time, window floor
5.000000041 s.
Nothing composed the arm with the classifier, which is why it shipped green:
the fallback and the classification were each pinned alone.
TestInlineBuildKillOutcome_ReadsTheWindowTheArmActuallyLeft now composes them
the way buildInline does, over the two documented paths to
budget_exhausted_before_body with a fast-fill negative beside each, and is
mutation-checked against the previous behaviour. assertArmedAt drops its
500 ms tolerance, because the arm no longer takes a clock reading of its own.
metrics.md also told an operator to rule the non-positive-size case out on
the route's document-size histogram. buildInline observes a size only where
it is positive, so that case leaves no sample there and its absence settles
nothing; the Warn the arm emits is what separates the two.
The remaining three are reference accuracy. The new helper's doc comment linked
#912, which is scoped to internal/format/oci by title, label and proposal and
would close without touching any npm file, leaving this package's three
arm-and-log helpers untracked; #1138 is filed for the npm side and is what the
comment and the plan now link. The HostedDocument.WriteDeadline field comment
enumerated the zero cases without the redirect and 304 arms. And
packument_cache.md cited ADR-004 as committing to "concurrent-session
limits", where its #### Concurrent Upload Sessions limit caps in-flight
uploads and bounds nothing on this read path.
Second review round
A branch review of the pushed state found five more, fixed in 75ab24316,
46876a3ff and e54df427b. None changes what the service does; four are
claims a reader can check and the fifth is a missing assertion.
Three passages said the size-derived arm leaves the kill_outcome split alone
below the 30 MiB break-even. It does not. The max compares against what the
first arm has left rather than against its whole 65 s, so below the
break-even which of the two instants wins is conditional on what the fill
spent, and this branch's own
TestArmInlineBuildBodyDeadline_ExtendsPastTheFirstInstant is the
counterexample: its "a fill that spent nearly all of its window" row is a
100 KiB document and the derived instant wins. metrics.md's
### The two kill counters and observability.md's dist-tags row now carry
the condition, and the dist-tags row says what metrics.md's label entry
already said correctly for both routes: a successful second arm on a positive
size puts budget_exhausted_before_body out of reach whatever the route,
because the derived window exceeds the grace from one byte up. What stays
route-specific is the magnitude of the widening, not the label's meaning.
Two smaller over-claims went with them. The split cannot say which of the two
arms set the window, since a max that picked firstArm and one that picked
the derived instant both book body_write_overran. And
budget_exhausted_before_body is a partial metric half of a failed second arm
rather than a whole one: after a fast fill a failed arm leaves the first arm's
window standing, so a kill there books body_write_overran and reads exactly
like a successful arm.
packument_cache.md's drain paragraph said a drain-cut body books in neither
kill counter and stopped there. It also books code="success", because
hostedStreamRecorder.record reaches that write through an arm calling no
recordOutcomeCode, and widening the window widens the population landing
there rather than introducing it. Both halves are now stated beside the drain.
The warm-arm bullet described the #1002 (closed) gap without naming it, so nothing in
this package reached the issue; it is linked once, on that bullet.
The second arm's Warn had no test, though metrics.md and
packument_cache.md both make it the only thing that says a widening was
lost: a grep for the message found one hit, the emit site. It moves to
logMsgInlineBuildBodyArmFailed, beside the logMsgSuperseded pair the file
already holds for the same reason, and the arm-failure test asserts level and
message together. Mutation-checked by replacing the log call with a no-op:
both rows fail.
assertArmedFromTheDocumentSize compared the armed instant against the
hand-over with a one-second InDelta, justified by a clock read "within
microseconds" of the stamp. 44e546446 removed that clock read, so a second
of slack sat over exactly the invariant that commit established. It is an
equality now, matching the sibling assertArmedAt, which had dropped its own
tolerance for this reason. Mutation-checked by restoring the clock read: the
equality fails on a 41 ns drift, which the InDelta passed.
And 44e546446 updated four of the five places the plan states the arm's
formula. The fifth, the opening line of
### The second arm takes the later of the two instants, still read now +.
The plan also cited config.example.yaml:91 for max_tags_per_package, which
is at :92.
One finding was raised and deliberately not taken. deadlineRecorder's type
doc still opens its last paragraph on a single arm. Correcting it drags an
eight-line block that carries real content into the comment-caps gate, which
would force the whole block to two lines, and the sentence is incomplete
rather than false: the writeDeadlines field and firstArm immediately below
it both carry the correction.
Review rounds after this MR opened
Every finding below was raised on this MR and answered on the branch. No production behavior changed in any of them; the two Go commits in the first round add coverage, and the rest correct claims.
| Round | Findings | Commits |
|---|---|---|
| First | 2 | e1411d9c per-arm failure split driven through Read; 9a33911b the body-arm WARN asserts its cause |
| Second | 9 | d32ccf15 three fixture-claim fixes; febca84f outcome split folded into the derived-window rows; e98f2557 dist-tags budget test says what it proves; 9a6c7e69 remote_document_serve.md claim bounded; 467f22ae an arm-failure tick reads as the blind spot being open, plus the 12.5 MiB and slow-fill corrections |
| Second, CI | — | eb311b16 gofmt blank line left by the fold in febca84f |
| Third | 3 | 118a62cf arm-failure HELP scoped to the first arm; 34d86dd0 the plan's dist-tags inert claim conditioned; this description |
The second round's largest finding is worth naming here because it reverses
something the branch had asserted. Both arms resolve the same request's
ResponseWriter chain, so a first arm that failed is followed by a second
failing the same way: first-fail plus second-succeed is unreachable, and a tick
on ..._inline_build_deadline_arm_failures_total always means the blind spot is
open rather than may mean it. The max over a zero first arm stays in the code
as a defensive contract, and is now documented as one rather than as a
production state.
ADR conformance
ADR-005 ## Constraints on the system ("Timeouts") is the one ADR bearing
on a response write deadline: the service must support extended timeouts for
proxy downloads without affecting redirect performance. This step is what makes
the service conform more closely, because the size-derived window is armed on
the stream arm alone and the redirect arm keeps the tight first arm. No
amendment is owed. The mirror was checked fresh with scripts/adr-freshness.sh
(exit 0).
Diff size
1414 reviewable LOC, over the 500 ceiling in
docs/dev/development-model.md.
Splitting would not help: the production change is 61 LOC across three files and
does not divide. The rest is the test table the derivation needs and the prose
that prices the two constants.
| Group | LOC | Files |
|---|---|---|
| Tests | 856 | inline_build_internal_test.go 451, hosted_document_read_internal_test.go 240, packument_get_test.go 92, inline_build_deadline_test.go 34, disttags_test.go 33, export_test.go 6 |
| Package docs | 370 | packument_cache.md 257, metrics.md 95, remote_document_serve.md 18 |
| Plan | 116 | docs/plans/2026-08-31-npm-hosted-fill-body-write-deadline.md |
| Production Go | 61 | inline_build.go 49, hosted_document_read.go 10, metrics.go 2 |
| Repo docs | 11 | docs/testing/e2e/npm.md 7, observability.md 3, configuration-reference.md 1 |
Plan-file edits in a step MR
Four commits here touch the plan file, which AGENTS.md guardrail 4 tells a step
MR not to do. Three are docs(plans). The fourth is 467f22ae, a docs(npm)
commit that corrects one figure in packument_cache.md and the plan's copy of
the same sentence together, at a reviewer's request; by the path rule in
AGENTS.md that plan hunk should have been its own docs(plans) commit, and
splitting it now would need a force-push mid-review. The guardrail's stated rationale is
Status-table collisions between parallel step MRs, and that does not apply:
the Status table is untouched, step 1 has merged, and no other step MR is in
flight against this plan, so there is no second writer to collide with.
The docs(plans) edits are prose. They resolve the plan's ## Open Questions into
## Resolutions, name ADR-005 in ## Dependencies, pin the 5.195 s rate term,
and retarget the arming-helper smell from #912 to #1138. One of them reverses
this plan's own ruling that the GKE Gateway backend response timeout had to be
answered before this MR opened; that reversal quotes the ruling it replaces in
## Resolutions' third entry, with the two reasons, so it is reviewable in
place rather than by reference.
Merge order
No dependency. Thirteen open MRs touch a file this one touches and all but two
merge cleanly into this branch, tested with git merge-tree:
- !2219 (merged) and !1598 conflict with this branch, and conflict with
mainas well, so the conflict is not attributable to this branch's merge order and wants re-checking once each rebases. - !2268 (merged) conflicts with
mainbut merges cleanly here. - !2279 (merged), !2270 (merged), !2269 (merged), !2259 (merged), !2255 (merged), !2232 (merged), !2230 (merged), !2228 (merged), !2210 (merged) and !2204 (merged) overlap and merge cleanly.
Testing
go test ./internal/format/npm/passes.go vet ./internal/format/npm/clean, and the full pre-commit chain (golangci-lint, Go comment caps,gitlint,go-test) passed on both commits.- No conformance run is owed. No route, status code, header, response body or error code changes: the same bytes go out under the same committed status, and only the instant the connection is held to moves.
docs/testing/e2e/npm.mdgainse2e.npm.consume.cold-read-slow-client, recorded asblocked: the suite has no throttled npm client, and the scenario is only meaningful against one, because what it asserts is which of two write deadlines cut the body. #841 asks for the same harness on the OCI path.
Known gap, not fixed here
The warm arm still writes its whole body under server.timeouts.write, 10 s at
the config.example.yaml figure, with no arm at all. After this step a large
packument therefore succeeds on the cold read that builds it and is truncated
on every warm read afterwards, booking no kill counter and no outcome code.
That is #1002 (closed), which ## Research Findings scopes out on purpose and which
this step does not regress; it is worth landing close behind. A flat kill
counter after this merges is not evidence the truncation class is closed, and
metrics.md says so from the counter's side.
Related to #952 (closed)