ateapi: store self-describing snapshots under stable paths

This commit is contained in:
Eitan Yarmush
2026-07-30 11:50:59 -07:00
committed by Haven Xia
parent ac3f08f7ad
commit 38ade556ee
6 changed files with 70 additions and 9 deletions
@@ -2049,7 +2049,7 @@ func TestSuspendActor(t *testing.T) {
LatestSnapshotInfo: &ateapipb.SnapshotInfo{
Data: &ateapipb.SnapshotInfo_External{
External: &ateapipb.ExternalSnapshotInfo{
SnapshotUriPrefix: fmt.Sprintf("gs://fake-fake-fake/%s/", name),
SnapshotUriPrefix: "gs://fake-fake-fake/snapshots/",
},
},
},
@@ -94,7 +94,7 @@ func (s *MarkSuspendingStep) CheckPrerequisite(ctx context.Context, input *Suspe
func (s *MarkSuspendingStep) Execute(ctx context.Context, input *SuspendInput, state *SuspendState) error {
state.Actor.Status = ateapipb.Actor_STATUS_SUSPENDING
snapshotID := time.Now().Format(time.RFC3339) + "-" + rand.Text()
state.Actor.InProgressSnapshot = strings.TrimSuffix(state.ActorTemplate.Spec.SnapshotsConfig.Location, "/") + "/" + input.ActorRef.Name + "/" + snapshotID
state.Actor.InProgressSnapshot = strings.TrimSuffix(state.ActorTemplate.Spec.SnapshotsConfig.Location, "/") + "/snapshots/" + snapshotID
updatedActor, err := s.store.UpdateActor(ctx, state.Actor, state.Actor.GetMetadata().GetVersion())
if err != nil {
return err
@@ -16,12 +16,14 @@ package controlapi
import (
"context"
"strings"
"testing"
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store/ateredis"
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store/storetest"
"github.com/agent-substrate/substrate/internal/resources"
atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
@@ -30,6 +32,36 @@ import (
"k8s.io/client-go/tools/cache"
)
func TestMarkSuspendingStep_SnapshotLocation(t *testing.T) {
ctx := context.Background()
persistence := newTestPersistence(t)
actor, err := persistence.CreateActor(ctx, &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: "team-a", Name: "actor-1"},
Status: ateapipb.Actor_STATUS_RUNNING,
})
if err != nil {
t.Fatalf("CreateActor: %v", err)
}
state := &SuspendState{
Actor: actor,
ActorTemplate: &atev1alpha1.ActorTemplate{Spec: atev1alpha1.ActorTemplateSpec{
SnapshotsConfig: atev1alpha1.SnapshotsConfig{Location: "gs://bucket/root/"},
}},
}
if err := (&MarkSuspendingStep{store: persistence}).Execute(ctx, &SuspendInput{ActorRef: resources.ActorRef{Atespace: "team-a", Name: "actor-1"}}, state); err != nil {
t.Fatalf("Execute: %v", err)
}
const prefix = "gs://bucket/root/snapshots/"
snapshotID, ok := strings.CutPrefix(state.Actor.GetInProgressSnapshot(), prefix)
if !ok {
t.Fatalf("snapshot location = %q, want prefix %q", state.Actor.GetInProgressSnapshot(), prefix)
}
if snapshotID == "" {
t.Fatal("snapshot ID is empty")
}
}
// TestSuspendActorWorkflow_RejectedAndIdempotentPaths covers the two
// short-circuit paths of the suspend workflow: rejection by
// MarkSuspendingStep's CheckPrerequisite and the IsComplete idempotent
+7 -4
View File
@@ -389,6 +389,11 @@ func (s *AteomHerder) Checkpoint(ctx context.Context, req *ateletpb.CheckpointRe
if len(sandboxRec.SnapshotFiles) == 0 {
return nil, ateerrors.NewGRPCError(ctx, codes.DataLoss, ateerrors.ReasonInvalidCheckpointResult, ateerrors.ActorCrashedMetadata(), errors.New("ateom reported no snapshot files for checkpoint"))
}
sandboxRec.Atespace = req.GetAtespace()
sandboxRec.ActorName = req.GetActorName()
sandboxRec.ActorUID = req.GetActorUid()
sandboxRec.ActorTemplateNamespace = req.GetActorTemplateNamespace()
sandboxRec.ActorTemplateName = req.GetActorTemplateName()
switch req.GetType() {
case ateletpb.CheckpointType_CHECKPOINT_TYPE_EXTERNAL:
@@ -442,8 +447,7 @@ func (s *AteomHerder) moveLocalCheckpoint(ctx context.Context, req *ateletpb.Che
}
}
// Pin the sandbox binaries + snapshot file list into a manifest beside the
// images so a later Restore is self-describing.
// Write the self-describing snapshot manifest beside the images.
manifest, err := json.Marshal(rec)
if err != nil {
return fmt.Errorf("while marshaling snapshot manifest: %w", err)
@@ -475,8 +479,7 @@ func (s *AteomHerder) uploadExternalCheckpoint(ctx context.Context, req *ateletp
return err
}
// Pin the sandbox binaries + snapshot file list into a manifest beside the
// images, written last, so a Restore on any node is self-describing.
// Write the self-describing snapshot manifest last.
manifest, err := json.Marshal(rec)
if err != nil {
return fmt.Errorf("while marshaling snapshot manifest: %w", err)
+20
View File
@@ -18,6 +18,7 @@ import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
@@ -37,6 +38,25 @@ import (
"google.golang.org/protobuf/testing/protocmp"
)
func TestSnapshotManifestActorMetadata(t *testing.T) {
rec := sandboxAssetsRecord{
Atespace: "team-a",
ActorName: "actor-1",
ActorUID: "actor-uid",
ActorTemplateNamespace: "templates",
ActorTemplateName: "agent",
}
got, err := json.Marshal(rec)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`"atespace":"team-a"`, `"actorName":"actor-1"`, `"actorUid":"actor-uid"`, `"actorTemplateNamespace":"templates"`, `"actorTemplateName":"agent"`} {
if !bytes.Contains(got, []byte(want)) {
t.Errorf("manifest %s missing %s", got, want)
}
}
}
func TestWriteFileAtomic(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "actor-id")
+9 -3
View File
@@ -36,9 +36,8 @@ import (
)
// sandboxManifestName is the object/file name of the per-snapshot manifest that
// records which sandbox binaries created a snapshot. It is written next to the
// checkpoint images (in the external object store, or the local checkpoint dir)
// so a Restore — possibly on another node — is self-describing.
// records the actor identity, snapshot files, and sandbox binaries. It is written
// next to the checkpoint images so a snapshot is self-describing.
const sandboxManifestName = "manifest.json"
// maxAssetBytes guards disk against an unbounded download URL; a var so tests can lower it.
@@ -59,6 +58,13 @@ type assetEntry struct {
type sandboxAssetsRecord struct {
SandboxClass string `json:"sandboxClass"`
Assets map[string]assetEntry `json:"assets"`
// Actor identity makes a flat snapshot self-identifying if control-plane
// persistence is unavailable.
Atespace string `json:"atespace,omitempty"`
ActorName string `json:"actorName,omitempty"`
ActorUID string `json:"actorUid,omitempty"`
ActorTemplateNamespace string `json:"actorTemplateNamespace,omitempty"`
ActorTemplateName string `json:"actorTemplateName,omitempty"`
// SnapshotFiles are the (relative) names of the files ateom wrote into the
// checkpoint directory, as reported by CheckpointWorkloadResponse. Recorded
// in the snapshot manifest so Restore ships/downloads exactly this set