Skip to content

[WIP]Allow force deleting an actor from any state. - #788

Open
Sneha-at (Sneha-at) wants to merge 4 commits into
agent-substrate:mainfrom
Sneha-at:force-delete
Open

[WIP]Allow force deleting an actor from any state.#788
Sneha-at (Sneha-at) wants to merge 4 commits into
agent-substrate:mainfrom
Sneha-at:force-delete

Conversation

@Sneha-at

@Sneha-at Sneha-at (Sneha-at) commented Aug 7, 2026

Copy link
Copy Markdown

Fixes #643

The "Force Delete" feature allows deleting an actor from almost any state (e.g., RUNNING, CRASHED, SUSPENDING), bypassing the normal restriction that required the actor to be SUSPENDED before deletion. This is crucial for cleaning up stuck or failed actors.

Add force option to DeleteActor and create a workflow to cleanup the actor.
This will bypass the requirement to suspend the workload cleanly before
deletion.
A new TERMINATING status is added to indicate that we're starting the
termination sequence. The termination sequence will call atelet to
terminate the workload. Once atelet termination succeeds, then we will
proceed with control plane termination sequence.
Finally we will move to the existing DELETING status to delete the
actor resources.

Involved Components

  • Ateapi (Control Plane): Orchestrates the deletion workflow. It manages the actor state machine, communicates with Atelet, and updates the global store.
  • Store (Database): Holds the state of Actors, Workers, and Volumes.
  • Atelet (Node Agent): Runs on each worker node. It receives termination requests from Ateapi, delegates to Ateom, and performs node-level cleanups (unmounting volumes, clearing directories).
  • Ateom (Runtime Agent): Runs on the worker node, managing the actual workload execution environment (gVisor sandbox or MicroVM). It handles the low-level termination of the workload process and network cleanup.
sequenceDiagram
    autonumber
    actor Client
    participant Ateapi as Ateapi (Control Plane)
    participant Store as Store (DB)
    participant Atelet as Atelet (Node Agent)
    participant Ateom as Ateom (Runtime Agent)
    participant VM_Sandbox as VM/Sandbox (gVisor/CH)

    Client->>Ateapi: DeleteActor(Actor, force=true)
    activate Ateapi
    Ateapi->>Store: GetActor(Actor)
    Store-->>Ateapi: Actor State
    
    Note over Ateapi: MarkTerminatingStep
    Ateapi->>Ateapi: Verify state (allows almost any state if force=true)
    Ateapi->>Store: UpdateActor(Status=TERMINATING)
    
    alt Actor has Worker Assignment
        Note over Ateapi: CallAteletTerminateStep
        Ateapi->>Atelet: Terminate(ActorUID, TargetAteomUID)
        activate Atelet
        
        Atelet->>Ateom: TerminateWorkload(ActorUID)
        activate Ateom
        Ateom->>Ateom: Deactivate Networking
        
        alt Runtime is gVisor (ateom-gvisor)
            Ateom->>VM_Sandbox: runsc delete (containers)
            Note over Ateom, VM_Sandbox: Kills and deletes sandboxed containers
        else Runtime is MicroVM (ateom-microvm)
            Ateom->>VM_Sandbox: Shutdown VMM / Kill Process
            Note over Ateom, VM_Sandbox: Shuts down Cloud Hypervisor & virtiofsd
        end
        
        Ateom->>Ateom: Unmount OCI Overlays
        Ateom->>Ateom: Cleanup Actor Network
        Ateom-->>Atelet: TerminateWorkload Response
        deactivate Ateom
        
        Atelet->>Atelet: Unmount External Volumes on Node
        Atelet->>Atelet: Reset Actor Directories on Node
        Atelet-->>Ateapi: Terminate Response
        deactivate Atelet
    end

    Note over Ateapi: DetachVolumesForDeleteStep
    Ateapi->>Ateapi: Detach volumes from actor in control plane

    Note over Ateapi: ReleaseWorkerStep
    Ateapi->>Store: Get Worker
    Ateapi->>Store: UpdateWorker(Assignment=nil) (releases worker)
    Ateapi->>Store: UpdateActor(WorkerAssignment=nil, LocalSnapshotInfo=nil)

    Note over Ateapi: MarkDeletingStep
    Ateapi->>Store: UpdateActor(Status=DELETING, Volumes=DELETING)

    Note over Ateapi: DeleteVolumesStep
    Ateapi->>Ateapi: Delete volumes (durable storage)

    Note over Ateapi: FinalizeDeletedStep
    Ateapi->>Store: DeleteActor() (removes record)
    
    Ateapi-->>Client: Deleted Actor
