Skip to content

Commit 9edb7b1

Browse files
authored
fix(substrate): revert crashed actors instead of recreating them (#432)
Deleting and recreating a crashed actor discards its durable workspace and snapshot history. RevertActor returns it to SUSPENDED at its last snapshot; delete and recreate remains the fallback when the revert fails. Bumps agent-substrate/substrate to the commit that adds RevertActor.
1 parent d0bc38b commit 9edb7b1

4 files changed

Lines changed: 67 additions & 4 deletions

File tree

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.27.1
44

55
require (
66
github.com/agent-substrate/env v0.0.11-0.20260912052224-4468a200b170
7-
github.com/agent-substrate/substrate v0.0.0-20260911232748-672533541dbf
7+
github.com/agent-substrate/substrate v0.0.0-20260918201817-944abe3278b8
88
github.com/redis/go-redis/v9 v9.22.0
99
google.golang.org/grpc v1.83.2
1010
google.golang.org/protobuf v1.36.12

‎go.sum‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
github.com/agent-substrate/env v0.0.11-0.20260912052224-4468a200b170 h1:8AxhEx3BL7J8c+zqnYq5S3aH0J/4z85/JYWoo1dr6xY=
22
github.com/agent-substrate/env v0.0.11-0.20260912052224-4468a200b170/go.mod h1:RZ4ACmDqWQx7fgX9b1EaFJskeYTZh1Y7uZinDtp7U2o=
3-
github.com/agent-substrate/substrate v0.0.0-20260911232748-672533541dbf h1:FC9Ed75cCczI6unzgMe1Ss3J0mzX2GOiASSHBQnIl/E=
4-
github.com/agent-substrate/substrate v0.0.0-20260911232748-672533541dbf/go.mod h1:WBkGfDCbVFbtJTEMpIIbq59yEgko1L4WHJIEVGBdems=
3+
github.com/agent-substrate/substrate v0.0.0-20260918201817-944abe3278b8 h1:C39j2y88zDvg0VVrIfJqYejCjeHI25hugGubMeee74w=
4+
github.com/agent-substrate/substrate v0.0.0-20260918201817-944abe3278b8/go.mod h1:pWnhZpFkHq9VjF2OxsqEbryLnnUYmohhC62bFoindCM=
55
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
66
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
77
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=

‎internal/controller/reconciler_test.go‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ type mockControlServer struct {
4141
deletedActors []string
4242
actorTemplates map[string]bool
4343
deletedTemplates []string
44+
crashedActor string
45+
revertedActors []string
4446
}
4547

4648
// noSecrets is a SecretResolver for tests: it never finds a key and never touches a cluster.
@@ -79,6 +81,9 @@ func (m *mockControlServer) CreateActor(ctx context.Context, req *ateapipb.Creat
7981
if req.Actor != nil && req.Actor.Metadata != nil {
8082
name = req.Actor.Metadata.Name
8183
}
84+
if name == m.crashedActor {
85+
return nil, status.Error(codes.AlreadyExists, "actor exists")
86+
}
8287
m.createdActors = append(m.createdActors, name)
8388
return &ateapipb.Actor{
8489
Metadata: &ateapipb.ResourceMetadata{Name: name},
@@ -123,6 +128,22 @@ func (m *mockControlServer) SuspendActor(ctx context.Context, req *ateapipb.Susp
123128
}
124129

125130

131+
func (m *mockControlServer) GetActor(_ context.Context, req *ateapipb.GetActorRequest) (*ateapipb.Actor, error) {
132+
return &ateapipb.Actor{
133+
Metadata: &ateapipb.ResourceMetadata{Name: req.GetActor().GetName()},
134+
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_CRASHED},
135+
}, nil
136+
}
137+
138+
func (m *mockControlServer) RevertActor(_ context.Context, req *ateapipb.RevertActorRequest) (*ateapipb.RevertActorResponse, error) {
139+
name := req.GetActor().GetName()
140+
m.revertedActors = append(m.revertedActors, name)
141+
return &ateapipb.RevertActorResponse{Actor: &ateapipb.Actor{
142+
Metadata: &ateapipb.ResourceMetadata{Name: name},
143+
Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED},
144+
}}, nil
145+
}
146+
126147
func (m *mockControlServer) DeleteActor(ctx context.Context, req *ateapipb.DeleteActorRequest) (*ateapipb.Actor, error) {
127148
name := req.GetActor().GetName()
128149
m.deletedActors = append(m.deletedActors, name)
@@ -459,3 +480,34 @@ func TestReconcileDelete_RemovesActorAndTemplates(t *testing.T) {
459480
}
460481
}
461482
}
483+
484+
func TestEnsureActor_RevertsCrashedActor(t *testing.T) {
485+
lis, err := net.Listen("tcp", "127.0.0.1:0")
486+
if err != nil {
487+
t.Fatalf("failed to listen: %v", err)
488+
}
489+
defer lis.Close()
490+
491+
mockSrv := &mockControlServer{crashedActor: "job"}
492+
grpcServer := grpc.NewServer()
493+
ateapipb.RegisterControlServer(grpcServer, mockSrv)
494+
go grpcServer.Serve(lis)
495+
defer grpcServer.Stop()
496+
497+
client, err := substrate.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
498+
if err != nil {
499+
t.Fatalf("failed to create substrate client: %v", err)
500+
}
501+
defer client.Close()
502+
503+
actor, err := client.EnsureActor(context.Background(), "default", "job", "default", "job-tmpl-0a1b2c3d")
504+
if err != nil {
505+
t.Fatalf("EnsureActor failed: %v", err)
506+
}
507+
if got := actor.GetStatus().GetState(); got != ateapipb.ActorState_ACTOR_STATE_SUSPENDED {
508+
t.Errorf("actor state = %v, want SUSPENDED", got)
509+
}
510+
if len(mockSrv.revertedActors) != 1 || len(mockSrv.deletedActors) != 0 {
511+
t.Errorf("crashed actor: reverted %v, deleted %v; want reverted only", mockSrv.revertedActors, mockSrv.deletedActors)
512+
}
513+
}

‎internal/substrate/client.go‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,18 @@ func (c *Client) EnsureActor(ctx context.Context, atespace, actorName, templateA
329329
if getErr == nil && existing != nil {
330330
state := existing.GetStatus().GetState()
331331
if state == ateapipb.ActorState_ACTOR_STATE_CRASHED {
332-
slog.Warn("existing actor is crashed, deleting and recreating", "actor", actorName)
332+
// Revert to the last snapshot so the workspace survives the crash.
333+
reverted, revertErr := c.control.RevertActor(ctx, &ateapipb.RevertActorRequest{
334+
Actor: &ateapipb.ObjectRef{
335+
Atespace: atespace,
336+
Name: actorName,
337+
},
338+
})
339+
if revertErr == nil {
340+
slog.Warn("existing actor is crashed, reverted to its last snapshot", "actor", actorName)
341+
return reverted.GetActor(), nil
342+
}
343+
slog.Warn("existing actor is crashed and could not be reverted, deleting and recreating", "actor", actorName, "error", revertErr)
333344
_, _ = c.control.DeleteActor(ctx, &ateapipb.DeleteActorRequest{
334345
Actor: &ateapipb.ObjectRef{
335346
Atespace: atespace,

0 commit comments

Comments
 (0)