Skip to content

Commit 6d976cf

Browse files
committed
Refactored the worker eligibility check in workflow_resume.go, to also check the worker eligibility again if previous attempt failed after setting actor to RESUMING state.
If the worker becomes ineligible, set the actor to crashed state.
1 parent c968955 commit 6d976cf

7 files changed

Lines changed: 432 additions & 80 deletions

File tree

cmd/ateapi/internal/controlapi/functional_test.go

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1731,7 +1731,8 @@ func TestUpdateActor_NotFound(t *testing.T) {
17311731
// TestResumeActor_ReleasesStaleWorkerWhenPoolBecomesIneligible verifies that
17321732
// a worker claimed by a failed resume attempt is released back to the free
17331733
// pool if, by the next resume attempt, the actor's worker_selector has
1734-
// changed such that the worker's pool is no longer eligible.
1734+
// changed such that the worker's pool is no longer eligible. The actor
1735+
// itself is crashed rather than transparently migrated to another pool.
17351736
// Workflow:
17361737
// 1. Creates pool-a (tier=a) and pool-b (tier=b), and an actor narrowed to
17371738
// tier=a.
@@ -1740,8 +1741,9 @@ func TestUpdateActor_NotFound(t *testing.T) {
17401741
// worker is claimed, leaving worker-a's actor_id set and the actor
17411742
// stuck in RESUMING.
17421743
// 3. Updates the actor's selector to tier=b, making pool-a ineligible.
1743-
// 4. Resumes again; asserts it succeeds onto worker-b, and that worker-a
1744-
// has been released (actor_id cleared) rather than left dangling.
1744+
// 4. Resumes again; asserts it fails and the actor is CRASHED, that
1745+
// worker-a has been released (actor_id cleared) rather than left
1746+
// dangling, and that worker-b was never claimed.
17451747
func TestResumeActor_ReleasesStaleWorkerWhenPoolBecomesIneligible(t *testing.T) {
17461748
ns := namespaceForTest("ns-resume-release-stale")
17471749
tc := setupTest(t, ns)
@@ -1780,19 +1782,16 @@ func TestResumeActor_ReleasesStaleWorkerWhenPoolBecomesIneligible(t *testing.T)
17801782
t.Fatalf("UpdateActor failed: %v", err)
17811783
}
17821784

1783-
if _, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name}}); err != nil {
1784-
t.Fatalf("second ResumeActor failed: %v", err)
1785+
if _, err := tc.client.ResumeActor(context.Background(), &ateapipb.ResumeActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name}}); err == nil {
1786+
t.Fatalf("expected second ResumeActor to fail: the assigned worker's pool is no longer eligible")
17851787
}
17861788

17871789
getResp, err := tc.client.GetActor(context.Background(), &ateapipb.GetActorRequest{Actor: &ateapipb.ObjectRef{Atespace: testAtespace, Name: name}})
17881790
if err != nil {
17891791
t.Fatalf("GetActor failed: %v", err)
17901792
}
1791-
if got := getResp.GetWorkerPoolName(); got != "pool-b" {
1792-
t.Errorf("expected actor to land on pool-b, got worker_pool_name=%q", got)
1793-
}
1794-
if got := getResp.GetStatus(); got != ateapipb.Actor_STATUS_RUNNING {
1795-
t.Errorf("expected actor status RUNNING, got %v", got)
1793+
if got := getResp.GetStatus(); got != ateapipb.Actor_STATUS_CRASHED {
1794+
t.Errorf("expected actor status CRASHED, got %v", got)
17961795
}
17971796

17981797
listResp, err := tc.client.ListWorkers(context.Background(), &ateapipb.ListWorkersRequest{})
@@ -1813,16 +1812,12 @@ func TestResumeActor_ReleasesStaleWorkerWhenPoolBecomesIneligible(t *testing.T)
18131812
t.Errorf("expected worker-a (now-ineligible pool-a) to be released, got actor_id=%q", got)
18141813
}
18151814
case "pool-b":
1816-
if wass := w.Assignment; wass == nil {
1817-
t.Errorf("expected worker-b to be claimed by %q, got nil assignment", name)
1818-
} else {
1819-
if wact := wass.Actor; wact == nil {
1820-
t.Errorf("expected worker-b to be claimed by %q, got nil assignment.actor", name)
1821-
} else {
1822-
if got := wact.Name; got != name {
1823-
t.Errorf("expected worker-b to be claimed by %q, got actor_id=%q", name, got)
1824-
}
1815+
if wass := w.Assignment; wass != nil {
1816+
got := "<nil-actor>"
1817+
if wass.Actor != nil {
1818+
got = wass.Actor.Name
18251819
}
1820+
t.Errorf("expected worker-b to remain unclaimed (actor is crashed, not migrated), got actor_id=%q", got)
18261821
}
18271822
}
18281823
}

cmd/ateapi/internal/controlapi/workflow_pause.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,16 +118,16 @@ func (s *CallAteletPauseStep) CheckPrerequisite(ctx context.Context, input *Paus
118118
if state.Actor.GetStatus() != ateapipb.Actor_STATUS_PAUSING {
119119
return status.Errorf(codes.FailedPrecondition, "CallAteletPauseStep prerequisite not met for Actor: %s (got: %v, want %s)", input.ActorName, state.Actor.GetStatus(), ateapipb.Actor_STATUS_PAUSING)
120120
}
121-
return nil
122-
}
123-
func (s *CallAteletPauseStep) Execute(ctx context.Context, input *PauseInput, state *PauseState) error {
124121
if state.Actor.GetAteomPodNamespace() == "" || state.Actor.GetAteomPodName() == "" {
125122
if err := crashActor(ctx, s.store, state.Actor.GetMetadata().GetAtespace(), state.Actor.GetMetadata().GetName()); err != nil {
126123
slog.Error("Failed to crash actor", slog.String("err", err.Error()))
127124
}
128-
return fmt.Errorf("actor is CRASHED because it was in PAUSING state but has no active worker")
125+
return status.Errorf(codes.FailedPrecondition, "CallAteletPauseStep prerequisite not met for Actor: %s. AteomPodNamespace: %s, GetAteomPodName %s", input.ActorName, state.Actor.GetAteomPodNamespace(), state.Actor.GetAteomPodName())
129126
}
127+
return nil
128+
}
130129

