Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
420 changes: 212 additions & 208 deletions benchmarking/locust/common/ateapi_pb2.py

Large diffs are not rendered by default.

24 changes: 12 additions & 12 deletions cmd/ateapi/internal/controlapi/functionaltest/actor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1473,15 +1473,15 @@ type partialFailVolumePlugin struct {
deleted []string
}

func (f *partialFailVolumePlugin) CreateVolume(ctx context.Context, name, capacity, driverName string, parameters map[string]string) (string, map[string]string, error) {
if strings.HasSuffix(name, "fail-vol2") {
return "", nil, fmt.Errorf("simulated volume creation failure")
func (f *partialFailVolumePlugin) CreateVolume(ctx context.Context, req volume.CreateVolumeRequest) (volume.CreateVolumeResponse, error) {
if strings.HasSuffix(req.Name, "fail-vol2") {
return volume.CreateVolumeResponse{}, fmt.Errorf("simulated volume creation failure")
}
return "storage-" + name, parameters, nil
return volume.CreateVolumeResponse{VolumeID: "storage-" + req.Name, VolumeContext: req.Parameters}, nil
}

func (f *partialFailVolumePlugin) AttachVolume(ctx context.Context, volumeID, node string) error {
return nil
func (f *partialFailVolumePlugin) AttachVolume(ctx context.Context, req volume.AttachVolumeRequest) (volume.AttachVolumeResponse, error) {
return volume.AttachVolumeResponse{}, nil
}

func (f *partialFailVolumePlugin) DetachVolume(ctx context.Context, volumeID, node string) error {
Expand Down Expand Up @@ -1609,20 +1609,20 @@ type retrySuccessVolumePlugin struct {
deleted []string
}

func (r *retrySuccessVolumePlugin) CreateVolume(ctx context.Context, name, capacity, driverName string, parameters map[string]string) (string, map[string]string, error) {
func (r *retrySuccessVolumePlugin) CreateVolume(ctx context.Context, req volume.CreateVolumeRequest) (volume.CreateVolumeResponse, error) {
r.mu.Lock()
defer r.mu.Unlock()
if strings.HasSuffix(name, "retry-vol2") {
if strings.HasSuffix(req.Name, "retry-vol2") {
r.attempts++
if r.attempts == 1 {
return "", nil, fmt.Errorf("simulated temporary volume creation failure")
return volume.CreateVolumeResponse{}, fmt.Errorf("simulated temporary volume creation failure")
}
}
return "storage-" + name, parameters, nil
return volume.CreateVolumeResponse{VolumeID: "storage-" + req.Name, VolumeContext: req.Parameters}, nil
}

func (r *retrySuccessVolumePlugin) AttachVolume(ctx context.Context, volumeID, node string) error {
return nil
func (r *retrySuccessVolumePlugin) AttachVolume(ctx context.Context, req volume.AttachVolumeRequest) (volume.AttachVolumeResponse, error) {
return volume.AttachVolumeResponse{}, nil
}

func (r *retrySuccessVolumePlugin) DetachVolume(ctx context.Context, volumeID, node string) error {
Expand Down
11 changes: 8 additions & 3 deletions cmd/ateapi/internal/controlapi/volumes.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"log/slog"

"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
"github.com/agent-substrate/substrate/internal/volume"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
Expand Down Expand Up @@ -114,17 +115,21 @@ func createActorVolumes(ctx context.Context, registry VolumePluginRegistry, scLi
return resultVolumes, status.Errorf(codes.FailedPrecondition, "failed to get volume plugin for driver %q (StorageClass %q): %v", sc.Provisioner, scName, err)
}

storageVolumeID, volCtx, volErr := plugin.CreateVolume(ctx, actVolID, specVol.GetExternalVolumeTemplate().GetCapacity(), sc.Provisioner, sc.Parameters)
resp, volErr := plugin.CreateVolume(ctx, volume.CreateVolumeRequest{
Name: actVolID,
Capacity: specVol.GetExternalVolumeTemplate().GetCapacity(),
Parameters: sc.Parameters,
})
if volErr != nil {
return resultVolumes, status.Errorf(codes.Internal, "failed to create volume %q: %v", specVol.GetName(), volErr)
}

resultVolumes = append(resultVolumes, &ateapipb.ExternalVolume{
VolumeName: volName,
StorageVolumeId: storageVolumeID,
StorageVolumeId: resp.VolumeID,
VolumeType: sc.Provisioner,
Status: ateapipb.ExternalVolume_STATUS_CREATED,
VolumeContext: volCtx,
VolumeContext: resp.VolumeContext,
})
}
return resultVolumes, nil
Expand Down
2 changes: 1 addition & 1 deletion cmd/ateapi/internal/controlapi/workflow_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func (w *ActorWorkflow) ensureAteletTerminated(ctx context.Context, actorRef res

var workloadSpec *ateletpb.WorkloadSpec
if actorTemplate != nil {
spec, err := workloadSpecFromActorTemplate(actorTemplate, actor)
spec, err := workloadSpecFromActorTemplate(actorTemplate, actor, "")
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/ateapi/internal/controlapi/workflow_pause.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ func (w *ActorWorkflow) ensureAteletPaused(ctx context.Context, actorRef resourc
}
client := ateletpb.NewAteomHerderClient(ateletConn)

workloadSpec, err := workloadSpecFromActorTemplate(actorTemplate, actor)
workloadSpec, err := workloadSpecFromActorTemplate(actorTemplate, actor, "")
if err != nil {
return "", err
}
Expand Down
49 changes: 37 additions & 12 deletions cmd/ateapi/internal/controlapi/workflow_resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"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/internal/volume"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
Expand Down Expand Up @@ -121,10 +122,12 @@ func (w *ActorWorkflow) ResumeActor(ctx context.Context, actorRef resources.Acto
return nil, false, err
}
actor = assigned
if err = w.ensureVolumesAttached(leaseCtx, actor, worker, actorTemplate); err != nil {
var attached *ateapipb.Actor
if attached, err = w.ensureVolumesAttached(leaseCtx, actorRef, actor, worker, actorTemplate); err != nil {
return nil, false, err
}
if tele, err = w.ensureAteletRestored(leaseCtx, actorRef, actor, actorTemplate, src); err != nil {
actor = attached
if tele, err = w.ensureAteletRestored(leaseCtx, actorRef, actor, actorTemplate, worker.GetNodeName(), src); err != nil {
return nil, false, err
}
var running *ateapipb.Actor
Expand Down Expand Up @@ -613,30 +616,52 @@ func schedulingConstraints(actor *ateapipb.Actor, tmpl *ateapipb.ActorTemplate)
}

// ensureVolumesAttached attaches the actor's mounted external volumes to the
// assigned worker's node. Attachment is idempotent, so a re-entered workflow
// safely runs it again.
// assigned worker's node and records the driver's attachment metadata.
// TODO replace re-execution with a proper check on the volumes' attach state.
func (w *ActorWorkflow) ensureVolumesAttached(ctx context.Context, actor *ateapipb.Actor, worker *ateapipb.Worker, actorTemplate *ateapipb.ActorTemplate) (err error) {
func (w *ActorWorkflow) ensureVolumesAttached(ctx context.Context, actorRef resources.ActorRef, actor *ateapipb.Actor, worker *ateapipb.Worker, actorTemplate *ateapipb.ActorTemplate) (_ *ateapipb.Actor, err error) {
ctx, done := stepSpan(ctx, "AttachVolumes")
defer func() { err = done(err) }()

node := worker.GetNodeName()
if node == "" {
return fmt.Errorf("assigned worker has no node name")
return nil, fmt.Errorf("assigned worker has no node name")
}

ref := &ateapipb.ObjectRef{Atespace: actor.GetMetadata().GetAtespace(), Name: actor.GetMetadata().GetName()}
attached := make(map[string]*ateapipb.ExternalVolume)
for _, vol := range getMountedActorVolumes(ctx, ref, actor.GetStatus().GetActorVolumes(), actorTemplate) {
slog.InfoContext(ctx, "Attaching volume to node", slog.String("volume_id", vol.GetStorageVolumeId()), slog.String("node", node))
plugin, err := w.pluginRegistry.GetPlugin(ctx, vol.GetVolumeType())
if err != nil {
return fmt.Errorf("failed to get volume plugin for %q: %w", vol.GetVolumeType(), err)
return nil, fmt.Errorf("failed to get volume plugin for %q: %w", vol.GetVolumeType(), err)
}
if err := plugin.AttachVolume(ctx, vol.GetStorageVolumeId(), node); err != nil {
return fmt.Errorf("failed to attach volume %q to node %q: %w", vol.GetStorageVolumeId(), node, err)

resp, err := plugin.AttachVolume(ctx, volume.AttachVolumeRequest{VolumeID: vol.GetStorageVolumeId(), Node: node})
if err != nil {
return nil, fmt.Errorf("failed to attach volume %q to node %q: %w", vol.GetStorageVolumeId(), node, err)
}
vol.PublishContext = resp.PublishContext
vol.PublishContextNode = node
attached[vol.GetVolumeName()] = vol

}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should we skip the DB update if there are no external volumes to be attached? i.e len(attached) == 0

@anishgang anishgang Sep 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I am currently doing this. In ensuredVolumesAttached, if len(mountedVols) == 0 { return actor, nil } to immediately bypass the store update. Also, I am checking !maps.Equal(vol.GetPublishContext(), pubCtx) so we also skip the DB write if the volume is already attached to that node. Answered in #1729

storedActor, updateErr := w.store.UpdateActor(ctx, actorRef, store.PreconditionFrom(actor), func(toUpdate *ateapipb.Actor) error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

How do we use this publish context information stored for a volume? I do not see a DB update on volume detach, if we plan to use the DB details to get the current publish context for the actor volume then it might not have the right details. Though, if the details are checked only for a running actor then implicitly the actor volumes are already attached and thus the DB has correct up-to-date information.

@anishgang anishgang Sep 22, 2026

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 use PublishContext to tell the worker where the attached disk is located (i.e /dev/xvdf) so it can mount it. On detach, we now explicitly wipe it from the database (PublishContext = nil). When an actor resumes, we always attach the disk and refresh this in the DB before launching the container. Answered in #1729

for _, vol := range toUpdate.GetStatus().GetActorVolumes() {
if a, ok := attached[vol.GetVolumeName()]; ok {
vol.PublishContext = a.GetPublishContext()
vol.PublishContextNode = a.GetPublishContextNode()
}
}
return nil
})
if updateErr != nil {
if errors.Is(updateErr, store.ErrVersionConflict) {
return nil, status.Error(codes.Aborted, "concurrent update conflict, please retry")
}
return nil, fmt.Errorf("while updating actor after volume attach: %w", updateErr)
}
return nil
return storedActor, nil
}

// ensureAteletRestored brings the workload up on the assigned worker:
Expand All @@ -646,7 +671,7 @@ func (w *ActorWorkflow) ensureVolumesAttached(ctx context.Context, actor *ateapi
// the worker pod UID, so a re-entered workflow re-sends the same semantic
// request; once atelet's Restore/Run are idempotent on those keys this step
// becomes fully reentrant with no changes here.
func (w *ActorWorkflow) ensureAteletRestored(ctx context.Context, actorRef resources.ActorRef, actor *ateapipb.Actor, actorTemplate *ateapipb.ActorTemplate, src resumeSnapshotSource) (tele restoreTelemetry, err error) {
func (w *ActorWorkflow) ensureAteletRestored(ctx context.Context, actorRef resources.ActorRef, actor *ateapipb.Actor, actorTemplate *ateapipb.ActorTemplate, node string, src resumeSnapshotSource) (tele restoreTelemetry, err error) {
ctx, done := stepSpan(ctx, "CallAteletRestore")
defer func() { err = done(err) }()

Expand All @@ -657,7 +682,7 @@ func (w *ActorWorkflow) ensureAteletRestored(ctx context.Context, actorRef resou
}
client := ateletpb.NewAteomHerderClient(ateletConn)

workloadSpec, err := workloadSpecFromActorTemplate(actorTemplate, actor)
workloadSpec, err := workloadSpecFromActorTemplate(actorTemplate, actor, node)
if err != nil {
return tele, err
}
Expand Down
85 changes: 85 additions & 0 deletions cmd/ateapi/internal/controlapi/workflow_resume_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ import (
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store/storetest"
"github.com/agent-substrate/substrate/cmd/ateapi/internal/workercache"
"github.com/agent-substrate/substrate/internal/resources"
"github.com/agent-substrate/substrate/internal/volume"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"github.com/google/go-cmp/cmp"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/testing/protocmp"
)

// TestSchedulerRecordable guards the retry-dedup rule: the assignment loop
Expand Down Expand Up @@ -1175,3 +1178,85 @@ func TestLoadActorForResume_RunningActorShortCircuits(t *testing.T) {
t.Errorf("expected empty snapshot source, got %+v", src)
}
}

type volumeUpdateStore struct {
store.Interface
}

func (s *volumeUpdateStore) UpdateActor(ctx context.Context, actorRef resources.ActorRef, precondition store.Precondition, mutate func(*ateapipb.Actor) error) (*ateapipb.Actor, error) {
return s.Interface.UpdateActor(ctx, actorRef, precondition, mutate)
}

// publishContextVolumePlugin hands back a fixed publish context, standing in
// for a driver whose node plugin needs attachment metadata.
type publishContextVolumePlugin struct {
volume.VolumePluginControlPlane
publishContext map[string]string
}

func (p *publishContextVolumePlugin) AttachVolume(ctx context.Context, req volume.AttachVolumeRequest) (volume.AttachVolumeResponse, error) {
return volume.AttachVolumeResponse{PublishContext: p.publishContext}, nil
}

func TestEnsureVolumesAttached_PersistsPublishContext(t *testing.T) {
ctx := context.Background()
persistence := newTestPersistence(t)
actorRef := resources.ActorRef{Atespace: "team-a", Name: "id1"}
storetest.MustCreateActor(t, ctx, persistence, &ateapipb.Actor{
Metadata: &ateapipb.ResourceMetadata{Atespace: "team-a", Name: "id1"},
Status: &ateapipb.ActorStatus{
State: ateapipb.ActorState_ACTOR_STATE_RESUMING,
ActorVolumes: []*ateapipb.ExternalVolume{
{VolumeName: "mounted", StorageVolumeId: "storage-mounted", VolumeType: "mock", Status: ateapipb.ExternalVolume_STATUS_CREATED},
{VolumeName: "unmounted", StorageVolumeId: "storage-unmounted", VolumeType: "mock", Status: ateapipb.ExternalVolume_STATUS_CREATED},
},
},
})
actor, err := persistence.GetActor(ctx, actorRef)
if err != nil {
t.Fatalf("GetActor: %v", err)
}

st := &volumeUpdateStore{Interface: persistence}
w := &ActorWorkflow{
store: st,
pluginRegistry: &mockPluginRegistry{plugins: map[string]volume.VolumePluginControlPlane{
"mock": &publishContextVolumePlugin{publishContext: map[string]string{"devicePath": "/dev/xvdba"}},
}},
}
worker := &ateapipb.Worker{NodeName: "node-1"}
tmpl := &ateapipb.ActorTemplate{
Volumes: []*ateapipb.Volume{
{Name: "mounted", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{StorageClassName: "sc"}},
{Name: "unmounted", ExternalVolumeTemplate: &ateapipb.ExternalVolumeTemplate{StorageClassName: "sc"}},
},
Containers: []*ateapipb.Container{
{Name: "main", Image: "img", VolumeMounts: []*ateapipb.VolumeMount{{Name: "mounted", MountPath: "/data"}}},
},
}

want := []*ateapipb.ExternalVolume{
{
VolumeName: "mounted",
StorageVolumeId: "storage-mounted",
VolumeType: "mock",
Status: ateapipb.ExternalVolume_STATUS_CREATED,
PublishContext: map[string]string{"devicePath": "/dev/xvdba"},
PublishContextNode: "node-1",
},
{
VolumeName: "unmounted",
StorageVolumeId: "storage-unmounted",
VolumeType: "mock",
Status: ateapipb.ExternalVolume_STATUS_CREATED,
},
}
got, err := w.ensureVolumesAttached(ctx, actorRef, actor, worker, tmpl)
if err != nil {
t.Fatalf("ensureVolumesAttached: %v", err)
}

if diff := cmp.Diff(want, got.GetStatus().GetActorVolumes(), protocmp.Transform()); diff != "" {
t.Errorf("actor volumes mismatch (-want +got):\n%s", diff)
}
}
2 changes: 1 addition & 1 deletion cmd/ateapi/internal/controlapi/workflow_suspend.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ func (w *ActorWorkflow) ensureAteletSuspended(ctx context.Context, actorRef reso
}
client := ateletpb.NewAteomHerderClient(ateletConn)

workloadSpec, err := workloadSpecFromActorTemplate(actorTemplate, actor)
workloadSpec, err := workloadSpecFromActorTemplate(actorTemplate, actor, "")
if err != nil {
return "", err
}
Expand Down
11 changes: 8 additions & 3 deletions cmd/ateapi/internal/controlapi/workload_spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func toAteletResources(r *ateapipb.Resources) (*ateletpb.ResourceLimits, error)

// workloadSpecFromActorTemplate builds a WorkloadSpec from the template;
// container env is copied verbatim.
func workloadSpecFromActorTemplate(actorTemplate *ateapipb.ActorTemplate, actor *ateapipb.Actor) (*ateletpb.WorkloadSpec, error) {
func workloadSpecFromActorTemplate(actorTemplate *ateapipb.ActorTemplate, actor *ateapipb.Actor, node string) (*ateletpb.WorkloadSpec, error) {
workloadSpec := &ateletpb.WorkloadSpec{}

// Convert volumes to atelet's representation. ActorTemplate validation has
Expand Down Expand Up @@ -119,7 +119,7 @@ func workloadSpecFromActorTemplate(actorTemplate *ateapipb.ActorTemplate, actor

// TODO: order may be important for nested mounts. Also need to think about
// nested mount support in general.
if err := appendExternalVolumes(workloadSpec, actorTemplate, actor); err != nil {
if err := appendExternalVolumes(workloadSpec, actorTemplate, actor, node); err != nil {
return nil, err
}

Expand Down Expand Up @@ -157,7 +157,7 @@ func workloadSpecFromActorTemplate(actorTemplate *ateapipb.ActorTemplate, actor

// appendExternalVolumes maps template external volumes to resolved actor volumes and appends them to workloadSpec
// if they are referenced in container volumeMounts.
func appendExternalVolumes(workloadSpec *ateletpb.WorkloadSpec, template *ateapipb.ActorTemplate, actor *ateapipb.Actor) error {
func appendExternalVolumes(workloadSpec *ateletpb.WorkloadSpec, template *ateapipb.ActorTemplate, actor *ateapipb.Actor, node string) error {
if template == nil {
return nil
}
Expand All @@ -173,11 +173,15 @@ func appendExternalVolumes(workloadSpec *ateletpb.WorkloadSpec, template *ateapi
var storageVolID string
var volType string
var volCtx map[string]string
var publishCtx map[string]string
for _, dbVol := range actor.GetStatus().GetActorVolumes() {
if dbVol.GetVolumeName() == vol.GetName() {
storageVolID = dbVol.GetStorageVolumeId()
volType = dbVol.GetVolumeType()
volCtx = dbVol.GetVolumeContext()
if dbVol.GetPublishContextNode() == node {
publishCtx = dbVol.GetPublishContext()
}
break
}
}
Expand All @@ -191,6 +195,7 @@ func appendExternalVolumes(workloadSpec *ateletpb.WorkloadSpec, template *ateapi
StorageVolumeId: storageVolID,
VolumeType: volType,
VolumeContext: volCtx,
PublishContext: publishCtx,
},
},
})
Expand Down
Loading
Loading