Loading

Detailed Step Descriptions (Control Plane)

  1. LoadActorForDeleteStep: Fetches the latest actor state and template from the store.
  2. MarkTerminatingStep: Transition the actor status to TERMINATING. If Force is true, this transition is allowed from any active state.
  3. CallAteletTerminateStep: If the actor is currently assigned to a worker node, this step dials the Atelet on that node and requests termination.
  4. DetachVolumesForDeleteStep: Initiates detachment of any volumes associated with the actor.
  5. ReleaseWorkerStep: Clears the assignment on the worker resource in the store, making the worker available for other actors. It also clears the worker assignment on the actor record.
  6. MarkDeletingStep: Transitions the actor status to DELETING and marks its volumes as DELETING.
  7. DeleteVolumesStep: Deletes the actual volume resources (e.g., GCS buckets or directories).
  8. FinalizeDeletedStep: Permanently removes the actor record from the store.
  • Tests pass

Testing

                                                                                                                                                                                                               
  ### Scenario 1: Force Delete a Running Actor                                                                                                                                                                 
                                                                                                                                                                                                               
  1. Create the actor:                                                                                                                                                                                         
    kubectl ate create actor manual-test-1 --template=ate-demo-counter-microvm/counter-microvm -a demo                                                                                                         
    Output:                                                                                                                                                                                                    
    ATESPACE   NAME            TEMPLATE                                   STATUS             ATEOM POD   ATEOM IP   VERSION   AGE                                                                              
    demo       manual-test-1   ate-demo-counter-microvm/counter-microvm   STATUS_SUSPENDED   <none>                 1         0s                                                                               
                                                                                                                                                                                                               
  2. Resume the actor:                                                                                                                                                                                         
    kubectl ate resume actor manual-test-1 -a demo                                                                                                                                                             
    Output:                                                                                                                                                                                                    
    ATESPACE   NAME            TEMPLATE                                   STATUS           ATEOM POD                                                   ATEOM IP      VERSION   AGE                             
    demo       manual-test-1   ate-demo-counter-microvm/counter-microvm   STATUS_RUNNING   ate-demo-counter-microvm/counter-microvm-5474bcdfc5-bl47v   10.244.0.53   3         3s                              
                                                                                                                                                                                                               
  3. Force delete the running actor:                                                                                                                                                                           
    kubectl ate delete actor manual-test-1 -a demo --force                                                                                                                                                     
    Output:                                                                                                                                                                                                    
    actor "manual-test-1" deleted                                                                                                                                                                              
                                                                                                                                                                                                               
  ──────                                                                                                                                                                                                       
  ### Scenario 2: Standard Delete a Suspended Actor                                                                                                                                                            
                                                                                                                                                                                                               
  1. Create the actor:                                                                                                                                                                                         
    kubectl ate create actor manual-test-2 --template=ate-demo-counter-microvm/counter-microvm -a demo                                                                                                         
    Output:                                                                                                                                                                                                    
    ATESPACE   NAME            TEMPLATE                                   STATUS             ATEOM POD   ATEOM IP   VERSION   AGE                                                                              
    demo       manual-test-2   ate-demo-counter-microvm/counter-microvm   STATUS_SUSPENDED   <none>                 1         0s                                                                               
                                                                                                                                                                                                               
  2. Resume the actor:                                                                                                                                                                                         
    kubectl ate resume actor manual-test-2 -a demo                                                                                                                                                             
    Output:                                                                                                                                                                                                    
    ATESPACE   NAME            TEMPLATE                                   STATUS           ATEOM POD                                                   ATEOM IP      VERSION   AGE                             
    demo       manual-test-2   ate-demo-counter-microvm/counter-microvm   STATUS_RUNNING   ate-demo-counter-microvm/counter-microvm-5474bcdfc5-7kjbm   10.244.0.52   3         3s                              
                                                                                                                                                                                                               
  3. Suspend the actor:                                                                                                                                                                                        
    kubectl ate suspend actor manual-test-2 -a demo                                                                                                                                                            
    Output:                                                                                                                                                                                                    
    ATESPACE   NAME            TEMPLATE                                   STATUS             ATEOM POD   ATEOM IP   VERSION   AGE                                                                              
    demo       manual-test-2   ate-demo-counter-microvm/counter-microvm   STATUS_SUSPENDED   <none>                 5         6s                                                                               
                                                                                                                                                                                                               
  4. Standard delete the suspended actor:                                                                                                                                                                      
    kubectl ate delete actor manual-test-2 -a demo                                                                                                                                                             
    Output:                                                                                                                                                                                                    
    actor "manual-test-2" deleted    

