Skip to content

Backport over-target admission and the lease leak fix to v0.17 - #1427

Merged
phinze merged 4 commits into
release/0.17from
phinze/backport-1387-v0.17
Nov 4, 2026
Merged

phinze merged 4 commits into
release/0.17from
phinze/backport-1387-v0.17

Conversation

@phinze

@phinze phinze commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Backports #1387 to release/0.17, minus its last commit.

This is the fix for MIR-2194: miren.cloud hung on every request until we restarted the app, with all 20 web sandboxes healthy and requests failing with "no sandbox became available within 120 seconds". eu1 runs v0.17.2, which doesn't have #1387, so the pool climbed to the 20-instance maximum and requests had nowhere left to go. This is the same failure MIR-2100 described.

Three of #1387's four commits come over. The first two are straight cherry-picks with identical diffs. The third has one test-only conflict: TestScaleDownDoesNotOverwriteConcurrentScaleUp lands after tests from ef2115a on main, which aren't on this line, so I kept the new test and left those out. Its manager.go change applied cleanly.

I left out 1783c69 (start a deploy's new pool at the current size). It changes deploy behavior and isn't needed to stop this failure, and #1387 notes it can be backed out by itself.

Tested on this tree with go test on pkg/concurrency, servers/httpingress, components/activator, and controllers/sandboxpool. Four tests fail, and the same four fail on plain v0.17.2: three need etcd from the dev environment, and TestManagerLongStartupFailureBackoff fails on a UTC vs local time comparison. I haven't run the etcd-backed tests locally, so those rely on CI.

The changelog entry links #1387. The v0.17.3 stamp will come separately, once we know what else the release carries.

When every running sandbox was at its requests_per_instance target,
AcquireLease parked the request and waited for a new sandbox. That
went wrong in three ways that compounded on prod (MIR-2100):

- Ingress keeps leased capacity cached for five minutes after use, so
  slots on busy-looking sandboxes rarely came back within the 120s
  wait, and ReleaseLease never woke anyone when they did.
- Each waiter asked for one more sandbox, once, and racing +1s
  collapsed through the revision checks: 35 waiters produced +3.
- A sandbox that finished booting served only the five leases its
  target allowed; the rest of the queue sat until it timed out.

Now a request goes to the least-loaded routable sandbox even when all
of them are at target, and that over-target admission kicks off a
background scale-up. The scale-up is sized from demand: capacity
leased on running sandboxes plus a slot per parked waiter, plus one
lease of headroom, over the per-sandbox target. Waiters re-evaluate it
whenever they wake, and at most one scale-up per pool is in flight.
Requests only wait when nothing in the pool is routable, and once
something is, every waiter takes it.

An over-target lease is marked Overflow and takes one slot rather than
a batch, and ingress uses it for the one request that took it and
hands it straight back rather than caching it. Otherwise steady
traffic would keep reusing it, pinning the load to the overloaded
sandbox after new ones boot and keeping demand inflated. A parked
waiter likewise counts as one slot, since a batch-sized slot per
request would size the pool for many times its real load at high
requests_per_instance. The scale-up also works for a pool the activator
hasn't resolved through requestPoolCapacity yet, which is every pool
a deploy creates after startup.

The scale-up trusts the cached pool size, where the old +1 patched
against it and re-read on a revision conflict. A missed scale-down
would leave the cache claiming sandboxes that don't exist and block
scaling for good. So a re-sync after a watch reconnect refreshes any
cached pool the store has moved past, and since a reconnect can still
miss an update, the scale-up also re-reads the pool (at most every 30s)
when demand looks covered but fewer sandboxes are live than the cache
claims. Both only take a newer revision.

A waiter that gets a lease leaves the waiter list under the same lock,
so demand doesn't count it as both a lease and a waiter.

A saturated pool calls the scale-up on every request, under the lock
every other app needs. So it returns before any demand math when a
scale-up is already in flight, when the pool is at its cap with no
re-read due, or for a short backoff after a failed attempt.

requestPoolCapacity keeps its +1 behaviour as a floor, so cold starts
and existing callers are unchanged. requests_per_instance becomes a
scaling target rather than an admission cap, which is what the scaling
docs already say it is.
Two bookkeeping bugs in the ingress lease cache, both of which keep
activator capacity claimed longer than it should be.

ReverseProxy aborts a response whose body copy fails, typically a
client hanging up mid-download, by panicking with http.ErrAbortHandler.
That skipped releaseLease, so the use was never handed back. A lease
that never returns to zero uses never expires, and kept its slots on
the sandbox for the sandbox's lifetime. proxyWithLease now releases on
the way out of a panic and re-panics.

useLease also accepted Uses <= Size, so a lease sized for two requests
carried three.
The idle monitor computes the new DesiredInstances from a pool it
listed earlier, then patched it with revision 0. A scale-up the
activator made in between was silently undone, and the activator's
cache went on believing its own number.

Patch against the revision from the listing instead. On a conflict
the pool is simply looked at again on the next 30s tick.
@phinze
phinze force-pushed the phinze/backport-1387-v0.17 branch from 37be329 to 2abcdf4 Compare November 4, 2026 17:56

@miren-code-agent miren-code-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍪 biscuit: ✅ ready to merge — auto-review, non-blocking

I think this backport is faithful and ready for a human to look at. I'm not recommending it skip human review, though. It changes the activator's hot path on a release branch, and one deliberate omission has a concrete effect on deploys that someone should sign off on.

What holds up on this tree

  • It compiles against release/0.17. Everything the backported code calls exists here: wakeAllWaiters, poolState.revision, versionRef.strategy, the maxInstances computed at the top of requestPoolCapacityTo, and the cond/errors imports in manager.go. wg.Go needs Go 1.25+, and go.mod is on 1.26.10.
  • Overflow leases stay balanced.
    • AcquireOverflow adds 1 to used and returns size 1. Ingress hands that same size back, and AutoStrategy.releaseCapacity subtracts it.
    • RenewLease returns the same *Lease it was given, so a long-running overflow request keeps its Overflow flag across expireLeases ticks. It doesn't turn into a reusable size-1 lease.
    • If invalidateSandboxLeases or invalidateAppLeases drops an overflow lease mid-request, the later releaseLease won't find it in ar.leases, so it doesn't release it a second time.
  • The abort fix releases exactly once. In proxyWithLease, the deferred recover releases and re-panics only on the panic path. Normal returns still go through the caller's releaseLease/invalidateLease.
  • The scale-down change is self-contained. It patches against the revision from the listing and treats a conflict as "try next tick". TestScaleDownDoesNotOverwriteConcurrentScaleUp pins this.

For a human to decide

  1. Deploys without 1783c69. On this branch a new auto-mode pool starts at one instance (controllers/deployment/launcher.go:480, desiredInstances := int64(1)). With over-target admission, every request for the new version that misses the ingress cache goes to that single sandbox instead of parking. checkForSandbox also deliberately hands every queued waiter to the first routable sandbox. So right after a deploy of a busy service, one fresh sandbox takes all of the new version's traffic until the demand-sized scale-up catches up. That's still better than the 120s hangs this fixes, and 1783c69 seems to exist for exactly this moment. Someone should confirm that post-deploy burst is acceptable for miren.cloud's web service on v0.17.3, or decide to bring 1783c69 along.
  2. The etcd-backed tests haven't run on this branch. You said you're relying on CI for them, so please check that run before merging. AcquireLease now holds the write lock for its whole scan, and waiters register before the capacity request. Existing activator tests may have quietly depended on the old timing.

🍪 full review note · reviewed at 2abcdf4

@phinze
phinze marked this pull request as ready for review November 4, 2026 18:19
@phinze
phinze requested a review from a team as a code owner November 4, 2026 18:19
@phinze
phinze merged commit f3c4dd9 into release/0.17 Nov 4, 2026
22 checks passed
@phinze
phinze deleted the phinze/backport-1387-v0.17 branch November 4, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants