Skip to content

Commit 5ba9448

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 5ba9448

7 files changed

Lines changed: 433 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: 99 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,13 @@ 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

5455
type LoadActorForResumeStep struct {
5556
store store.Interface
57+
workerCache workercache.Cache
5658
actorTemplateLister listersv1alpha1.ActorTemplateLister
5759
}
5860

@@ -80,6 +82,36 @@ func (s *LoadActorForResumeStep) Execute(ctx context.Context, input *ResumeInput
8082
}
8183
state.ActorTemplate = actorTemplate
8284

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

@@ -116,19 +148,16 @@ type AssignWorkerStep struct {
116148
func (s *AssignWorkerStep) Name() string { return "AssignWorker" }
117149

118150
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
151+
// RESUMING means a previous attempt already assigned a worker (loaded by
152+
// LoadActorForResumeStep); RUNNING is past this step entirely.
153+
return state.Actor.GetStatus() == ateapipb.Actor_STATUS_RESUMING || state.Actor.GetStatus() == ateapipb.Actor_STATUS_RUNNING, nil
123154
}
124155
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.
127156
switch state.Actor.GetStatus() {
128-
case ateapipb.Actor_STATUS_SUSPENDED, ateapipb.Actor_STATUS_PAUSED, ateapipb.Actor_STATUS_RESUMING:
157+
case ateapipb.Actor_STATUS_SUSPENDED, ateapipb.Actor_STATUS_PAUSED:
129158
return nil
130159
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)
160+
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)
132161
}
133162
}
134163

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

141-
var assignedWorker *ateapipb.Worker
142-
143170
// 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.
171+
// This can happen if ateapi crashed after updating worker with actor assignment,
172+
// but has not yet updated the actor.
148173
for _, worker := range workers {
149174
if worker.Assignment == nil {
150175
continue
@@ -157,20 +182,28 @@ func (s *AssignWorkerStep) Execute(ctx context.Context, input *ResumeInput, stat
157182
return fmt.Errorf("while checking worker eligibility: %w", err)
158183
}
159184
if eligible {
160-
assignedWorker = worker
185+
state.Worker = worker
161186
break
162187
}
163188
// Workers() returns pointers directly from the cache so we need to clone before
164189
// 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-
}
190+
releaseWorker := proto.Clone(worker).(*ateapipb.Worker)
191+
releaseWorker.Assignment = nil
192+
// The claimed worker is no longer eligible (e.g. the actor's
193+
// worker_selector changed after the failed attempt); release it back
194+
// to the free pool — nothing else reclaims a healthy worker whose
195+
// actor moved on to a different pool. Best effort in the background.
196+
go func(release *ateapipb.Worker) {
197+
bgCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
198+
defer cancel()
199+
if err := s.store.UpdateWorker(bgCtx, release, release.Version); err != nil {
200+
slog.ErrorContext(bgCtx, "Failed to release stale worker assignment",
201+
slog.String("worker", release.GetWorkerNamespace()+"/"+release.GetWorkerPod()),
202+
slog.Any("err", err))
203+
}
204+
}(releaseWorker)
170205
}
171-
172-
// If not, find a free one using randomized shuffling
173-
if assignedWorker == nil {
206+
if state.Worker == nil {
174207
pickedWorker, err := s.findFreeWorker(workers, state.ActorTemplate.Spec.SandboxClass, state.ActorTemplate.Spec.WorkerSelector, state.Actor.GetWorkerSelector(), state.Actor.GetLatestSnapshotInfo().GetLocal().GetNodeVmsWithLocalSnapshots())
175208
if err != nil {
176209
return err
@@ -179,14 +212,14 @@ func (s *AssignWorkerStep) Execute(ctx context.Context, input *ResumeInput, stat
179212
return status.Errorf(codes.FailedPrecondition, "no free workers available")
180213
}
181214

182-
assignedWorker = pickedWorker
215+
state.Worker = pickedWorker
183216
slog.InfoContext(ctx, "Picked worker", slog.Any("worker", pickedWorker.String()))
184217
}
185218

186219
// Workers() returns pointers directly from the cache so we need to clone before
187220
// mutating so that the cache is not corrupted if UpdateWorker fails.
188-
assignedWorker = proto.Clone(assignedWorker).(*ateapipb.Worker)
189-
assignedWorker.Assignment = &ateapipb.Assignment{
221+
state.Worker = proto.Clone(state.Worker).(*ateapipb.Worker)
222+
state.Worker.Assignment = &ateapipb.Assignment{
190223
ActorTemplate: &ateapipb.KubeNamespacedObjectRef{
191224
Namespace: state.Actor.GetActorTemplateNamespace(),
192225
Name: state.Actor.GetActorTemplateName(),
@@ -197,16 +230,16 @@ func (s *AssignWorkerStep) Execute(ctx context.Context, input *ResumeInput, stat
197230
},
198231
}
199232

200-
if err := s.store.UpdateWorker(ctx, assignedWorker, assignedWorker.Version); err != nil {
233+
if err := s.store.UpdateWorker(ctx, state.Worker, state.Worker.Version); err != nil {
201234
return err
202235
}
203236

204237
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()
238+
state.Actor.AteomPodNamespace = state.Worker.GetWorkerNamespace()
239+
state.Actor.AteomPodName = state.Worker.GetWorkerPod()
240+
state.Actor.AteomPodIp = state.Worker.GetIp()
241+
state.Actor.AteomPodUid = state.Worker.GetWorkerPodUid()
242+
state.Actor.WorkerPoolName = state.Worker.GetWorkerPool()
210243

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

0 commit comments

Comments
 (0)