Ran the E2E tests

   E2E_TEMPLATE_NAMESPACE=ate-demo-counter-microvm E2E_TEMPLATE_NAME=counter-microvm ./hack/run-e2e-kind.sh ./internal/e2e/suites/demo -run TestActorLifecycle 
PASS
Cleaning up 1 namespaces...
Deleting namespace bidt-043...
ok  	github.com/agent-substrate/substrate/internal/e2e/suites/demo	16.038s


### Newly added test
./hack/run-e2e-kind.sh ./internal/e2e/suites/demo -run TestForceDeleteActorWithExternalVolume 
=== RUN   TestForceDeleteActorWithExternalVolume
    demo_test.go:404: Creating namespace: eywz-363
    demo_test.go:1110: Waiting for ActorTemplate counter-ext-vol to be Ready...
    demo_test.go:1127: ActorTemplate counter-ext-vol is Ready with golden snapshot "2026-08-10t20-47-05z-27jtfttc6ph2wvic5xn3nvjyhq"
    demo_test.go:417: Creating Actor "force-delete-extvol-eywz-363"...
    demo_test.go:425: Waiting for Actor "force-delete-extvol-eywz-363" to be STATUS_SUSPENDED...
    demo_test.go:425: Actor "force-delete-extvol-eywz-363" reached status STATUS_SUSPENDED
    demo_test.go:427: Resuming Actor "force-delete-extvol-eywz-363"...
    demo_test.go:433: Waiting for Actor "force-delete-extvol-eywz-363" to be STATUS_RUNNING...
    demo_test.go:433: Actor "force-delete-extvol-eywz-363" reached status STATUS_RUNNING
    demo_test.go:452: Force deleting running Actor "force-delete-extvol-eywz-363"...
--- PASS: TestForceDeleteActorWithExternalVolume (3.60s)
PASS
Cleaning up 1 namespaces...
Deleting namespace eywz-363...
ok  	github.com/agent-substrate/substrate/internal/e2e/suites/demo	3.727s

  • Appropriate changes to documentation are included in the PR

Michelle Au (msau42) and others added 4 commits August 10, 2026 22:06
…actor.

This will bypass the requirement to suspend the workload cleanly before
deletion.

A new TERMINATING status is added to indicate that we're starting the
termination sequence. The termination sequence will call atelet to
terminate the workload. Once atelet termination succeeds, then we will
proceed with control plane termination sequence.

