Run auto-standby operations on bounded workers#311
Conversation
The auto-standby controller executed standby operations synchronously inside its single event loop. Each standby pauses the VM and writes its memory snapshot to disk, so one slow standby blocked every other standby and also stalled processing of instance lifecycle and conntrack events, delaying idle countdowns for unrelated instances. Under bursts of concurrently idle instances, queue wait grew unboundedly. Dispatch standby attempts to goroutines gated by a semaphore instead. Concurrency is capped by the new auto_standby.max_concurrent config (default 16); demand above the cap queues without blocking the event loop. Inbound activity observed while an attempt is queued cancels it via the existing standbyRequested flag, which now also deduplicates timer fires for the same instance. Standby attempts that fail because the instance no longer exists now drop tracking state instead of re-arming the idle timer forever; the store adapter translates instances.ErrNotFound so the controller can detect this. Adds a hypeman_auto_standby_standby_in_flight gauge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
State refreshes (lifecycle events, periodic resync) reset standbyRequested, which could re-arm an elapsed idle timer and dispatch a second worker for an instance whose standby was still executing. Track execution in a separate standbyExecuting flag that refreshes do not touch; dispatch and the worker re-check both skip while it is set. Also clear standbyRequested when the active-connection reconcile discovers inbound connections for an instance with a queued standby, matching the conntrack NEW-event handling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
One thing to verify: lib/autostandby/controller.go:729-740 — On StandbyInstance failure, the worker always sets a fresh idleSince, arms the idle timer, and persists idle runtime without checking activeInbound. With the loop unblocked, conntrack NEW can land during the snapshot; a late failure then overwrites that active state and arms a countdown while connections exist. That also persists a false IdleSince, which can shorten the effective idle window across restart. Sync path couldn’t hit this because the loop was blocked inside standby. |
|
reviewed the full diff — solid PR. the dispatch/execute split is careful, the Questions
Suggestions
Nits
|
Review follow-ups: - A standby failure no longer re-arms the idle countdown or persists IdleSince when inbound activity arrived during the attempt; the reconcile/destroy flow owns the countdown once connections exist. Previously a late failure overwrote active state with a false idle window that could survive a restart. - executeStandby clears standbyRequested when it bails on active inbound, so the invariant no longer depends on every activeInbound writer clearing the flag. - executeStandby refuses to start after shutdown cancellation, so a worker that wins a freed slot during teardown exits instead of beginning a new snapshot. - New hypeman_auto_standby_standby_queued gauge exposes dispatched attempts waiting for a slot; the in-flight gauge alone saturates at the cap and cannot show queue pressure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@yummybomb good catch, fixed in b1d6dee — the failure path now checks |
|
@hiroTamada thanks — answers on the questions, and the suggestion + nits are in b1d6dee: Shutdown hang in Pausing a VM with a just-established connection: unchanged semantics from the old code — once Queue-pressure gauge: added — Bail-path flag clear: done — Shutdown test: added as above; ran 20x under |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b1d6dee. Configure here.
A state refresh cancels any queued standby attempt via standbyRequested, then re-establishes a countdown or reconcile. If the refresh errored between those steps (policy compile, connection matching, or runtime persistence), the instance was left with no timer, no worker, and no pending request until the next periodic resync. Clear standbyRequested only after the fallible refresh steps succeed, so an erroring refresh leaves the queued attempt to proceed on the last known idle state. Treat runtime persistence failure in the refresh idle branch as non-fatal — arm the timer and log — matching how connection event handlers already handle persist errors; the runtime only matters for recovery across controller restarts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Problem
The auto-standby controller executes standby operations synchronously inside its single
Runevent loop. A standby pauses the VM and writes its full memory snapshot to disk, which can take fractions of a second per standby.Standbystate transition can time out on instances that are perfectly healthyA secondary issue: a standby attempt that fails because the instance was deleted re-arms the idle timer and retries forever until the delete event drains through the (congested) loop.
Change
handleStandbyTimernow marks the instance standby-requested and hands theStandbyInstancecall to a goroutine gated by a semaphore, so the event loop never blocks on snapshot IO. Follows the same shape as the existingfirecracker_max_concurrent_restorescap on the restore side.auto_standby.max_concurrent(default 16) bounds concurrent standby operations per host. Demand above the cap queues;max_concurrent: 1reproduces the old serialized throughput while still keeping the loop unblocked.standbyRequested, and the worker re-validates state after acquiring a slot, cancelling the attempt. The same flag deduplicates repeat timer fires for one instance.instances.ErrNotFoundintoautostandby.ErrInstanceNotFound; the controller drops tracking state for such instances instead of re-arming the timer.Runcancels and waits for in-flight workers on exit.hypeman_auto_standby_standby_in_flightfor observing concurrency; startup log includes the configured cap.Testing
go test -race ./lib/autostandby/ ./lib/providers/ ./cmd/api/config/passes. New tests cover: concurrency never exceeds the cap, queued attempts cancelled by inbound activity, duplicate timer fires deduplicate, and not-found errors drop state without re-arming.TestAutoStandbyCloudHypervisorActiveInboundTCP(the manual-only e2e that CI skips,HYPEMAN_RUN_AUTO_STANDBY_E2E=1) passes on this branch on a KVM-capable Linux host: a real VM staysRunningwhile an inbound TCP connection is open, and reachesStandbyvia the new async worker path ~1.3s after the idle timer fires. Note it runs a single instance, so the semaphore is never contended — concurrency behavior is covered by the unit tests above.go build ./...passes; wire regenerated viamake generate-wire(includes a cosmetic identifier rename from the current wire version).cmd/api/apiintegration suite fails in my environment on an image-pull timeout; it fails identically on unmodifiedmain, so it is unrelated to this change.🤖 Generated with Claude Code
Note
Medium Risk
Changes core VM standby timing and concurrency on the control plane; behavior is well-tested but mis-tuned
max_concurrentor race fixes could affect when instances enter Standby or how load spikes hit disk.Overview
Auto-standby no longer blocks its event loop on memory snapshot IO. When an idle timer fires, the controller marks the instance standby-requested and dispatches
StandbyInstanceto background workers gated by a semaphore (standbySlots), so conntrack, lifecycle events, and other timers keep processing.New server config
auto_standby.max_concurrent(default 16, validated in config load) wires throughProvideAutoStandbyControllerintoControllerOptions.MaxConcurrentStandbys. Extra demand queues until a slot frees; startup logging and docs describe the tradeoff between snapshot IO pressure and standby latency.Standby lifecycle and races are tightened:
standbyExecutingprevents duplicate workers across resyncs; inbound traffic or reconcile while queued clearsstandbyRequestedand workers no-op after acquiring a slot; failed standbys skip re-arming the idle timer when connections became active mid-flight.instances.ErrNotFoundmaps toautostandby.ErrInstanceNotFoundso deleted instances drop tracking instead of retrying forever.Runcancels worker context on exit and waits onstandbyWG; persist errors during idle refresh log warnings but still arm timers.Observability: gauges
hypeman_auto_standby_standby_in_flightandhypeman_auto_standby_standby_queuedplus expanded unit tests for concurrency, queue cancel, shutdown, and edge cases.Reviewed by Cursor Bugbot for commit 90e7cfd. Bugbot is set up for automated code reviews on this repo. Configure here.