130+
func (s *CallAteletPauseStep) Execute(ctx context.Context, input *PauseInput, state *PauseState) error {
131131
ateletConn, err := s.dialer.DialForWorker(state.Actor.GetAteomPodNamespace(), state.Actor.GetAteomPodName())
132132
if err != nil {
133133
if errors.Is(err, ErrWorkerPodNotFound) {

cmd/ateapi/internal/controlapi/workflow_pause_test.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,10 @@ import (
2424
"google.golang.org/grpc/status"
2525
)
2626

27-
// TestPauseActorWorkflow exercises the pause workflow end-to-end against
28-
// seeded actor statuses, covering both the rejected and the idempotent-success
29-
// paths. The atelet dialer is nil, so any step that unexpectedly reaches it
30-
// panics.
31-
func TestPauseActorWorkflow(t *testing.T) {
27+
// TestPauseActorWorkflow_RejectedAndIdempotentPaths covers the two
28+
// short-circuit paths of the pause workflow: rejection by MarkPausingStep's
29+
// CheckPrerequisite and the IsComplete idempotent fast-forward.
30+
func TestPauseActorWorkflow_RejectedAndIdempotentPaths(t *testing.T) {
3231
tests := []struct {
3332
name string
3433
seedStatus ateapipb.Actor_Status
@@ -136,9 +135,38 @@ func TestPauseSteps_CheckPrerequisite(t *testing.T) {
136135
t.Run(tc.name, func(t *testing.T) {
137136
ctx := context.Background()
138137
for _, st := range allActorStatuses {
139-
err := tc.step.CheckPrerequisite(ctx, &PauseInput{ActorName: "id1"}, &PauseState{Actor: &ateapipb.Actor{Status: st}})
138+
// Worker pod fields are populated so CallAteletPauseStep's
139+
// missing-worker crash branch is not taken; this test only
140+
// verifies status gating.
141+
err := tc.step.CheckPrerequisite(ctx, &PauseInput{ActorName: "id1"}, &PauseState{Actor: &ateapipb.Actor{Status: st, AteomPodNamespace: "ns", AteomPodName: "worker-1"}})
140142
assertPrerequisiteResult(t, st, err, tc.allowed == nil || tc.allowed[st])
141143
}
142144
})
143145
}
144146
}
147+
148+
// TestPauseActor_CrashesWhenPausingActorMissingWorkerPod verifies that a
149+
// PAUSING actor with no worker pod recorded is moved to CRASHED by
150+
// CallAteletPauseStep's prerequisite check and the pause fails with
151+
// FailedPrecondition.
152+
func TestPauseActor_CrashesWhenPausingActorMissingWorkerPod(t *testing.T) {
153+
ctx := context.Background()
154+
st, cleanup := storetest.SetupTestStore(t)
155+
defer cleanup()
156+
w := newTestActorWorkflow(t, st, "ns", "tmpl1")
157+
158+
seedWorkflowActor(t, ctx, st, "team-a", "id1", "ns", "tmpl1", ateapipb.Actor_STATUS_PAUSING)
159+
160+
_, err := w.PauseActor(ctx, "team-a", "id1")
161+
if got := status.Code(err); got != codes.FailedPrecondition {
162+
t.Fatalf("status.Code(err) = %v, want %v (err: %v)", got, codes.FailedPrecondition, err)
163+
}
164+
165+
got, err := st.GetActor(ctx, "team-a", "id1")
166+
if err != nil {
167+
t.Fatalf("GetActor failed: %v", err)
168+
}
169+
if got.GetStatus() != ateapipb.Actor_STATUS_CRASHED {
170+
t.Errorf("stored status = %v, want %v", got.GetStatus(), ateapipb.Actor_STATUS_CRASHED)
171+
}
172+
}

cmd/ateapi/internal/controlapi/workflow_resume.go

Lines changed: 98 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type ResumeInput struct {
4848
// ResumeState holds the mutable state loaded and modified during execution.
4949
type ResumeState struct {
5050
Actor *ateapipb.Actor
51+
Worker *ateapipb.Worker
5152
ActorTemplate *atev1alpha1.ActorTemplate
5253
}
5354

@@ -80,6 +81,36 @@ func (s *LoadActorForResumeStep) Execute(ctx context.Context, input *ResumeInput
8081
}
8182
state.ActorTemplate = actorTemplate
8283

84+
// If the Actor is in Resuming state, it means a previous attempt crashed after AssignWorkerStep.
85+
// We don't need to repeat the AssignWorkerStep, load the Worker now.
86+
if actor.Status == ateapipb.Actor_STATUS_RESUMING {
87+
allPopulated := actor.AteomPodUid != "" && actor.WorkerPoolName != "" && actor.AteomPodName != ""
88+
if !allPopulated {
89+
slog.ErrorContext(ctx, "expected all of AteomPodUid, WorkerPoolName and AteomPodName to be populated, found",
90+
slog.String("AteomPodUid", actor.AteomPodUid),
91+
slog.String("WorkerPoolName", actor.WorkerPoolName),
92+
slog.String("AteomPodName", actor.AteomPodName))
93+
94+
// Crash the actor if its worker assignment is corrupted. We should never be in this state.
95+
if cerr := crashActor(ctx, s.store, input.Atespace, input.ActorName); cerr != nil {
96+
return cerr
97+
}
98+
return status.Errorf(codes.Aborted, "actor %s crashed", input.ActorName)
99+
}
100+
101+
wk, err := s.store.GetWorker(ctx, actor.AteomPodNamespace, actor.WorkerPoolName, actor.AteomPodName)
102+
if err != nil {
103+
// Crash the actor if it was assigned to a deleted pod.
104+
if errors.Is(err, ErrWorkerPodNotFound) {
105+
if cerr := crashActor(ctx, s.store, input.Atespace, input.ActorName); cerr != nil {
106+
return cerr
107+
}
108+
return status.Errorf(codes.Aborted, "actor %s crashed", input.ActorName)
109+
}
110+
return fmt.Errorf("failed to get already assigned worker for actor %w", err)
111+
}
112+
state.Worker = wk
113+
}
83114
return nil
84115
}
85116

@@ -116,19 +147,16 @@ type AssignWorkerStep struct {
116147
func (s *AssignWorkerStep) Name() string { return "AssignWorker" }
117148

118149
func (s *AssignWorkerStep) IsComplete(ctx context.Context, input *ResumeInput, state *ResumeState) (bool, error) {
119-
// Only RUNNING is past this step. RESUMING intentionally re-runs because
120-
// a retry must be able to release a stale worker whose pool became
121-
// ineligible and pick a fresh one.
122-
return state.Actor.GetStatus() == ateapipb.Actor_STATUS_RUNNING, nil
150+
// RESUMING means a previous attempt already assigned a worker (loaded by
151+
// LoadActorForResumeStep); RUNNING is past this step entirely.
152+
return state.Actor.GetStatus() == ateapipb.Actor_STATUS_RESUMING || state.Actor.GetStatus() == ateapipb.Actor_STATUS_RUNNING, nil
123153
}
124154
func (s *AssignWorkerStep) CheckPrerequisite(ctx context.Context, input *ResumeInput, state *ResumeState) error {
125-
// The resume edge exists from SUSPENDED and PAUSED.
126-
// RESUMING is allowed for retrying this step.
127155
switch state.Actor.GetStatus() {
128-
case ateapipb.Actor_STATUS_SUSPENDED, ateapipb.Actor_STATUS_PAUSED, ateapipb.Actor_STATUS_RESUMING:
156+
case ateapipb.Actor_STATUS_SUSPENDED, ateapipb.Actor_STATUS_PAUSED:
129157
return nil
130158
default:
131-
return status.Errorf(codes.FailedPrecondition, "AssignWorkerStep prerequisite not met for Actor: %s (got: %v, want %s, %s or %s)", input.ActorName, state.Actor.GetStatus(), ateapipb.Actor_STATUS_SUSPENDED, ateapipb.Actor_STATUS_PAUSED, ateapipb.Actor_STATUS_RESUMING)
159+
return status.Errorf(codes.FailedPrecondition, "AssignWorkerStep prerequisite not met for Actor: %s (got: %v, want %s or %s)", input.ActorName, state.Actor.GetStatus(), ateapipb.Actor_STATUS_SUSPENDED, ateapipb.Actor_STATUS_PAUSED)
132160
}
133161
}
134162

@@ -138,13 +166,9 @@ func (s *AssignWorkerStep) Execute(ctx context.Context, input *ResumeInput, stat
138166
return fmt.Errorf("while listing workers: %w", err)
139167
}
140168

141-
var assignedWorker *ateapipb.Worker
142-
143169
// Check if we already have a worker assigned from a previous failed attempt.
144-
// If that worker's pool is no longer eligible (e.g. the actor's
145-
// worker_selector was updated after the failed attempt), release it back
146-
// to the free pool instead of leaving it claimed forever — nothing else
147-
// reclaims a healthy worker whose actor moved on to a different pool.
170+
// This can happen if ateapi crashed after updating worker with actor assignment,
171+
// but has not yet updated the actor.
148172
for _, worker := range workers {
149173
if worker.Assignment == nil {
150174
continue
@@ -157,20 +181,28 @@ func (s *AssignWorkerStep) Execute(ctx context.Context, input *ResumeInput, stat
157181
return fmt.Errorf("while checking worker eligibility: %w", err)
158182
}
159183
if eligible {
160-
assignedWorker = worker
184+
state.Worker = worker
161185
break
162186
}
163187
// Workers() returns pointers directly from the cache so we need to clone before
164188
// mutating so that the cache is not corrupted if UpdateWorker fails.
165-
release := proto.Clone(worker).(*ateapipb.Worker)
166-
release.Assignment = nil
167-
if err := s.store.UpdateWorker(ctx, release, release.Version); err != nil {
168-
return fmt.Errorf("while releasing stale worker assignment: %w", err)
169-
}
189+
releaseWorker := proto.Clone(worker).(*ateapipb.Worker)
190+
releaseWorker.Assignment = nil
191+
// The claimed worker is no longer eligible (e.g. the actor's
192+
// worker_selector changed after the failed attempt); release it back
193+
// to the free pool — nothing else reclaims a healthy worker whose
194+
// actor moved on to a different pool. Best effort in the background.
195+
go func(release *ateapipb.Worker) {
196+
bgCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
197+
defer cancel()
198+
if err := s.store.UpdateWorker(bgCtx, release, release.Version); err != nil {
199+
slog.ErrorContext(bgCtx, "Failed to release stale worker assignment",
200+
slog.String("worker", release.GetWorkerNamespace()+"/"+release.GetWorkerPod()),
201+
slog.Any("err", err))
202+
}
203+
}(releaseWorker)
170204
}
171-
172-
// If not, find a free one using randomized shuffling
173-
if assignedWorker == nil {
205+
if state.Worker == nil {
174206
pickedWorker, err := s.findFreeWorker(workers, state.ActorTemplate.Spec.SandboxClass, state.ActorTemplate.Spec.WorkerSelector, state.Actor.GetWorkerSelector(), state.Actor.GetLatestSnapshotInfo().GetLocal().GetNodeVmsWithLocalSnapshots())
175207
if err != nil {
176208
return err
@@ -179,14 +211,14 @@ func (s *AssignWorkerStep) Execute(ctx context.Context, input *ResumeInput, stat
179211
return status.Errorf(codes.FailedPrecondition, "no free workers available")
180212
}
181213

182-
assignedWorker = pickedWorker
214+
state.Worker = pickedWorker
183215
slog.InfoContext(ctx, "Picked worker", slog.Any("worker", pickedWorker.String()))
184216
}
185217

186218
// Workers() returns pointers directly from the cache so we need to clone before
187219
// mutating so that the cache is not corrupted if UpdateWorker fails.
188-
assignedWorker = proto.Clone(assignedWorker).(*ateapipb.Worker)
189-
assignedWorker.Assignment = &ateapipb.Assignment{
220+
state.Worker = proto.Clone(state.Worker).(*ateapipb.Worker)
221+
state.Worker.Assignment = &ateapipb.Assignment{
190222
ActorTemplate: &ateapipb.KubeNamespacedObjectRef{
191223
Namespace: state.Actor.GetActorTemplateNamespace(),
192224
Name: state.Actor.GetActorTemplateName(),
@@ -197,16 +229,16 @@ func (s *AssignWorkerStep) Execute(ctx context.Context, input *ResumeInput, stat
197229
},
198230
}
199231

200-
if err := s.store.UpdateWorker(ctx, assignedWorker, assignedWorker.Version); err != nil {
232+
if err := s.store.UpdateWorker(ctx, state.Worker, state.Worker.Version); err != nil {
201233
return err
202234
}
203235

204236
state.Actor.Status = ateapipb.Actor_STATUS_RESUMING
205-
state.Actor.AteomPodNamespace = assignedWorker.GetWorkerNamespace()
206-
state.Actor.AteomPodName = assignedWorker.GetWorkerPod()
207-
state.Actor.AteomPodIp = assignedWorker.GetIp()
208-
state.Actor.AteomPodUid = assignedWorker.GetWorkerPodUid()
209-
state.Actor.WorkerPoolName = assignedWorker.GetWorkerPool()
237+
state.Actor.AteomPodNamespace = state.Worker.GetWorkerNamespace()
238+
state.Actor.AteomPodName = state.Worker.GetWorkerPod()
239+
state.Actor.AteomPodIp = state.Worker.GetIp()
240+
state.Actor.AteomPodUid = state.Worker.GetWorkerPodUid()
241+
state.Actor.WorkerPoolName = state.Worker.GetWorkerPool()
210242

211243
updatedActor, err := s.store.UpdateActor(ctx, state.Actor, state.Actor.GetMetadata().GetVersion())
212244
if err != nil {
@@ -275,6 +307,40 @@ func (s *CallAteletRestoreStep) CheckPrerequisite(ctx context.Context, input *Re
275307
if state.Actor.GetStatus() != ateapipb.Actor_STATUS_RESUMING {
276308
return status.Errorf(codes.FailedPrecondition, "CallAteletRestoreStep prerequisite not met for Actor: %s (got: %v, want %s)", input.ActorName, state.Actor.GetStatus(), ateapipb.Actor_STATUS_RESUMING)
277309
}
310+
if state.Worker == nil {
311+
return status.Errorf(codes.FailedPrecondition, "Assigned worker is nil")
312+
}
313+
// Verify if the worker is still assigned to the same Actor.
314+
assigned := state.Worker.GetAssignment().GetActor()
315+
if assigned.GetAtespace() != input.Atespace || assigned.GetName() != input.ActorName {
316+
slog.ErrorContext(ctx, "crashing actor because its assigned worker no longer belongs to it",
317+
slog.String("worker", state.Worker.GetWorkerPod()),
318+
slog.Any("assignment", state.Worker.GetAssignment()))
319+
if cerr := crashActor(ctx, s.store, input.Atespace, input.ActorName); cerr != nil {
320+
return fmt.Errorf("while crashing actor: %w", cerr)
321+
}
322+
return status.Errorf(codes.Aborted, "actor %s crashed", input.ActorName)
323+
}
324+
eligible, err := isWorkerEligibleForActor(state.Worker, state.ActorTemplate.Spec.SandboxClass, state.ActorTemplate.Spec.WorkerSelector, state.Actor.GetWorkerSelector())
325+
if err != nil {
326+
return fmt.Errorf("while calling isWorkerEligbleForActor :%w", err)
327+
}
328+
if !eligible {
329+
slog.ErrorContext(ctx, "crashing actor because previously assigned worker is not eligible anymore")
330+
release := proto.Clone(state.Worker).(*ateapipb.Worker)
331+
release.Assignment = nil
332+
// If that worker's pool is no longer eligible (e.g. the actor's
333+
// worker_selector was updated after the failed attempt), release it back
334+
// to the free pool instead of leaving it claimed forever — nothing else
335+
// reclaims a healthy worker whose actor moved on to a different pool.
336+
if err := s.store.UpdateWorker(ctx, release, release.Version); err != nil {
337+
return fmt.Errorf("while releasing stale worker assignment: %w", err)
338+
}
339+
if cerr := crashActor(ctx, s.store, input.Atespace, input.ActorName); cerr != nil {
340+
return fmt.Errorf("while crashing actor: %w", cerr)
341+
}
342+
return status.Errorf(codes.Aborted, "actor %s crashed", input.ActorName)
343+
}
278344
return nil
279345
}
280346
func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, state *ResumeState) error {

0 commit comments

Comments
 (0)