Finally we will move to the existing DELETING status to delete the
actor resources.
- Add E2E tests for force deletion of actors.
- Add --force flag to kubectl-ate delete command.
- Refactor DeleteActor workflow in controlapi to use the new plain-method (ensure-steps) style.
- Fix unit tests for the new DeleteActor workflow.

TAG=agy
CONV=ab017ff1-c93c-4fb4-a5d0-15bf24183e50
// already on disk for atelet to ship.
tTeardown := time.Now()
s.teardownActor(ctx, actorUID, ra, client)
s.terminateWorkload(ctx, actorUID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are not doing error checks here? the method returns err, what if termination fails

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method does not return any errors, I see TerminateWorkload does return error but there is actually no error reported in it's implementation, I think we can update the return value. I also checked other methods called by TerminateWorkload - teardownActor and that too doesn't seem to report any error

setSpanActorRefAttributes(ctx, actorRef)

deleted, err = s.actorWorkflow.DeleteActor(ctx, req.GetActor().GetAtespace(), req.GetActor().GetName())
deleted, err = s.actorWorkflow.DeleteActor(ctx, req.GetActor().GetAtespace(), req.GetActor().GetName(), req.GetForce())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we squash this to one commit

if s.actorTemplateLister != nil && actor.GetActorTemplateNamespace() != "" && actor.GetActorTemplateName() != "" {
tmpl, err := s.actorTemplateLister.ActorTemplates(actor.GetActorTemplateNamespace()).Get(actor.GetActorTemplateName())
if err != nil && !k8serrors.IsNotFound(err) {
return fmt.Errorf("while fetching actor template: %w", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to log info, warn in case actor template was not found?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ActorTemplate is first referred in ensureAteletTerminated where we return err if the template is nil. I suppose that should be alright as we do fail soon?

	if actorTemplate == nil {
		return status.Errorf(codes.FailedPrecondition, "actor template %s/%s not found for actor %s", actor.GetActorTemplateNamespace(), actor.GetActorTemplateName(), actorRef)
	}

ateapipb.Actor_STATUS_PAUSING,
ateapipb.Actor_STATUS_PAUSED,
ateapipb.Actor_STATUS_CRASHED,
ateapipb.Actor_STATUS_TERMINATING,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if its elsewhere in the code, do we log info on what the last state of the actor was it will help debug complex lifecycle issues if any

ateapipb.Actor_STATUS_SUSPENDED:
return nil
default:
return status.Errorf(codes.FailedPrecondition, "Actor %s is not in a deletable status (status: %v)", input.ActorRef, state.Actor.GetStatus())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case on non force scenario should this fail if actor is already in TERMINATING step. It could be a no-op with a log?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering a scenario where the Actor was deleted with force at T0 and then attempted again to be deleted without force at T1 where the gap between T0 and T1 is minimal we can run into a state where the request at T1 finds the Actor in TERMINATING state (as mentioned in the comment). If so I agree that it is a no-op. However, if the Actor is already in DELETING status then the current logic updates it to TERMINATING which is actually taking a step back (For force workflow X status -> TERMINATING -> DELETING). I think we should have a different workflow for DELETING status.

} else {
		switch st {
		case ateapipb.Actor_STATUS_SUSPENDED:
			// allowed
        case ateapipb.Actor_STATUS_DELETING:
             return actor, nil // return the same actor object we received as the function arg
		default:
			return nil, status.Errorf(codes.FailedPrecondition, "Actor %s is not in a deletable status (status: %v)", actorRef, st)
		}
	}

return nil
}

func (s *LoadActorForDeleteStep) RetryBackoff() *wait.Backoff { return nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a backoff here, why is this nil.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean we should have backoff for DeleteActor and retry on failure?

func (s *CallAteletTerminateStep) Name() string { return "CallAteletTerminate" }

func (s *CallAteletTerminateStep) IsComplete(ctx context.Context, input *DeleteInput, state *DeleteState) (bool, error) {
return state.Actor.GetStatus() == ateapipb.Actor_STATUS_DELETING, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a code path that needs to be still added here, we are not passing any non nil value of err ever, then we should never return err if it can never have non nil error

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please highlight which function are you referring to? I see we return some custom errors with return status.Errorf(.....)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh I see the comments are on the older version of the code. While rebasing the changes to latest main I realized there were some significant impact of Workflow lifecycle implementation so I had to re-write parts of the code. The code blocks this comments refers to no longer exists.

func (s *DetachVolumesForDeleteStep) Name() string { return "DetachVolumesForDelete" }

func (s *DetachVolumesForDeleteStep) IsComplete(ctx context.Context, input *DeleteInput, state *DeleteState) (bool, error) {
return state.Actor.GetStatus() == ateapipb.Actor_STATUS_DELETING, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same isComplete not using err here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, the code blocks this comments refers to no longer exists.


// TODO: can this be done inside releaseWorker()?
latestActor.LocalSnapshotInfo = nil
updatedActor, err := s.store.UpdateActor(ctx, latestActor, latestActor.GetMetadata().GetVersion())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so if update actor fails and release worker succeeds. Is release worker op idempotent, in a retry would release worker succeed if its already released?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the worker is released already from the actor the ensureWorkerReleased is just a no-op

	if actor.GetWorkerAssignment() == nil {
		markSkipped(ctx, "worker already released")
		return actor, nil
	}

slog.WarnContext(ctx, "Failed to deactivate actor networking during terminate", slog.Any("err", err))
}

ra := s.running[actorUID]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be guaranteed as running when the termination is called? what if it got suspended in interim

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The running variable is a bit confusing, it actually tracks the VMs the actorPod is running on and is not related to the state of the Actor.

f.FailTerminate = nil
}

func (f *FakeAteletServer) Terminate(ctx context.Context, req *ateletpb.TerminateRequest) (*ateletpb.TerminateResponse, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have some negative tests, where actor worker is unresponsive etc. Whether that is still able to force delete in those hang or failure conditions

ateapipb.Actor_STATUS_PAUSING,
ateapipb.Actor_STATUS_PAUSED,
ateapipb.Actor_STATUS_CRASHED,
ateapipb.Actor_STATUS_TERMINATING,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TERMINATING and DELETING state cannot be reached. in the case as earlier check already returns error

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Earlier check as in before the ensureMarkedTerminating function is called?

return status.Errorf(codes.FailedPrecondition, "CallAteletTerminate prerequisite not met for Actor: %s (got: %v, want %s)", actorRef, st, ateapipb.Actor_STATUS_TERMINATING)
}
if actorTemplate == nil {
return status.Errorf(codes.FailedPrecondition, "actor template %s/%s not found for actor %s", actor.GetActorTemplateNamespace(), actor.GetActorTemplateName(), actorRef)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would happen if the actor template goes missing? would this clean up the actor, or atleast not lead to stuckness?


conn, err := w.dialer.DialForWorker(workerPodNs, workerPodName)
if err != nil {
if errors.Is(err, ErrWorkerPodNotFound) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be deleting metadata / clean up the actor even when associated worker was not found?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the force case atleast?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So ignore atletTermination error when force deleting?

if err := w.ensureAteletTerminated(ctx, actorRef, actor, actorTemplate); err != nil {
		if force {
			slog.WarnContext(ctx, "ignoring atelet termination failure during force delete", "error", err)
		} else {
			return nil, err
		}
	}

	if err := w.ensureVolumesDetachedForDelete(ctx, actor, actorTemplate); err != nil {
		return nil, err
	}

	if actor, err = w.ensureWorkerReleased(ctx, actorRef, actor); err != nil {
		return nil, err
	}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recovery from failed resume, crash scenarios

3 participants