docs(plans): plan the upload session concurrency cap
What
The plan MR for #1035: a global cap over the population that holds object-storage staging buffers.
Adds docs/plans/2026-09-03-upload-session-concurrency-cap.md and its index row. No code.
Why now
!2183 (merged) and !2192 (merged) fixed #1023 (closed)'s cause, the GCS driver sizing its buffers from chunk_size. They do not remove the population term. A session still ratchets its buffer to the full chunk_size once its transfer passes half of it and never shrinks for the session's life, and nothing bounds how many sessions are open, so pod memory still scales with concurrent upload count rather than with combined payload size.
The eight decisions the plan settles
| Decision | Ruling |
|---|---|
| What the cap counts | Live in-memory storage.Session handles |
| Wait or shed | Bounded wait, then shed |
| Status | 503 UNAVAILABLE with Retry-After |
| Scope | Global, process-wide |
| Where it lives | internal/storage, as a required NewBlobStore parameter |
| Where the release lives | The callers, by deferred closure |
| The value | Derived at boot from GOMEMLIMIT over the effective chunk_size, with a floor |
| Teardown | A dedicated ungated CancelSession, not a ResumeSession opt-out |
Three of these came out the opposite of the obvious reading, and the plan carries why:
- The release cannot be booked in the store.
Close,Commit, andCancelall return early when the captured request context is dead, so a store-side release misses every client disconnect, a handler panic calls none of them, and the ordinaryPOSTinitiate answers202and never touches its session again. Whichever function owns the session's window defers a release func instead, which is thereserveRenderingSlotshape. That is not always the caller ofNewSession: three of the five openers hand the session back, andinternal/remote'sstreamAndFillhands it out inside the response body it returns, which is the one release site the plan leaves as an open question. 503, not429. S12 already routes transient resource exhaustion to503 UNAVAILABLEwithRetry-Afterand names a drained database pool as an instance. It holdsTOOMANYREQUESTSin reserve for the rate limiter it defers, and this cap is not a rate limit. npm and Maven already carry a503shed vocabulary to extend.- The floor is not 1.
rebuildStreamedKindsholds two sessions concurrently, so a derived cap of 1, which the S3 default produces, would make every npm rebuild wait for a slot only it could release.
Decomposition
Twelve steps, rooted at two, with two sibling groups (Steps 3, 4, 5, 6, 10 off Step 2; Steps 7, 8, 9 off Step 6). Every rendering arm lands before the acquire, because FallbackEligible excludes ErrCacheFill and all three remote formats plus both hosted publish paths answer 500 for the sentinel today. Shipping the acquire first would put a 500 on a first shed. Deriving rebuildMaxRendering from the cap is in scope, and it ships in Step 11 with the acquire it serves rather than in Step 10, so a clamp to 1 on a default S3 install cannot land in the window before the gate exists. The mechanism is a single-purpose SetRebuildRenderingCap(int) setter, because internal/format/npm has no cache type to move the constant and its semaphore onto and its rebuild goroutines take no parameters by design. That leaves the package global in place, which the plan names as a knowingly accepted smell with the reason.
Steps 1, 2, 11, and 12 forecast over the 500-LOC ceiling, each justified in its own entry (Step 3 lands at ~500, not past it). Step 2 joined them when the metrics cardinality pin and the vector-count assertion were added to it. Step 11's overrun has two terms of different kinds: the enumerated post-admission return set, which is what makes the release falsifiable, since a representative sample passes with the release deleted; and the signature change's mechanical tail, 64 NewSession call sites in the untagged internal/storage/testsuites package plus three narrowed declarations, the stub, and the 28 fake declarations across 16 files, none of which decides anything but all of which have to land in the same commit or the tree does not build. Step 12 pays the same tail for 29 ResumeSession sites and 10 fake declarations across 10 files.
Not in this plan
- ADR-004's plan-tier per-user and per-namespace upload cap, which S12 defers and #86 and #279 own. ADR-004's argument against a single global default is about tenant fairness; a pod memory bound has no per-tenant meaning.
- The work-item-396 wedge, where a stalled terminal
Commitpins a goroutine. Behind a global cap it also pins a slot. Named, not fixed.
Open questions
## Open Questions in the plan is not empty, against
docs/plans/TEMPLATE.md's "Must be empty before implementation begins". One
question remains, where the remote fill's release is booked, and it binds
Step 11 alone: Steps 1 through 10 do not touch that path and may open on
this MR's merge, while Step 11's MR may not open until the section is
emptied, and Step 12 inherits the gate through its dependency on Step 11.
The plan says the same at the section itself, so the constraint travels with
the document rather than only with this description.
Diff size
2,823 reviewable LOC, past the 500-LOC ceiling
docs/dev/development-model.md
sets. Split by file group, against the merge base 655743520, which is also
the commit the plan's measurements are pinned at: 2,822 added in
docs/plans/2026-09-03-upload-session-concurrency-cap.md and 1 added in
docs/plans/README.md, nothing removed. Review rounds have rewritten lines
rather than only appending them, so this figure is re-derived from
git diff --numstat at the head rather than accumulated.
Splitting does not help here. A plan is one document and its steps cross-reference
each other, so a half plan cannot be reviewed against the guardrail that a plan
lands before any step MR opens; the alternative is not two smaller plans but a
plan that omits the rulings the twelve steps depend on. What does bound the read
is that the plan is reviewable in sections: ## Research Findings carries the
measured basis, ## Approach the eight rulings, and the twelve step entries
follow one template each. No code ships in this MR.
Overlaps
docs/plans/README.md is an additive index row. Checked against the open set rather than assumed from the plan MRs in flight: !2005 (closed), !2055 (merged), and !2275 (merged) also add a row and conflict keep-both, while !2305 (merged) and !2301 (merged) add only their own plan file and do not touch the index.
!2301 (merged), the upload inactivity deadline plan, is the closest neighbour, and its overlap is one of scope rather than of files: it is itself a plan MR and touches only its own plan file today, so internal/format/oci/upload.go and internal/format/maven/upload.go, which Steps 3, 5, 11, and 12 touch, collide when its step MRs open. !2259 (merged) and !2268 (merged) touch the npm publish files Step 4 needs, and !2268 (merged) also touches internal/format/npm/metrics.md, which Step 10 edits. Each overlap is recorded on the step it hits. !2300 (merged) has merged (2026-09-03), and it rewrote both .runway/values.yaml's GOMEMLIMIT comment, which Step 1 quotes, and .runway/fairway.yaml's headroom comment, which ## Research Findings paraphrases. Both are a staleness risk rather than a conflict, since this plan edits no .runway file, and the research section carries the exemption for each.
Related to #1035