The Bundle Transfer Budget

A consuming instance adopts a package's compiled module by downloading a bundle from its registry. Two budgets bound that download, they were authored independently, and until #4528 they disagreed with each other and with the thing they were supposed to bound.

What was measured

On memex.systemorph.com, 2026-09-17, reading Loki through the control instance's Logs action.

module adopts that failed 18 — 14 in one pass on 2026-09-15 (19:58:24Z → 20:54:45Z), 4 more on 2026-09-16 (14:20:44Z → 14:33:11Z)
spacing between failures exactly 180 s for 10 of them — the reconciler's PerPackageAdoptBudget
cause, every time The operation has timed out.
packages AI, Anthropic, AppleIntelligence, Chat, Essentials, Import, Mcp, Northwind, Notifications, OgCard, OpenAI, OpenStreetMap, Publish, Radzen — the pass walking the catalog in order
attempt timeouts on the transfer pipeline 11, all inside the 65-minute 2026-09-15 episode, 10 of them on ONE pod
Bundle fetch for … lines beside them 0
byte counts or durations recorded anywhere none

🚨 The last two rows are the finding. Eleven attempts hit the 120 s transfer budget and the bundle client never reported one of them, because the reconciler's outer 3-minute wait cancelled the operation first and logged its own timeout. So every occurrence of this defect left exactly one sentence — "The operation has timed out" — and no evidence of why. "Is 120 s too short?" is really "how many bytes, at what throughput?", and that question could not be asked at all.

It is not a burst, and not a roll wave. The episode is a sequential reconcile pass in which every package fails at the same bound; it recurred the next afternoon on different pods. The incident that folded these events retains only ten samples, which is why they first appeared to be six failures in four minutes across four pods.

The shape defect

PluginBundleClient.DownloadOverHttp sent with HttpClient's buffering default, so the entire archive was downloaded inside SendAsync — inside the Polly attempt. A per-attempt budget then measures size ÷ throughput, not "is the registry answering?", which is the only question a per-attempt budget can meaningfully answer. A large bundle and a dead registry produced the same timeout.

Its sibling in the same assembly never had this problem: OciRegistryClient reads with ResponseHeadersRead and streams the blob. The HTTP route now does the same — headers bound the attempt, and the body streams outside it.

