Skip to content

Commit 8f88011

Browse files
committed
Fix toctou race in UpdateActor
UpdateActor now takes an ActorRef, an ActorPrecondition, and a mutate callback. The store reads the stored actor inside the WATCH transaction, checks the precondition against that value, and hands it to mutate, which edits it in place. The storage layer retries up to 5 times when a concurrent write invalidates it, re-running mutate against the newer state I still need to migrate the other callers of store.UpdateActor to avoid calling GetActor outside of the closure function.
1 parent 231a9ef commit 8f88011

13 files changed

Lines changed: 787 additions & 196 deletions

cmd/ateapi/internal/controlapi/crash.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,16 @@ func crashActor(ctx context.Context, st store.Interface, actorRef resources.Acto
7575
// the counter itself is emitted only after the transition commits.
7676
crashAttrs := ateattr.ActorMetricAttributes(actor, sandboxClass, opName, reason)
7777

78-
actor.Status = ateapipb.Actor_STATUS_CRASHED
79-
80-
// InProgressSnapshotName and InProgressLocalSnapshotName are kept for
81-
// debugging; failed workflow steps must never promote either of them to an
82-
// ActorSnapshot or to LocalSnapshotInfo.
83-
actor.WorkerAssignment = nil
84-
85-
_, err = st.UpdateActor(ctx, actor, actor.GetMetadata().GetVersion())
78+
precondition := store.WithActorPrecondition(actor.GetMetadata().GetUid(), actor.GetMetadata().GetVersion())
79+
_, err = st.UpdateActor(ctx, actorRef, precondition, func(dbActor *ateapipb.Actor) error {
80+
dbActor.Status = ateapipb.Actor_STATUS_CRASHED
81+
82+
// InProgressSnapshotName and InProgressLocalSnapshotName are kept for
83+
// debugging; failed workflow steps must never promote either of them to an
84+
// ActorSnapshot or to LocalSnapshotInfo.
85+
dbActor.WorkerAssignment = nil
86+
return nil
87+
})
8688
if err != nil {
8789
errCollected = append(errCollected, fmt.Errorf("while marking actor crashed: %w", err))
8890
return errors.Join(errCollected...)

cmd/ateapi/internal/controlapi/functional_test.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"testing"
2727
"time"
2828

29+
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
2930
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store/ateredis"
3031
"github.com/agent-substrate/substrate/cmd/ateapi/internal/workercache"
3132
"github.com/agent-substrate/substrate/internal/ateinterceptors"
@@ -2447,7 +2448,7 @@ func TestUpdateActor_Preconditions(t *testing.T) {
24472448
// The uid from the deleted lifecycle must be rejected, even though the
24482449
// atespace/name it was observed under still resolves.
24492450
_, err := update(&ateapipb.ResourceMetadata{Uid: staleUID}, "other-lifecycle")
2450-
assertGrpcError(t, err, codes.Aborted, fmt.Sprintf("Actor %s/%s has uid %s, not %s", testAtespace, testActorID, uid, staleUID))
2451+
assertGrpcError(t, err, codes.Aborted, fmt.Sprintf("actor %s/%s not found with uid %s", testAtespace, testActorID, staleUID))
24512452

24522453
// An unguarded update is last-writer-wins, and moves the resource past the
24532454
// version observed above.
@@ -2494,7 +2495,7 @@ func TestUpdateActor_NotFound(t *testing.T) {
24942495
Actor: &ateapipb.Actor{Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "does-not-exist"}},
24952496
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"worker_selector"}},
24962497
})
2497-
assertGrpcError(t, err, codes.NotFound, "Actor test-atespace/does-not-exist not found")
2498+
assertGrpcError(t, err, codes.NotFound, "actor test-atespace/does-not-exist not found")
24982499
}
24992500

25002501
func TestUpdateActorSnapshotTag_Success(t *testing.T) {
@@ -3287,7 +3288,7 @@ func TestDeleteActor_Crashed(t *testing.T) {
32873288

32883289
createTemplate(t, tc, ns)
32893290

3290-
_, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
3291+
actor, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
32913292
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: "id1"},
32923293
ActorTemplateNamespace: ns,
32933294
ActorTemplateName: "tmpl1",
@@ -3296,12 +3297,12 @@ func TestDeleteActor_Crashed(t *testing.T) {
32963297
t.Fatalf("CreateActor failed: %v", err)
32973298
}
32983299

3299-
actor, err := tc.persistence.GetActor(context.Background(), resources.ActorRef{Atespace: testAtespace, Name: "id1"})
3300-
if err != nil {
3301-
t.Fatalf("GetActor failed: %v", err)
3302-
}
3303-
actor.Status = ateapipb.Actor_STATUS_CRASHED
3304-
if _, err := tc.persistence.UpdateActor(context.Background(), actor, actor.GetMetadata().GetVersion()); err != nil {
3300+
actorRef := resources.ActorRef{Atespace: testAtespace, Name: "id1"}
3301+
precondition := store.WithActorPrecondition(actor.GetMetadata().Uid, actor.GetMetadata().GetVersion())
3302+
if _, err := tc.persistence.UpdateActor(context.Background(), actorRef, precondition, func(dbActor *ateapipb.Actor) error {
3303+
dbActor.Status = ateapipb.Actor_STATUS_CRASHED
3304+
return nil
3305+
}); err != nil {
33053306
t.Fatalf("UpdateActor failed: %v", err)
33063307
}
33073308

cmd/ateapi/internal/controlapi/syncer.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -383,14 +383,16 @@ func (s *WorkerPoolSyncer) releaseActorOnDeadWorker(ctx context.Context, namespa
383383
// Snapshot crash attributes before pod and pool pointers are cleared on actor.
384384
crashAttrs := ateattr.ActorMetricAttributes(actor, worker.GetSandboxClass(), opName, ateattr.ReasonWorkerPodGone)
385385

386-
actor.Status = ateapipb.Actor_STATUS_CRASHED
387-
actor.WorkerAssignment = nil
388-
// Both in-progress checkpoints die with the worker: the durable one was
389-
// never uploaded, the local one lived on the node that went away.
390-
actor.InProgressSnapshotName = ""
391-
actor.InProgressLocalSnapshotName = ""
392-
393-
_, err = s.persistence.UpdateActor(ctx, actor, actor.GetMetadata().GetVersion())
386+
precondition := store.WithActorPrecondition(actor.GetMetadata().GetUid(), actor.GetMetadata().GetVersion())
387+
_, err = s.persistence.UpdateActor(ctx, actorRef, precondition, func(dbActor *ateapipb.Actor) error {
388+
dbActor.Status = ateapipb.Actor_STATUS_CRASHED
389+
dbActor.WorkerAssignment = nil
390+
// Both in-progress checkpoints die with the worker: the durable one was
391+
// never uploaded, the local one lived on the node that went away.
392+
dbActor.InProgressSnapshotName = ""
393+
dbActor.InProgressLocalSnapshotName = ""
394+
return nil
395+
})
394396

395397
if err == nil && !wasAlreadyCrashed {
396398
recordActorCrash(ctx, crashAttrs)

cmd/ateapi/internal/controlapi/update_actor.go

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -41,31 +41,21 @@ func (s *Service) UpdateActor(ctx context.Context, req *ateapipb.UpdateActorRequ
4141
actorRef := resources.ActorRefFromActor(in)
4242
setSpanActorRefAttributes(ctx, actorRef)
4343

44-
actor, err := s.persistence.GetActor(ctx, actorRef)
45-
if err != nil {
46-
if errors.Is(err, store.ErrNotFound) {
47-
return nil, status.Errorf(codes.NotFound, "Actor %s not found", actorRef)
48-
}
49-
return nil, fmt.Errorf("while getting actor: %w", err)
50-
}
51-
52-
// UID and version preconditions
53-
if uid := in.GetMetadata().GetUid(); uid != "" && uid != actor.GetMetadata().GetUid() {
54-
return nil, status.Errorf(codes.Aborted, "Actor %s has uid %s, not %s", actorRef, actor.GetMetadata().GetUid(), uid)
55-
}
56-
57-
expectedVersion := actor.GetMetadata().GetVersion()
58-
if version := in.GetMetadata().GetVersion(); version != 0 {
59-
expectedVersion = version
60-
}
61-
62-
applyUpdateMask(actor, in, req.GetUpdateMask(), actorMutableFields)
63-
64-
updated, err := s.persistence.UpdateActor(ctx, actor, expectedVersion)
44+
precondition := store.WithActorPrecondition(in.GetMetadata().GetUid(), in.GetMetadata().GetVersion())
45+
updated, err := s.persistence.UpdateActor(ctx, actorRef, precondition, func(dbActor *ateapipb.Actor) error {
46+
applyUpdateMask(dbActor, in, req.GetUpdateMask(), actorMutableFields)
47+
return nil
48+
})
6549
if err != nil {
6650
if errors.Is(err, store.ErrVersionConflict) {
6751
return nil, status.Error(codes.Aborted, "concurrent update conflict, please retry")
6852
}
53+
if errors.Is(err, store.ErrUIDConflict) {
54+
return nil, status.Errorf(codes.Aborted, "actor %s/%s not found with uid %s", in.GetMetadata().GetAtespace(), in.GetMetadata().GetName(), in.GetMetadata().GetUid())
55+
}
56+
if errors.Is(err, store.ErrNotFound) {
57+
return nil, status.Errorf(codes.NotFound, "actor %s not found", actorRef)
58+
}
6959
return nil, fmt.Errorf("while updating actor: %w", err)
7060
}
7161

cmd/ateapi/internal/controlapi/update_actor_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ import (
2424
"google.golang.org/protobuf/types/known/fieldmaskpb"
2525
"k8s.io/apimachinery/pkg/util/validation/field"
2626

27+
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
2728
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store/storetest"
2829
"github.com/agent-substrate/substrate/internal/ateattr"
30+
"github.com/agent-substrate/substrate/internal/resources"
2931
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
3032
)
3133

@@ -218,6 +220,152 @@ func TestUpdateActor_FailedLookupStampsRefIdentityOnly(t *testing.T) {
218220
}
219221
}
220222

223+
// TestUpdateActor_DeleteRecreateRace checks that an update is not applied
224+
// if an actor was deleted and recreated during the update operation.
225+
func TestUpdateActor_DeleteRecreateRace(t *testing.T) {
226+
ctx := context.Background()
227+
persistence, cleanup := storetest.SetupTestStore(t)
228+
t.Cleanup(cleanup)
229+
230+
actorRef := resources.ActorRef{Atespace: testAtespace, Name: testActorID}
231+
232+
// Actor A: what the client reads, and what its uid precondition names.
233+
// Freshly created, so it sits at version 1.
234+
original, err := persistence.CreateActor(ctx, &ateapipb.Actor{
235+
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID},
236+
ActorTemplateNamespace: "ns1",
237+
ActorTemplateName: "tmpl1",
238+
Status: ateapipb.Actor_STATUS_RUNNING,
239+
WorkerAssignment: &ateapipb.WorkerAssignment{WorkerPod: "pod-a"},
240+
})
241+
if err != nil {
242+
t.Fatalf("seed CreateActor: %v", err)
243+
}
244+
245+
// A concurrent client deletes A and recreates the same atespace/name as a
246+
// brand new actor B, in the window the handler used to leave open between
247+
// its own read and the store's WATCH.
248+
var recreated *ateapipb.Actor
249+
racing := &conflictInjectingStore{
250+
Interface: persistence,
251+
inject: func() {
252+
if _, err := persistence.UpdateActor(ctx, actorRef, store.WithActorPrecondition(store.AnyUID, store.AnyVersion), func(dbActor *ateapipb.Actor) error {
253+
dbActor.Status = ateapipb.Actor_STATUS_DELETING
254+
return nil
255+
}); err != nil {
256+
t.Fatalf("racing writer: mark deleting: %v", err)
257+
}
258+
if _, err := persistence.DeleteActor(ctx, actorRef); err != nil {
259+
t.Fatalf("racing writer: DeleteActor: %v", err)
260+
}
261+
recreated, err = persistence.CreateActor(ctx, &ateapipb.Actor{
262+
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID},
263+
ActorTemplateNamespace: "ns1",
264+
ActorTemplateName: "tmpl1",
265+
Status: ateapipb.Actor_STATUS_SUSPENDED,
266+
})
267+
if err != nil {
268+
t.Fatalf("racing writer: recreate CreateActor: %v", err)
269+
}
270+
},
271+
}
272+
svc := &Service{persistence: racing}
273+
274+
// The client asserts "only update the actor with uid A".
275+
_, err = svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{
276+
Actor: &ateapipb.Actor{
277+
Metadata: &ateapipb.ResourceMetadata{
278+
Atespace: testAtespace,
279+
Name: testActorID,
280+
Uid: original.GetMetadata().GetUid(),
281+
},
282+
WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "paid"}},
283+
},
284+
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"worker_selector"}},
285+
})
286+
if code := status.Code(err); code != codes.Aborted {
287+
t.Errorf("UpdateActor error = %v (code %v), want code Aborted: the actor holding uid %s was deleted mid-update",
288+
err, code, original.GetMetadata().GetUid())
289+
}
290+
291+
stored, err := persistence.GetActor(ctx, actorRef)
292+
if err != nil {
293+
t.Fatalf("GetActor: %v", err)
294+
}
295+
if got, want := stored.GetMetadata().GetUid(), recreated.GetMetadata().GetUid(); got != want {
296+
t.Fatalf("stored uid = %s, want recreated actor's uid %s", got, want)
297+
}
298+
// The stored record must still be actor B as its creator left it. Any of A's
299+
// state showing up here is the clobber.
300+
if got := stored.GetStatus(); got != ateapipb.Actor_STATUS_SUSPENDED {
301+
t.Errorf("stored status = %v, want %v: recreated actor was overwritten with the deleted actor's state",
302+
got, ateapipb.Actor_STATUS_SUSPENDED)
303+
}
304+
if got := stored.GetWorkerAssignment(); got != nil {
305+
t.Errorf("stored worker_assignment = %v, want nil: recreated actor inherited the deleted actor's worker", got)
306+
}
307+
if got := stored.GetWorkerSelector(); got != nil {
308+
t.Errorf("stored worker_selector = %v, want nil: update meant for the deleted actor was applied", got)
309+
}
310+
}
311+
312+
// TestUpdateActor_ConcurrentDisjointUpdates checks that concurrent write
313+
// to a disjoint field is resolved by the store and both fields survive the update.
314+
func TestUpdateActor_ConcurrentDisjointUpdates(t *testing.T) {
315+
ctx := context.Background()
316+
persistence, cleanup := storetest.SetupTestStore(t)
317+
t.Cleanup(cleanup)
318+
319+
actorRef := resources.ActorRef{Atespace: testAtespace, Name: testActorID}
320+
321+
if _, err := persistence.CreateActor(ctx, &ateapipb.Actor{
322+
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID},
323+
ActorTemplateNamespace: "ns1",
324+
ActorTemplateName: "tmpl1",
325+
Status: ateapipb.Actor_STATUS_RUNNING,
326+
}); err != nil {
327+
t.Fatalf("seed CreateActor: %v", err)
328+
}
329+
330+
// A suspend workflow bumps status (a field that a later update operation will not touch)
331+
// inside the handler's read-modify-write window.
332+
racing := &conflictInjectingStore{
333+
Interface: persistence,
334+
inject: func() {
335+
if _, err := persistence.UpdateActor(ctx, actorRef, store.WithActorPrecondition(store.AnyUID, store.AnyVersion), func(dbActor *ateapipb.Actor) error {
336+
dbActor.Status = ateapipb.Actor_STATUS_SUSPENDING
337+
return nil
338+
}); err != nil {
339+
t.Fatalf("racing writer: mark suspending: %v", err)
340+
}
341+
},
342+
}
343+
svc := &Service{persistence: racing}
344+
345+
// Update operation is changing the worker_selector field, not the actor's status (like the concurrent op)
346+
if _, err := svc.UpdateActor(ctx, &ateapipb.UpdateActorRequest{
347+
Actor: &ateapipb.Actor{
348+
Metadata: &ateapipb.ResourceMetadata{Atespace: testAtespace, Name: testActorID},
349+
WorkerSelector: &ateapipb.Selector{MatchLabels: map[string]string{"tier": "paid"}},
350+
},
351+
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"worker_selector"}},
352+
}); err != nil {
353+
t.Fatalf("UpdateActor error = %v, want success: no version precondition was set, so the conflict is the server's to resolve", err)
354+
}
355+
356+
stored, err := persistence.GetActor(ctx, actorRef)
357+
if err != nil {
358+
t.Fatalf("GetActor: %v", err)
359+
}
360+
// Both worker selector and status updates survive
361+
if got := stored.GetWorkerSelector().GetMatchLabels()["tier"]; got != "paid" {
362+
t.Errorf("stored worker_selector[tier] = %q, want %q", got, "paid")
363+
}
364+
if got := stored.GetStatus(); got != ateapipb.Actor_STATUS_SUSPENDING {
365+
t.Errorf("stored status = %v, want %v: the concurrent writer's field must survive", got, ateapipb.Actor_STATUS_SUSPENDING)
366+
}
367+
}
368+
221369
// updateActorReq builds a minimal valid UpdateActorRequest, then applies the
222370
// given mutations.
223371
func updateActorReq(mutate ...func(*ateapipb.UpdateActorRequest)) *ateapipb.UpdateActorRequest {

cmd/ateapi/internal/controlapi/workflow_delete.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,14 @@ func (s *MarkDeletingStep) CheckPrerequisite(ctx context.Context, input *DeleteI
8181
return nil
8282
}
8383
func (s *MarkDeletingStep) Execute(ctx context.Context, input *DeleteInput, state *DeleteState) error {
84-
state.Actor.Status = ateapipb.Actor_STATUS_DELETING
85-
for _, vol := range state.Actor.GetActorVolumes() {
86-
vol.Status = ateapipb.ExternalVolume_STATUS_DELETING
87-
}
88-
updated, err := s.store.UpdateActor(ctx, state.Actor, state.Actor.GetMetadata().GetVersion())
84+
precondition := store.WithActorPrecondition(state.Actor.GetMetadata().GetUid(), state.Actor.GetMetadata().GetVersion())
85+
updated, err := s.store.UpdateActor(ctx, input.ActorRef, precondition, func(dbActor *ateapipb.Actor) error {
86+
dbActor.Status = ateapipb.Actor_STATUS_DELETING
87+
for _, vol := range dbActor.GetActorVolumes() {
88+
vol.Status = ateapipb.ExternalVolume_STATUS_DELETING
89+
}
90+
return nil
91+
})
8992
if err != nil {
9093
if errors.Is(err, store.ErrVersionConflict) {
9194
return status.Error(codes.Aborted, "concurrent update conflict, please retry")

0 commit comments

Comments
 (0)