mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-11-06 09:39:27 +08:00
Partially implements the proposed API changes in: https://docs.google.com/document/d/1lQ6fhrRb2sLCeFrvhsfytaoKVbA8X4w0TEwj84pr8uU/edit?tab=t.0 Depends on (and temporily includes) https://github.com/agent-substrate/substrate/pull/2222, review that one first. Part of #2069 This PR tries not to make major changes to existing semantics. They will be done as a followup. Other things in the proposal that are not implemented in this PR (tracked separately) - hardware information (https://github.com/agent-substrate/substrate/issues/2246) - sandbox information - combining pause/resume and introducing detach (https://github.com/agent-substrate/substrate/issues/798) #### Breaking change The Snapshots API has been completely rewritten and replaces - ActorStatus in_progress_snapshot_uri, external_snapshot, local_snapshot, in_progress_local_snapshot_name fields. - TagStatus snapshot, storage_location fields ---- - [ ] Tests pass - [ ] Appropriate changes to documentation are included in the PR
316 lines
14 KiB
Go
316 lines
14 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package controlapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
|
|
"github.com/agent-substrate/substrate/internal/apierror"
|
|
"github.com/agent-substrate/substrate/internal/ateattr"
|
|
"github.com/agent-substrate/substrate/internal/proto/ateletpb"
|
|
"github.com/agent-substrate/substrate/internal/resources"
|
|
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
)
|
|
|
|
// PauseActor executes the workflow to pause a running actor. Idempotent:
|
|
// a re-entered workflow fast-forwards past the steps a previous attempt
|
|
// completed, deriving progress from the persisted actor alone.
|
|
func (w *ActorWorkflow) PauseActor(ctx context.Context, actorRef resources.ActorRef) (_ *ateapipb.Actor, err error) {
|
|
start := time.Now()
|
|
var actor *ateapipb.Actor
|
|
var actorTemplate *ateapipb.ActorTemplate
|
|
var wireFidelity string
|
|
// Set just before finalize; nil until then, so earlier exits label
|
|
// themselves from the record they hold.
|
|
var finalAttrs []attribute.KeyValue
|
|
|
|
defer func() {
|
|
attrs := finalAttrs
|
|
if attrs == nil {
|
|
attrs = lifecycleOpAttrs(actor, actorTemplate, "", wireFidelity)
|
|
}
|
|
w.instruments.recordLifecycleOp(ctx, ateattr.OperationPause, start, err, attrs...)
|
|
}()
|
|
|
|
leaseCtx, lease, err := w.acquireActorLease(ctx, actorRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer lease.Close()
|
|
|
|
actor, actorTemplate, err = w.loadActorForPause(leaseCtx, actorRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if actor.GetStatus().GetState() == ateapipb.ActorState_ACTOR_STATE_PAUSED {
|
|
// Fully paused already: FinalizePaused commits PAUSED and the cleared
|
|
// worker assignment in a single update, so there is nothing left to do.
|
|
// This success reports no pool, and cannot: the previous attempt
|
|
// released the worker, so the record names none (#957).
|
|
return actor, nil
|
|
}
|
|
var marked *ateapipb.Actor
|
|
if marked, err = w.ensureMarkedPausing(leaseCtx, actorRef, actor, actorTemplate); err != nil {
|
|
return nil, err
|
|
}
|
|
actor = marked
|
|
if wireFidelity, err = w.ensureAteletPaused(leaseCtx, actorRef, actor, actorTemplate); err != nil {
|
|
return nil, err
|
|
}
|
|
// TODO: There is no difference between suspend and pause for now, but we
|
|
// could optimize pause by not detaching. We would need to make sure Resume
|
|
// is idempotent.
|
|
if err = w.ensureVolumesDetached(leaseCtx, actor, actorTemplate, "DetachVolumesForPause", ateattr.OperationPause); err != nil {
|
|
return nil, err
|
|
}
|
|
// FinalizePaused clears the WorkerAssignment the labels read, so snapshot
|
|
// them here, as crash.go does for the crash counter.
|
|
finalAttrs = lifecycleOpAttrs(actor, actorTemplate, "", wireFidelity)
|
|
var finalized *ateapipb.Actor
|
|
if finalized, err = w.ensurePausedFinalized(leaseCtx, actorRef); err != nil {
|
|
return nil, err
|
|
}
|
|
actor = finalized
|
|
return actor, nil
|
|
}
|
|
|
|
// loadActorForPause fetches the current actor record and its template.
|
|
func (w *ActorWorkflow) loadActorForPause(ctx context.Context, actorRef resources.ActorRef) (_ *ateapipb.Actor, _ *ateapipb.ActorTemplate, err error) {
|
|
ctx, done := stepSpan(ctx, "LoadActorForPause")
|
|
defer func() { err = done(err) }()
|
|
|
|
actor, err := w.store.GetActor(ctx, actorRef)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
actorTemplate, err := resolveActorTemplate(ctx, w.store, actor)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return actor, actorTemplate, nil
|
|
}
|
|
|
|
// ensureMarkedPausing transitions a RUNNING actor to PAUSING, minting the
|
|
// local snapshot name. Skips when a previous attempt already marked the
|
|
// actor; the persisted name then stays authoritative for the rest of the
|
|
// workflow.
|
|
func (w *ActorWorkflow) ensureMarkedPausing(ctx context.Context, actorRef resources.ActorRef, actor *ateapipb.Actor, actorTemplate *ateapipb.ActorTemplate) (_ *ateapipb.Actor, err error) {
|
|
ctx, done := stepSpan(ctx, "MarkPausing")
|
|
defer func() { err = done(err) }()
|
|
|
|
if actor.GetStatus().GetState() == ateapipb.ActorState_ACTOR_STATE_PAUSING {
|
|
markSkipped(ctx, "actor already PAUSING")
|
|
return actor, nil
|
|
}
|
|
// The pause edge only exists from RUNNING.
|
|
if actor.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_RUNNING {
|
|
return nil, apierror.FailedPrecondition("MarkPausing prerequisite not met for Actor: %s (got: %v, want %s)", actorRef, actor.GetStatus().GetState(), ateapipb.ActorState_ACTOR_STATE_RUNNING)
|
|
}
|
|
// By design a golden actor cannot be paused — it can only be suspended
|
|
// (committed).
|
|
if actorRef.Atespace == resources.GoldenActorAtespace {
|
|
return nil, apierror.FailedPrecondition("actors in atespace %q are golden actors, which cannot be paused", actorRef.Atespace)
|
|
}
|
|
|
|
snapshotName := resources.NewSnapshotName()
|
|
storedActor, err := w.store.UpdateActor(ctx, actorRef, store.PreconditionFrom(actor), func(toUpdate *ateapipb.Actor) error {
|
|
toUpdate.Status.State = ateapipb.ActorState_ACTOR_STATE_PAUSING
|
|
// Increment last_assigned_generation for the new Pause request.
|
|
gen := toUpdate.Status.LastAssignedGeneration + 1
|
|
toUpdate.Status.LastAssignedGeneration = gen
|
|
toUpdate.Status.Snapshots = append(toUpdate.Status.Snapshots,
|
|
newLocalSnapshot(
|
|
gen,
|
|
actorTemplate.GetSnapshotConfig().GetPreferredFidelity(),
|
|
actorTemplate.GetMetadata().GetUid(),
|
|
snapshotName,
|
|
ateapipb.SnapshotStorageStatus_SNAPSHOT_STORAGE_STATUS_IN_PROGRESS,
|
|
))
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrVersionConflict) {
|
|
return nil, apierror.Aborted("concurrent update conflict, please retry")
|
|
}
|
|
return nil, err
|
|
}
|
|
logActorStateChanged(ctx, storedActor, ateattr.OperationPause)
|
|
return storedActor, nil
|
|
}
|
|
|
|
// ensureAteletPaused checkpoints the workload locally on the worker node
|
|
// under the actor's persisted snapshot name. This is the atelet reentrancy
|
|
// seam (#372): the request is keyed by the actor UID, the worker pod UID, and
|
|
// the once-minted snapshot name, so a re-entered workflow re-sends the same
|
|
// semantic request; once atelet's Checkpoint is idempotent on those keys this
|
|
// step becomes fully reentrant with no changes here.
|
|
func (w *ActorWorkflow) ensureAteletPaused(ctx context.Context, actorRef resources.ActorRef, actor *ateapipb.Actor, actorTemplate *ateapipb.ActorTemplate) (wireFidelity string, err error) {
|
|
ctx, done := stepSpan(ctx, "CallAteletPause")
|
|
defer func() { err = done(err) }()
|
|
|
|
assignment := actor.GetStatus().GetWorkerAssignment()
|
|
if assignment == nil {
|
|
// Missing active worker pod reference in PAUSING state indicates corrupted store state.
|
|
if err := crashActor(ctx, w.store, actorRef, ateattr.OperationPause, crashMessageWorkerAssignmentMissing); err != nil {
|
|
slog.ErrorContext(ctx, "Failed to crash actor", slog.String("err", err.Error()))
|
|
}
|
|
return "", apierror.FailedPrecondition("CallAteletPause prerequisite not met for Actor: %s. No worker assignment", actorRef)
|
|
}
|
|
|
|
ateletConn, err := w.dialer.DialForAteletOnNode(assignment.GetNodeName())
|
|
if err != nil {
|
|
return "", fmt.Errorf("while getting atelet conn for node %q: %w", assignment.GetNodeName(), err)
|
|
}
|
|
client := ateletpb.NewAteomHerderClient(ateletConn)
|
|
|
|
workloadSpec, err := workloadSpecFromActorTemplate(actorTemplate, actor, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Checkpoint does not carry the sandbox config: atelet uses the version the
|
|
// actor is currently running (recorded on-node at Run/Restore) and pins it
|
|
// into the snapshot manifest.
|
|
_, inProgressLocal := findLatestSnapshotStorage(actor.GetStatus(), ateapipb.SnapshotDurability_SNAPSHOT_DURABILITY_LOCAL, ateapipb.SnapshotStorageStatus_SNAPSHOT_STORAGE_STATUS_IN_PROGRESS)
|
|
req := &ateletpb.CheckpointRequest{
|
|
TargetAteomUid: assignment.GetWorkerPodUid(),
|
|
Atespace: actor.GetMetadata().GetAtespace(),
|
|
ActorName: actor.GetMetadata().GetName(),
|
|
ActorTemplateAtespace: actor.GetActorTemplate().GetAtespace(),
|
|
ActorTemplateName: actor.GetActorTemplate().GetName(),
|
|
Spec: workloadSpec,
|
|
Type: ateletpb.CheckpointType_CHECKPOINT_TYPE_LOCAL,
|
|
Config: &ateletpb.CheckpointRequest_LocalConfig{
|
|
LocalConfig: &ateletpb.LocalCheckpointConfiguration{
|
|
SnapshotName: inProgressLocal.GetLocal().GetSnapshotName(),
|
|
},
|
|
},
|
|
Fidelity: fidelityToAtelet(actorTemplate.GetSnapshotConfig().GetPreferredFidelity()),
|
|
ActorUid: actor.GetMetadata().Uid,
|
|
}
|
|
wireFidelity = ateattr.SnapshotFidelityValue(req.Fidelity)
|
|
|
|
if _, err = client.Checkpoint(ctx, req); err != nil {
|
|
return wireFidelity, handleAteletError(ctx, w.store, actorRef, ateattr.OperationPause, "Checkpoint", false, err)
|
|
}
|
|
return wireFidelity, nil
|
|
}
|
|
|
|
// ensurePausedFinalized releases the actor's worker (only when it is still
|
|
// owned by this actor), records where the local snapshot lives, and commits
|
|
// PAUSED with the assignment cleared in a single update — or CRASHED when the
|
|
// worker's node name was lost, since a local snapshot on an unknown node can
|
|
// never be resumed. It re-reads the actor first so an out-of-band transition
|
|
// (e.g. the syncer crashing the actor after its worker died) is not
|
|
// overwritten: with no assignment left there is nothing to finalize.
|
|
func (w *ActorWorkflow) ensurePausedFinalized(ctx context.Context, actorRef resources.ActorRef) (_ *ateapipb.Actor, err error) {
|
|
ctx, done := stepSpan(ctx, "FinalizePaused")
|
|
defer func() { err = done(err) }()
|
|
|
|
latestActor, err := w.store.GetActor(ctx, actorRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 1. Free the worker (if it hasn't been freed yet)
|
|
if assignment := latestActor.GetStatus().GetWorkerAssignment(); assignment != nil {
|
|
worker, err := w.store.GetWorker(ctx, assignment.GetWorker().GetName())
|
|
if err != nil {
|
|
if !errors.Is(err, store.ErrNotFound) {
|
|
return nil, fmt.Errorf("while getting worker for release: %w", err)
|
|
}
|
|
slog.Warn("Worker already gone during finalize pause, skipping release", "worker", assignment.GetWorkerPod())
|
|
} else {
|
|
// Drop just this actor's assignment; any other actors the worker
|
|
// hosts keep theirs.
|
|
_, err := w.store.ReleaseActorFromWorker(ctx, worker.GetMetadata().GetName(), latestActor.GetMetadata().GetUid())
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrVersionConflict) {
|
|
return nil, apierror.Aborted("concurrent update conflict, please retry")
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// 2. Clear the actor's assignment, now that the worker is freed
|
|
latestActor, err = w.store.GetActor(ctx, actorRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
wasAlreadyCrashed := latestActor.GetStatus().GetState() == ateapipb.ActorState_ACTOR_STATE_CRASHED
|
|
newState := ateapipb.ActorState_ACTOR_STATE_PAUSED
|
|
var crashStatus *ateapipb.ActorCrash
|
|
if latestActor.GetStatus().GetAssignedNode() == "" {
|
|
// Without a node name we cannot record where the local snapshot lives,
|
|
// so the actor can never be resumed (the scheduler would search for a
|
|
// worker on an unknown node forever). Crash it instead of leaving it
|
|
// stuck in PAUSED.
|
|
slog.LogAttrs(ctx, slog.LevelError, "Node name not found during finalize pause, crashing actor",
|
|
ateattr.ActorRefLogAttrs(actorRef)...)
|
|
newState = ateapipb.ActorState_ACTOR_STATE_CRASHED
|
|
crashStatus = newActorCrash(ateattr.OperationPause, crashMessageLocalSnapshotNodeUnknown)
|
|
}
|
|
sandboxClass := ""
|
|
if worker != nil {
|
|
sandboxClass = worker.GetSandboxClass()
|
|
}
|
|
// Snapshot crash attributes before pod and pool pointers are cleared below.
|
|
latestActor.Status.State = newState
|
|
crashAttrs := ateattr.ActorMetricAttributes(latestActor, sandboxClass, ateattr.OperationPause)
|
|
|
|
storedActor, err := w.store.UpdateActor(ctx, actorRef, store.PreconditionFrom(latestActor), func(toUpdate *ateapipb.Actor) error {
|
|
toUpdate.Status.State = newState
|
|
if newState == ateapipb.ActorState_ACTOR_STATE_CRASHED && !wasAlreadyCrashed {
|
|
toUpdate.Status.Crash = crashStatus
|
|
}
|
|
if newState != ateapipb.ActorState_ACTOR_STATE_CRASHED {
|
|
// Finalize the snapshot and move it from IN_PROGRESS to COMPLETED.
|
|
snap := snapshotAtLatestGeneration(toUpdate.Status)
|
|
if snap == nil {
|
|
return fmt.Errorf("actor %s has no latest snapshot", actorRef)
|
|
}
|
|
if inProgressSt := findSnapshotStorage(snap, ateapipb.SnapshotDurability_SNAPSHOT_DURABILITY_LOCAL); inProgressSt.GetLocal().GetSnapshotName() != "" {
|
|
inProgressSt.Status = ateapipb.SnapshotStorageStatus_SNAPSHOT_STORAGE_STATUS_COMPLETED
|
|
removeOlderSnapshotStorageEntries(toUpdate.Status, ateapipb.SnapshotDurability_SNAPSHOT_DURABILITY_LOCAL, nil, snap.GetGeneration())
|
|
}
|
|
}
|
|
toUpdate.Status.WorkerAssignment = nil
|
|
return nil
|
|
})
|
|
if err == nil && storedActor.GetStatus().GetState() == ateapipb.ActorState_ACTOR_STATE_CRASHED && !wasAlreadyCrashed {
|
|
logActorCrashed(ctx, latestActor, ateattr.OperationPause)
|
|
recordActorCrash(ctx, crashAttrs)
|
|
}
|
|
if err == nil && storedActor.GetStatus().GetState() == ateapipb.ActorState_ACTOR_STATE_PAUSED {
|
|
logActorStateChanged(ctx, storedActor, ateattr.OperationPause)
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrVersionConflict) {
|
|
return nil, apierror.Aborted("concurrent update conflict, please retry")
|
|
}
|
|
return nil, err
|
|
}
|
|
latestActor = storedActor
|
|
}
|
|
|
|
return latestActor, nil
|
|
}
|