🚨 That alone would have traded a timeout for a HANG, and the body carries its own bound because of it. Taking the body outside the attempt leaves it bounded by nothing on the callers that have no operation deadline: RegistryUpdateReconciler wraps its adopt in PerPackageAdoptBudget, but CatalogLayoutAreas.InstallPackage (the manual click) and InstanceAutoRegistrationService (the default install) do not, so a registry that sends headers and then stops would hang them indefinitely — and a hang is worse than a failure (Plugins#959). So the read is bounded here, by a STALL budget: every chunk that arrives resets the deadline, so a transfer still making progress is never cut off however large it is — the whole point — while one that goes silent for TransferStallBudget fails, names itself as a stall rather than someone else's cancellation, and reports the byte count it reached. It bounds silence, never total size.

🚨 Every transfer in the fleet takes this route. The OCI path runs only when a catalog entry carries an artifact, and the platform default IPublicationArtifacts records none, so the digest-verified artifact path — the one that did log its byte count — is dead code on every deployment. That is why no size was recoverable from production logs.

What the transfer now records

A completed transfer states the bytes, the elapsed time and the rate. A transfer that does not complete states how much had arrived before it was cut, and then rethrows untouched — a fault is never swallowed to produce a log line. Those two lines separate the two diagnoses that want opposite fixes:

A rate is reported as 0 when the interval is too small to divide by: a fabricated throughput in the one line that exists to be trusted is worse than no number.

What this does NOT claim

It does not prove a bundle fits 120 s, and it does not cure the eighteen failures. No size for a production bundle was obtainable while writing this, precisely because nothing recorded one. What changed is that the attempt budget now bounds responsiveness rather than size, and the next occurrence will say which of the two it was. Settling #4528 needs that evidence.

Measured after the roll: the fix is live on every replica and the budget is still exceeded

The paragraph above expected the next occurrence to be informative. It was informative in the direction nobody wrote down: the shape fix did not stop the attempt timeout.

Read-only, from Ops/Status/{memex,memex-cloud} on the control instance and the incident node the log watcher folds these events onto. Each reading carries the sample time of the object it came from, so every number below is attributable to one read rather than to a session window: Ops/Status/memex sampled 2026-09-19T18:53:11Z, Ops/Status/memex-cloud sampled 2026-09-19T19:03:22Z, and the incident node re-read at 2026-09-19T19:07:24Z.

deployment image commit replicas pods started carries the shape fix?
memex 3.0.0-ci.8968 96f88406 2/2, converged: true 2026-09-19T08:10:37Z, 08:11:39Z yes
memex-cloud 3.0.0-ci.8969 c25f86ae 3/3, converged: true 2026-09-19T08:34:53Z, 08:39:58Z, 08:39:59Z yes

All ten retained samples on the incident read Source: 'plugin-registry-bundles-standard//Standard-AttemptTimeout', spanning 2026-09-19T19:00:52Z → 19:06:53Z across five pods, and its shape counter advanced 375 → 397 between the node's own lastSeen of 18:51:53Z and 19:06:53Z — 22 occurrences in exactly fifteen minutes. So the 120 s attempt budget on this pipeline is exceeded roughly one and a half times a minute, on five replicas of two deployments, every one of which had been running the streaming transfer for between 10 h 23 m and 10 h 56 m at the time of these reads.

That relocates the cost, and the relocation is what the fix bought. With HttpCompletionOption.ResponseHeadersRead the body leaves the Polly attempt and CopyStallBounded bounds the copy separately, so on these images an attempt timeout cannot be spent streaming bytes. It is spent before the response headers arrive — which points at the bundle index endpoint and its uncached per-request work rather than at the blob transfers this page was written about. The earlier attribution ("it was the blob transfers, not the index") held for the pre-fix images and does not survive the roll.

🚨 A per-pod period is the reading that rules out "one large bundle". The samples sit at a 180 s spacing with millisecond jitter — …-2kcwk at 19:00:52.047 · 19:03:52.052 · 19:06:52.052, five milliseconds of drift over six minutes. A fixed-period population is a repeating scheduled adopt whose attempt exceeds the budget every time it runs, not an unlucky request; the period is PerPackageAdoptBudget, i.e. the outer bound of the inversion below cutting each pass. So the inversion is no longer only a shape problem — it is the reason 397 occurrences in one day still cannot say which of the two call shapes timed out.

Every stage is bounded by the CLIENT, on silence, and the refusal names the stage

The reading above left one question — which of the two call shapes was timing out — and one structural defect, the inverted budgets. Both are settled by moving the clock to the one place that can see every stage: the client's own receive path (PluginBundleClient.Receive), which the index and the bundle now share.

A transfer can be refused in exactly three ways, and each wants a different remedy, so each is a named BundleTransferStage on a BundleTransferException carrying the registry, the elapsed time, the bytes received and the bytes declared:

stage what happened what it accuses
NoResponse no status line or header arrived within one stall budget everything before the first byte — name resolution, the connection, the handshake or the registry's own work; the transport cannot say which, so the stage claims only that. Paired with a fast /api/version from the same host it is the registry (#4963)
StalledMidBody headers arrived, then the body went quiet for one stall budget; the byte count says how far it got the transport (or the registry, at zero bytes)
OverSize the declared Content-Length, or the bytes actually streamed, exceed what the client accepts the archive — a smaller or resumable bundle, never a larger bound

The two stall bounds are bounds on silence, never on total duration. The response start gets TransferStallBudget (120 s) from the moment the request goes out; every chunk of the body that arrives resets a deadline of the same length. A transfer that trickles for longer than the budget completes — that is the property #4549 bought, and BundleTransferFailsOnSilenceTest proves it against a Kestrel socket that streams for two and a half budgets with no gap reaching one. The size bound is the one the buffering read always carried (HttpClient.MaxResponseContentBufferSize), re-established explicitly because streaming had removed the only bound a caller had; a declared length over it is refused before a byte is read.

The fallback client's own clock is switched off (HttpClient.Timeout = InfiniteTimeSpan). With headers read first that clock bounded only the header stage, at 100 s, under a message that names no stage; the standard resilience handler the hosts register already leaves it infinite, which the same test pins.

The index is read once per client. The PromiseSlot used to hold a cold pool.Invoke(...), which every subscriber re-subscribes and therefore re-sends — so "one index read per install pass" was one per package, and a stalled registry was paid for by every package in turn, which is precisely the 180 s per-package period the incident showed. It now holds a pool.Run(...): hot, replayed to every package of the pass, evicted on a fault so the next package asks again.

The inverted budgets, resolved by derivation

RegistryUpdateReconciler.PerPackageAdoptBudget is no longer a second number authored beside the client's: it is TransferStallBudget + 1 min — one silence budget, the longest any single stage may stay quiet, plus headroom for the index read, the decision and the landing write. It therefore always fires after the client's own refusal of a stalled stage, which is what keeps the cause in the log. Neither number was raised; the value is the same 180 s.

The transport pipeline's retry — three attempts of 120 s inside a five-minute total — was structurally unable to finish inside that bound, and that is why the 09-15/16 failures left "The operation has timed out" as their only sentence. It is not reachable for a stall any more: the client's clock is armed before the request leaves and cancels the pipeline's retry of a stall it could never complete, while a fast transient failure (a 5xx, a refused connection) is retried exactly as before. The pipeline's own attempt timer still exists and may win the race with the client's by a millisecond; the client's filter reads its own stall token rather than the exception's type, so the refusal is named NoResponse in every ordering, and the pipeline's OnTimeout event — when it fires — is a duplicate of the client's line, never the discriminator.

#4963 is a different root, and this change makes it legible rather than fixing it

The stall the fleet is living with since the roll is the registry never beginning a response to an authenticated GET /api/plugins/bundles/index.json — #4963 measured it from outside the mesh with a plain curl: 180 s, 0 bytes received, three times in 12.5 hours, while /api/version and the unauthenticated 401 answer in an eighth of a second. That is a server-side defect on the registry, and no client budget can cure it: a bound on bytes cannot fire on a stream that sends none, and a bound on silence can only name it. This page's change does exactly that — the consumer now logs Bundle index over HTTP from https://… did NOT complete after 120000 ms — 0 of an undeclared number of byte(s) had arrived. Cause: Bundle index: the registry at … did not begin a response within 120 s — and the reconciler moves on after one budget per pass instead of one per package. The two issues share a symptom surface and nothing else: #4528 is the consumer's instrument, #4963 is what the instrument is currently reading.

One finding this does not fix

🚨 The incident fingerprint masks Source:, so every Polly OnTimeout on every pipeline folds onto one incident node — the samples on it have also included Orleans.Placement/(null)/Timeout. That formula lives in the log watcher in MeshWeaver.Plugins and is unchanged.

What the masking costs is an attribution, and the attribution has to be repaired by hand. The listing pipeline (#4222) and this transfer pipeline shared one counter, so neither could be closed on "occurrences stopped advancing". When the listing fix reached the registry on 2026-09-19 the listing samples stopped and the transfer samples did not — so the counter's issueNumber was pointing at a defect that was fixed, and the recurrence bot would have reopened the fixed issue on the next tick, inside the hour. The step that makes such a close hold is to repoint the incident node at the issue its current samples name (content.issueNumber / issueUrl, an ordinary patch; the same operation had already been done once on this fingerprint, 1134 → 4222). The superseded predecessor node is left pointing at the closed issue on purpose: it is that issue's historical record, and if it ever advances again the reopen would be correct.

🚨 So the discriminator is samples[], never the count. Read the Source: value on a reopen of any issue attributed to a masked fingerprint before believing the reopen is about that issue. Two issues have now been reopened against fixed defects by this mechanism (#1134, then #4222 repeatedly), which is the argument for deriving the fingerprint from Source: rather than masking it.

Where this sits

The Registry Listing Cache — the sibling endpoint, and the same lesson about measuring the cost before changing the number. Plugin Bundles in the Registry — what a bundle is and how it is published. Operating from the Portal — the Logs action every measurement above came from.

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.