Repository navigation
ate-env-api: wait for a suspended environment to wake instead of failing its first call - #86
Conversation
…ed environment wakes, bounded by --wake-budget
Tomer Glottmann (tomergee)
left a comment
There was a problem hiding this comment.
Review at ee8eabe. I read the diff, ran the two new tests three times with -race, ran the package suite, and trial-merged the branch against #82, #83 and #84 (clean). The design is sound and the server is the right place for it. One behaviour change to bound before merge, one cost to note.
Blocking: none.
Before merge
internal/apiservice/wakeretry.go:59-83. The loop retries on eachUnavailablefor the full budget, also when the control plane reports the actorRUNNING. Today a router outage, or a worker the router cannot reach, fails a call at once; with this PR each call hangs for two minutes and holds a goroutine and a connection while it waits.RUNNINGplusUnavailablehas two causes: a guest that is not listening yet, which resolves in seconds, and a path that is down, which does not. Use the state you already read inactorCrashed: forRUNNINGcap the wait at a short budget (10 to 30 s, a second flag or a constant), and keep the two minutes forSUSPENDED,RESUMINGand the other transitional states. Put the state in the final error message, thus an operator can tell the two cases apart. The same loop callsclient.Geton each attempt, about 60 control-plane reads per waiting call over the two minutes; the shorter cap forRUNNINGbounds that too.
Nits
internal/apiservice/wakeretry_test.go. The budget-expiry path has no test:Unavailablefor the whole budget must end inUnavailablewith the "did not wake within" message and the attempt count.WakeBudgetis a public field, thus the test needs only the*apiservice.ServerontestEnv(it is not kept today) and a budget of a few hundred milliseconds.internal/apiservice/server.go:497. The probe is one extra round trip on each of the three guest paths, on a running environment too.Env.shell()is two guest calls, thus two probes; a rollout loop pays it on each exec, and the nemo-gym readiness poll pays it on each attempt. Not a reason to hold the PR. A follow-up can remember "awake" per(atespace, id)for a few seconds after a probe or a successful call, so a loop probes once.internal/apiservice/wakeretry.go:93-96. A deleted environment comes back fromactorCrashedasNotFoundthroughtoGRPCError, which is the right answer; a comment there saves the next reader the trace.- The budget is reachable only by the
--wake-budgetflag;ate-env manifest workerpooldoes not expose it. Fine for now, because the default is the intended value. One line indocs/release.mdor the README's deploy section when flags get documented.
Verified
TestGuestCallWaitsForWakeandTestGuestCallFailsFastOnCrashpass three times with-race; the fullinternal/apiservicesuite passes.- The fake router returns 503 from the
Runninghook, thus the wait test exercises the real path: 503 →Unavailable→ retry → state flips → probe answersNotFoundfrom the real guest → call proceeds. - The probe id reaches the guest as
GetProcess, which answersNotFoundwithout a log line (guest/process/service.go:229), thus no noise per call. - Merges clean with #82, #83, #84 on
mainat 490130e.
…r is RUNNING, name the state in the error, test both budgets
|
Addressed. RUNNING now has its own budget (--wake-running-budget, default 20 s) so a down path fails in seconds while suspended and transitional states keep the 2 m budget; the final error names the state and the attempt count; the state is read once per attempt and the 2 s backoff cap bounds that at ~60 reads per two-minute wait. Added TestGuestCallFailsWhenWakeBudgetExpires and TestGuestCallCapsWaitWhenRunning (the fixture now keeps the server so budgets can be set), a comment on the NotFound path for deleted environments, and the two flags in the README deploy section. The per-call probe cost is a follow-up as you suggested: a short-lived awake cache per (atespace, id). |
Tomer Glottmann (tomergee)
left a comment
There was a problem hiding this comment.
Review at 3df2928. All five items from the first pass are done: the RUNNING cap with its own flag, the state and attempt count in the error, the budget-expiry test, the NotFound comment, the flags in the README. I read the diff, ran the package suite and the four wake tests three times with -race, ran gofmt, vet and staticcheck (clean), and trial-merged against main at df24cef (clean). One defect in the new accounting blocks.
Blocking
B1. internal/apiservice/wakeretry.go:90-98. The running budget is measured from start, the beginning of the whole wait, not from the attempt that first sees the actor RUNNING. A wake whose suspended phase lasts longer than WakeRunningBudget fails on the first attempt after the state flips, unless the guest already listens at that moment: remaining = runningBudget - time.Since(start) is negative. That is the slow-wake case this PR exists for. 18 s for a cold image pull plus 7 s for a cross-region snapshot read exceeds the 20 s default, and the attempts are 2 s apart.
Proof against the branch (test below): suspended for 2.5 s, RUNNING at 2.5 s, guest listening at 4.5 s, WakeRunningBudget 3 s, WakeBudget 1 min. The call fails at 3.0 s with did not answer within 3s while RUNNING (5 attempts). The actor was RUNNING for 0.5 s of its 3 s budget.
Fix: start the running budget at the first attempt that sees RUNNING, and keep the wake budget as the bound on the total. With this change the five wake tests pass twice with -race.
start := time.Now()
var runningSince time.Time // first attempt that saw the actor RUNNING
...
allowed, since := budget, start
if state == ateapipb.ActorState_ACTOR_STATE_RUNNING {
if runningSince.IsZero() {
runningSince = time.Now()
}
allowed, since = runningBudget, runningSince
} else {
runningSince = time.Time{}
}
remaining := allowed - time.Since(since)
if overall := budget - time.Since(start); overall < remaining {
remaining = overall
}Test (fails on the branch, passes with the fix):
// A wake whose suspended phase outlasts the running budget must still get
// the running budget once the actor is RUNNING: the guest listens a moment
// after the state flips.
func TestGuestCallRunningBudgetStartsAtRunning(t *testing.T) {
te := newFullTestEnv(t)
envCtx := registerGuest(t, te, "late-running-test")
te.control.SetState("late-running-test", ateapipb.ActorState_ACTOR_STATE_SUSPENDED)
te.srv.WakeBudget = time.Minute
te.srv.WakeRunningBudget = 3 * time.Second
start := time.Now()
flip := start.Add(2500 * time.Millisecond) // the control plane reports RUNNING; the next attempt is at ~3.0 s
listen := start.Add(4500 * time.Millisecond) // the guest answers 2 s later, inside the 3 s running budget
te.router.Running = func(string) bool { return time.Now().After(listen) }
go func() {
time.Sleep(time.Until(flip))
te.control.SetState("late-running-test", ateapipb.ActorState_ACTOR_STATE_RUNNING)
}()
_, err := te.procClient.StartProcess(envCtx, &ateenvv1alpha.StartProcessRequest{Command: []string{"sh", "-c", "true"}})
if err != nil {
t.Fatalf("StartProcess after a 2.5 s suspended phase and a 2 s listen lag: %v", err)
}
if elapsed := time.Since(start); elapsed < 4400*time.Millisecond {
t.Errorf("call returned after %s, before the guest was listening", elapsed)
}
}Before merge: none.
Nit
wakeretry.go:92-93. With the fix, the total bound can trip while the actor is RUNNING, and the message then names the running budget. Name the budget that tripped.
Verified
go test -race ./internal/apiservice/...passes. The four wake tests pass three times with-race. gofmt,go vetand staticcheck are clean.- Trial merge with
mainat df24cef: clean. CI at 3df2928: green. TestGuestCallCapsWaitWhenRunningholds the router at 503 with the state RUNNING from the first attempt, thus it does not reach the accounting defect. The test above does.
…the actor RUNNING, and name the budget that tripped
|
Addressed at 4685b2b. The running budget now starts at the first attempt that sees RUNNING and resets if the state leaves RUNNING; the wake budget remains the bound on the total, and the message and the first-attempt log name whichever budget tripped. Added TestGuestCallRunningBudgetStartsAtRunning unchanged; it fails on 3df2928 and passes now, and the five wake tests pass three times with -race along with the package suite. |
Tomer Glottmann (tomergee)
left a comment
There was a problem hiding this comment.
Review at 4685b2b. Approve.
The blocker from the last review is fixed. The running budget now starts at the first attempt that sees RUNNING, and it resets when the state leaves RUNNING. The wake budget still bounds the whole wait. The error and the first-attempt log name the budget that trips.
Blocking: none. Before merge: none.
Nits
cmd/ate-env-api/main.goconflicts with #92, because both PRs add a flag to the same block. The PR that merges second must rebase. The change is mechanical.- The probe adds one round trip to each guest call, as the first review noted. A follow-up can cache an awake state for each environment for a few seconds.
Verified
| if serr != nil { | ||
| return serr | ||
| } | ||
| if state == ateapipb.ActorState_ACTOR_STATE_CRASHED { |
There was a problem hiding this comment.
[nits] internal/apiservice/wakeretry.go:90: Return immediately when state is ACTOR_STATE_DELETING so guest calls do not retry against a deleting environment.
7be7449
into
agent-substrate:main
…t port, bound idle tunnels; provider validates ports and no longer wakes on endpoint() Review follow-ups on #92. Proxy (internal/portproxy): - dial repeats the CONNECT while the router answers 503, with backoff, until --wake-budget is spent: the first CONNECT resumes a suspended actor and the router holds it only for its own parking budget, so a cold image pull or a cross-region snapshot read used to surface as 503. Same budget and flag as the guest calls from #86; the body still names the target when the budget runs out. - Port 80 is refused with 400: it is the ate-env guest, which the API serves itself, not a daemon port. - Idle tunnels: 10 s idle timeout, 4 per environment port, 1024 in total (the zero value meant no limit across hosts), so idle environments are not held open by the API and a large fleet does not pin sockets. - Close drops the idle tunnels; bufferedConn forwards CloseWrite, which httputil.ReverseProxy calls on the backend after an upgrade and the embedded net.Conn hid. - No log line for a router 404 or 503; those are the caller's answer. - Fake router: the one-connection listener's Close ends its Accept, and the tests drop idle tunnels in cleanup, so no tunnel goroutine outlives its test. NeMo Gym provider: - endpoint_base prepends http:// to a bare host:port endpoint_url too; SandboxEndpoint rejects a URL without a scheme. - create() validates SandboxSpec.ports before the actor starts: integers (bool excluded), 1-65535, not 80. - endpoint() no longer probes the guest. It checks that the sandbox exists from the control plane (deleted or crashed raise RuntimeError at once) and returns the static endpoint; the first request through the proxy wakes a parked sandbox and the proxy waits for it. - README: the stale ports/SandboxEndpoint limit is gone; the endpoint paragraph describes the new behaviour. Top-level README notes the wait.
Why
A suspended environment is resumed by the first request that reaches the router, and the router holds that request only for its own parking budget (a few seconds). A wake that pulls the environment's image onto the node or reads its snapshot across a region takes longer: 18 s for a cold image pull, 7 s for a 1 GiB snapshot across regions (measured on GKE this week). The caller got
Unavailablewhile the wake succeeded behind it. A guest not yet listening when its first request arrived produced the same error from the worker. Every client made one attempt, so one such window failed a training step and, in one run, crashed the trainer and leaked its fleet.What
Before forwarding any guest call, ate-env-api sends one trivial request to the guest (
GetProcesswith a probe id) and repeats it with backoff (200 ms → 2 s) while the router answersUnavailable, bounded by--wake-budget(default 2m). Between attempts it checks the actor's state in the control plane and fails at once withFailedPreconditionif it crashed. Anything other thanUnavailablemeans the guest answered; the real call then proceeds on the same connection. On a running environment the probe is one extra round trip.Server-side on purpose: the Python client, the CLI and the MCP handler all get it without changes, and the server already owns the per-actor router connection.
Tests
TestGuestCallWaitsForWake: environment held SUSPENDED, marked RUNNING after 600 ms by a stand-in for the resume;StartProcesssucceeds and returns after the wake rather than failing with the router's 503.TestGuestCallFailsFastOnCrash: environment marked CRASHED; the call fails withFailedPreconditionin milliseconds. The fake control plane gainsSetState. Existing proxy tests pass with the probe in the path.Verified on GKE: an actor whose wake takes 18 s (cold task image on the node) now answers its first command after the wake instead of failing at the 10 s parking budget.