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, and Cancel all 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 ordinary POST initiate answers 202 and never touches its session again. Whichever function owns the session's window defers a release func instead, which is the reserveRenderingSlot shape. That is not always the caller of NewSession: three of the five openers hand the session back, and internal/remote's streamAndFill hands it out inside the response body it returns, which is the one release site the plan leaves as an open question.
  • 503, not 429. S12 already routes transient resource exhaustion to 503 UNAVAILABLE with Retry-After and names a drained database pool as an instance. It holds TOOMANYREQUESTS in reserve for the rate limiter it defers, and this cap is not a rate limit. npm and Maven already carry a 503 shed vocabulary to extend.
  • The floor is not 1. rebuildStreamedKinds holds 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 Commit pins 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

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading