Skip to content

feat(load-balancer): suspend and resume a pool around a sleeping container - #64

Merged
mhenrixon merged 1 commit into
dashfrom
feature/scale-to-zero-lb
Jul 29, 2026
Merged

feat(load-balancer): suspend and resume a pool around a sleeping container#64
mhenrixon merged 1 commit into
dashfrom
feature/scale-to-zero-lb

Conversation

@mhenrixon

Copy link
Copy Markdown
Collaborator

Summary

The pool half of scale-to-zero (#19): two LoadBalancer methods that take targets out of rotation while their container sleeps and put them back when it wakes.

Still inert. Nothing calls them yet — the idle controller gets wired up when the request gate lands. Proxy behavior is unchanged.

ResumeFromSleep is the whole point of this PR

SuspendForSleep is the obvious half: empty the pool, stop probing, so a deliberately-stopped container is neither routed to nor dialled once a second for the entire nap.

The resume is where the real defect lives, and it only shows up in the shape scale-to-zero actually targets — a single-target pool:

updateHealthyTargets stops health checking once a lone target first goes healthy (load_balancer.go:313). That means markHealthy() has already fired and waitForHealthyContext is already cancelled. So on wake, without re-arming:

WaitUntilHealthy(30s) -> nil, in 0.00s, for a container that has not started

The wake reports ready and forwards its held request straight into a connection refused. Verified by mutation — drop the re-arm and TestLoadBalancer_ResumeFromSleepRearmsWaitUntilHealthy fails in 0.00s with "readiness was reported for a target that never answered".

Replaced, not cancelled — and what that costs

WaitUntilHealthy treats any non-deadline cancellation as success. Cancelling the old context on resume would therefore tell a waiter that parked before the resume "healthy" at the exact moment every target was marked unverified.

So the context is replaced. The cost, which I want visible rather than buried: a waiter spanning a resume is never released and times out instead. That is a timeout it can retry rather than a false ready, and it is unreachable in the real flow — the controller serializes wakes behind its generation counter, so each wake waits on the context its own resume installed. TestLoadBalancer_ResumeFromSleepDoesNotReleaseAPreviousWaiter pins that behavior explicitly rather than leaving it to be discovered.

Targets re-enter as Adding, not Healthy

A successful probe promotes Adding → Healthy; a failed one only ever demotes Healthy → Unhealthy. So a container that never comes up stays out of the pool instead of flapping into it, and a woken target is held to exactly the standard a freshly deployed one is.

The third race, now fixed where it belongs

WaitUntilHealthy now reads waitForHealthyContext under the lock. This is the third finding from the review that landed in #58, which I deliberately did not fix then: the field was written once in NewLoadBalancer before publication, so it was not a race — there was no second writer. ResumeFromSleep is that second writer, so the lock lands in the commit that creates the defect rather than ahead of it.

Simplification the plan didn't anticipate

docs/plans/2026-07-29-scale-to-zero.md calls for a new RestartHealthChecks method, because BeginHealthChecks assigned stateConsumer outside the inflight lock and was only safe before a target served anything. #58 moved that assignment inside the lock, so there is nothing left to work around — this reuses BeginHealthChecks directly and adds no method.

Test plan

Six tests, all against a single-target pool on purpose:

  • SuspendForSleepEmptiesThePoolAndStopsProbing — pool empty, and probe count flat across three check intervals
  • ResumeFromSleepRearmsWaitUntilHealthythe headline; readiness withheld while the backend 503s, granted once it answers. Mutation-verified.
  • ResumeFromSleepDoesNotReleaseAPreviousWaiter — a resume never hands a parked waiter a success
  • ResumeFromSleepReentersUnverified — suspended targets leave the pool; woken ones rejoin only after a probe
  • SuspendAndResumeAreSafeUnderConcurrentRouting — 20 sleep/wake cycles against 200 concurrent pool reads
  • gofmt, go vet clean; go test -race1251 pass

Deviations & judgment calls

  • I had to correct my own test rather than the code. ResumeFromSleepDoesNotReleaseAPreviousWaiter originally asserted the parked waiter would eventually succeed once the backend answered. It does not, and should not — the resume replaced the markHealthy that would have closed its context. The design is right; my assertion was wrong. It now pins the actual guarantee (no false success) and documents why the timeout is the acceptable outcome.
  • Two tests needed the backend answering during setup, since the helper waits for initial health before returning. Obvious in hindsight, but it produced a confusing 5s failure in the helper rather than in the test.
  • No benchmark. SuspendForSleep/ResumeFromSleep run once per sleep and once per wake, not per request. WaitUntilHealthy gained one uncontended mutex acquire; it is called once per deploy and once per wake, not on the request path.
  • SuspendForSleep clears readers as well as writers. Read targets are replicas whose lifecycle the proxy does not own, so they are never stopped — but leaving them in a pool whose writers are gone would route reads at a service that cannot serve writes. Worth a second opinion if you disagree.

Refs #19

…ainer

The pool half of scale-to-zero (#19). Nothing calls it yet -- the idle
controller is wired up when the request gate lands -- so proxy behavior is
unchanged.

SuspendForSleep empties the pool and stops probing, so a container that was
deliberately stopped is neither routed to nor dialled once a second for the
whole nap. It runs before the containers go down, and the controller holds
arriving requests while it does, so the empty pool is never observable.

ResumeFromSleep is the subtle half, and it exists because of how a single-target
pool behaves -- which is the only shape scale-to-zero actually targets.
updateHealthyTargets stops health checking once a lone target first goes
healthy, so markHealthy has already fired and waitForHealthyContext is already
cancelled. Without re-arming, WaitUntilHealthy returns nil INSTANTLY for a
container that has not started, and the wake would report ready and forward its
held request straight into a connection refused. Verified by mutation: drop the
re-arm and TestLoadBalancer_ResumeFromSleepRearmsWaitUntilHealthy fails in
0.00s with "readiness was reported for a target that never answered".

The context is replaced rather than cancelled. WaitUntilHealthy treats any
non-deadline cancellation as success, so cancelling would tell a waiter that
parked before the resume "healthy" at the very moment every target was marked
unverified. The cost is that such a waiter times out instead of ever being
released; that is a timeout it can retry rather than a false ready, and it is
unreachable in the real flow because the controller serializes wakes behind its
generation counter.

Targets re-enter as Adding, not Healthy: a successful probe promotes Adding to
Healthy while a failed one only ever demotes Healthy to Unhealthy, so a
container that never comes up stays out of the pool instead of flapping into it.
A woken target is therefore held to exactly the standard a freshly deployed one
is.

WaitUntilHealthy now reads waitForHealthyContext under the lock. This is the
third race from the review that landed in 9e6f28b, deliberately left then: the
field was written once in NewLoadBalancer before publication, so it was not a
race until something wrote it at runtime. ResumeFromSleep is that writer, so the
lock lands in the commit where the second writer appears rather than ahead of it.

Reuses BeginHealthChecks rather than adding a restart variant. The plan called
for a separate method because BeginHealthChecks assigned stateConsumer outside
the inflight lock, but 9e6f28b moved that assignment inside, so there is nothing
left to work around.

Refs #19
@mhenrixon mhenrixon self-assigned this Jul 29, 2026
@mhenrixon mhenrixon added the enhancement New feature or request label Jul 29, 2026
@mhenrixon
mhenrixon merged commit 2715a1d into dash Jul 29, 2026
5 checks passed
@mhenrixon
mhenrixon deleted the feature/scale-to-zero-lb branch July 29, 2026 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant