@@ -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.
223371func updateActorReq (mutate ... func (* ateapipb.UpdateActorRequest )) * ateapipb.UpdateActorRequest {
0 commit comments