Skip to content

Commit 985c200

Browse files
authored
Add DeletePreconditions to all Delete<Resource> methods (agent-substrate#1807)
Allow clients to do optimistic locking on deletes, as well as prevent unintended deletes on name reuse. agent-substrate#866
1 parent 32df553 commit 985c200

41 files changed

Lines changed: 1574 additions & 529 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎benchmarking/locust/common/ateapi_pb2.py‎

Lines changed: 126 additions & 126 deletions
Large diffs are not rendered by default.

‎cmd/ateapi/internal/controlapi/actor.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -434,16 +434,16 @@ func (s *RPCService) DeleteActor(ctx context.Context, req *ateapipb.DeleteActorR
434434
actorRef := resources.ActorRefFromObjectRef(req.GetActor())
435435
setSpanActorRefAttributes(ctx, actorRef)
436436

437-
deleted, err = s.actorWorkflow.DeleteActor(ctx, actorRef, req.GetAnyState())
437+
deleted, err = s.actorWorkflow.DeleteActor(ctx, actorRef, req.GetAnyState(), toDeletePreconditions(req.GetOptions()))
438438
if err != nil {
439439
return nil, err
440440
}
441441

442442
return deleted, nil
443443
}
444444

445-
func (s *ServiceImpl) DeleteActor(ctx context.Context, actorRef resources.ActorRef) (*ateapipb.Actor, error) {
446-
return s.store.DeleteActor(ctx, actorRef)
445+
func (s *ServiceImpl) DeleteActor(ctx context.Context, actorRef resources.ActorRef, precondition store.DeletePreconditions) (*ateapipb.Actor, error) {
446+
return s.store.DeleteActor(ctx, actorRef, precondition)
447447
}
448448

449449
func validateDeleteActorRequest(ctx context.Context, req *ateapipb.DeleteActorRequest) field.ErrorList {

‎cmd/ateapi/internal/controlapi/actor_template.go‎

Lines changed: 3 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -156,45 +156,12 @@ func (s *RPCService) DeleteActorTemplate(ctx context.Context, req *ateapipb.Dele
156156
if errs := validateDeleteActorTemplateRequest(ctx, req); len(errs) > 0 {
157157
return nil, toGRPCStatusError(errs)
158158
}
159-
160-
templateRef := resources.ActorTemplateRefFromObjectRef(req.GetActorTemplate())
161-
// Serialize cleanup against golden actor/tag creation by the reconciler.
162-
ctx, lease, err := acquireLease(ctx, s.impl, "lease:actortemplate:"+templateRef.Atespace+":"+templateRef.Name, "ActorTemplate "+templateRef.String())
163-
if err != nil {
164-
return nil, err
165-
}
166-
defer lease.Close()
167-
tmpl, err := s.impl.GetActorTemplate(ctx, templateRef)
168-
if errors.Is(err, store.ErrNotFound) {
169-
return nil, status.Errorf(codes.NotFound, "ActorTemplate %s not found", templateRef)
170-
}
171-
if err != nil {
172-
return nil, err
173-
}
174-
goldenRef := &ateapipb.ObjectRef{Atespace: resources.GoldenActorAtespace, Name: tmpl.GetMetadata().GetUid()}
175-
if _, err := s.DeleteActor(ctx, &ateapipb.DeleteActorRequest{Actor: goldenRef, AnyState: true}); err != nil && status.Code(err) != codes.NotFound {
176-
return nil, fmt.Errorf("while deleting golden actor: %w", err)
177-
}
178-
if _, err := s.DeleteTag(ctx, &ateapipb.DeleteTagRequest{Tag: goldenRef}); err != nil && status.Code(err) != codes.NotFound {
179-
return nil, fmt.Errorf("while deleting golden tag: %w", err)
180-
}
181-
deleted, err := s.impl.DeleteActorTemplate(ctx, templateRef)
182-
if err != nil {
183-
if errors.Is(err, store.ErrNotFound) {
184-
return nil, status.Errorf(codes.NotFound, "ActorTemplate %s not found", templateRef)
185-
}
186-
if errors.Is(err, store.ErrFailedPrecondition) {
187-
return nil, status.Error(codes.FailedPrecondition, err.Error())
188-
}
189-
return nil, fmt.Errorf("while deleting actor template from DB: %w", err)
190-
}
191-
192-
return deleted, nil
159+
return s.actorWorkflow.DeleteActorTemplate(ctx, resources.ActorTemplateRefFromObjectRef(req.GetActorTemplate()), toDeletePreconditions(req.GetOptions()))
193160
}
194161

195-
func (s *ServiceImpl) DeleteActorTemplate(ctx context.Context, templateRef resources.ActorTemplateRef) (*ateapipb.ActorTemplate, error) {
162+
func (s *ServiceImpl) DeleteActorTemplate(ctx context.Context, templateRef resources.ActorTemplateRef, precondition store.DeletePreconditions) (*ateapipb.ActorTemplate, error) {
196163
// TODO: implement this
197-
return s.store.DeleteActorTemplate(ctx, templateRef)
164+
return s.store.DeleteActorTemplate(ctx, templateRef, precondition)
198165
}
199166

200167
func validateDeleteActorTemplateRequest(ctx context.Context, req *ateapipb.DeleteActorTemplateRequest) field.ErrorList {

‎cmd/ateapi/internal/controlapi/actor_template_test.go‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ func TestDeleteActorTemplate(t *testing.T) {
359359
// failPrefix makes object storage fail cleanup for this resource kind.
360360
failPrefix string
361361
wantActorAfterFailure bool
362+
staleGuard bool
362363
}{
363364
{name: "golden actor and tag"},
364365
{name: "golden actor already deleted", actorDeleted: true},
@@ -367,6 +368,7 @@ func TestDeleteActorTemplate(t *testing.T) {
367368
{name: "incomplete golden tag", pendingTag: true},
368369
{name: "actor cleanup failure", failPrefix: "/actors/", wantActorAfterFailure: true},
369370
{name: "tag cleanup failure", failPrefix: "/tags/"},
371+
{name: "stale guard refused before cleanup", staleGuard: true},
370372
}
371373
for _, tt := range tests {
372374
t.Run(tt.name, func(t *testing.T) {
@@ -406,7 +408,7 @@ func TestDeleteActorTemplate(t *testing.T) {
406408
s.State = ateapipb.ActorState_ACTOR_STATE_RUNNING
407409
})
408410
if tt.actorDeleted {
409-
if _, err := workflow.DeleteActor(ctx, goldenRef, true); err != nil {
411+
if _, err := workflow.DeleteActor(ctx, goldenRef, true, store.DeletePreconditions{}); err != nil {
410412
t.Fatal(err)
411413
}
412414
}
@@ -423,6 +425,22 @@ func TestDeleteActorTemplate(t *testing.T) {
423425
return nil
424426
}
425427
}
428+
if tt.staleGuard {
429+
current, err := persistence.GetActorTemplate(ctx, templateRef)
430+
if err != nil {
431+
t.Fatal(err)
432+
}
433+
stale := store.DeletePreconditions{UID: current.GetMetadata().GetUid(), Version: current.GetMetadata().GetVersion() + 1}
434+
if _, err := workflow.DeleteActorTemplate(ctx, templateRef, stale); status.Code(err) != codes.Aborted {
435+
t.Fatalf("DeleteActorTemplate with a stale version = %v, want code Aborted", err)
436+
}
437+
if _, err := persistence.GetActor(ctx, goldenRef); err != nil {
438+
t.Fatalf("golden actor after the refused delete: %v", err)
439+
}
440+
if _, err := persistence.GetTag(ctx, tagRef); err != nil {
441+
t.Fatalf("golden tag after the refused delete: %v", err)
442+
}
443+
}
426444
req := &ateapipb.DeleteActorTemplateRequest{ActorTemplate: templateRef.ToObjectRef()}
427445
deleted, err := svc.DeleteActorTemplate(ctx, req)
428446
if tt.failPrefix != "" {

‎cmd/ateapi/internal/controlapi/actor_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1117,7 +1117,7 @@ func TestUpdateActor_DeleteRecreateRace(t *testing.T) {
11171117
}); err != nil {
11181118
t.Fatalf("racing writer: mark deleting: %v", err)
11191119
}
1120-
if _, err := persistence.DeleteActor(ctx, actorRef); err != nil {
1120+
if _, err := persistence.DeleteActor(ctx, actorRef, store.DeletePreconditions{}); err != nil {
11211121
t.Fatalf("racing writer: DeleteActor: %v", err)
11221122
}
11231123
recreated, err = persistence.CreateActor(ctx, &ateapipb.Actor{

‎cmd/ateapi/internal/controlapi/atespace.go‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,18 +128,24 @@ func (s *RPCService) DeleteAtespace(ctx context.Context, req *ateapipb.DeleteAte
128128
return nil, toGRPCStatusError(errs)
129129
}
130130

131-
return s.impl.DeleteAtespace(ctx, req.Atespace.Name)
131+
return s.impl.DeleteAtespace(ctx, req.Atespace.Name, toDeletePreconditions(req.GetOptions()))
132132
}
133133

134-
func (s *ServiceImpl) DeleteAtespace(ctx context.Context, name string) (*ateapipb.Atespace, error) {
135-
deleted, err := s.store.DeleteAtespace(ctx, name)
134+
func (s *ServiceImpl) DeleteAtespace(ctx context.Context, name string, precondition store.DeletePreconditions) (*ateapipb.Atespace, error) {
135+
deleted, err := s.store.DeleteAtespace(ctx, name, precondition)
136136
if err != nil {
137137
if errors.Is(err, store.ErrNotFound) {
138138
return nil, status.Errorf(codes.NotFound, "Atespace %s not found", name)
139139
}
140140
if errors.Is(err, store.ErrFailedPrecondition) {
141141
return nil, status.Errorf(codes.FailedPrecondition, "Atespace %s is not empty", name)
142142
}
143+
if errors.Is(err, store.ErrUIDConflict) {
144+
return nil, status.Errorf(codes.Aborted, "Atespace %s does not have uid %s", name, precondition.UID)
145+
}
146+
if errors.Is(err, store.ErrVersionConflict) {
147+
return nil, status.Error(codes.Aborted, "concurrent update conflict, please retry")
148+
}
143149
return nil, fmt.Errorf("while deleting atespace from DB: %w", err)
144150
}
145151

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package controlapi
16+
17+
import (
18+
"github.com/agent-substrate/substrate/cmd/ateapi/internal/store"
19+
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
20+
)
21+
22+
func toDeletePreconditions(opts *ateapipb.DeleteOptions) store.DeletePreconditions {
23+
return store.DeletePreconditions{UID: opts.GetUid(), Version: opts.GetVersion()}
24+
}

‎cmd/ateapi/internal/controlapi/egress_policy.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func (s *RPCService) GetActorEgressPolicy(ctx context.Context, req *ateapipb.Get
6969
func (s *ServiceImpl) GetEgressPolicy(ctx context.Context, actorRef resources.ActorRef) (*ateapipb.EgressPolicy, error) {
7070
policy, err := s.store.GetEgressPolicy(ctx, actorRef)
7171
if errors.Is(err, store.ErrNotFound) {
72-
return nil, status.Error(codes.NotFound, "EgressPolicy not found")
72+
return nil, status.Errorf(codes.NotFound, "EgressPolicy for actor %s not found", actorRef)
7373
}
7474
if err != nil {
7575
return nil, fmt.Errorf("while getting Actor egress policy: %w", err)
@@ -128,11 +128,11 @@ func (s *RPCService) DeleteActorEgressPolicy(ctx context.Context, req *ateapipb.
128128
return nil, toGRPCStatusError(errs)
129129
}
130130

131-
return s.impl.DeleteEgressPolicy(ctx, resources.ActorRefFromObjectRef(req.GetActor()))
131+
return s.impl.DeleteEgressPolicy(ctx, resources.ActorRefFromObjectRef(req.GetActor()), toDeletePreconditions(req.GetOptions()))
132132
}
133133

134-
func (s *ServiceImpl) DeleteEgressPolicy(ctx context.Context, actorRef resources.ActorRef) (*ateapipb.EgressPolicy, error) {
135-
deleted, err := s.store.DeleteEgressPolicy(ctx, actorRef)
134+
func (s *ServiceImpl) DeleteEgressPolicy(ctx context.Context, actorRef resources.ActorRef, precondition store.DeletePreconditions) (*ateapipb.EgressPolicy, error) {
135+
deleted, err := s.store.DeleteEgressPolicy(ctx, actorRef, precondition)
136136
return mapEgressPolicyWrite(deleted, err)
137137
}
138138

‎cmd/ateapi/internal/controlapi/functionaltest/actor_template_test.go‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ package functionaltest
1616

1717
import (
1818
"context"
19+
"testing"
20+
"time"
21+
1922
"github.com/agent-substrate/substrate/cmd/ateapi/internal/controlapi"
2023
"github.com/agent-substrate/substrate/internal/resources"
2124
"google.golang.org/grpc/status"
2225
"k8s.io/apimachinery/pkg/util/wait"
23-
"testing"
24-
"time"
2526

2627
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
2728
"github.com/google/go-cmp/cmp"
@@ -245,3 +246,41 @@ func TestListActorTemplates_InvalidPageToken(t *testing.T) {
245246
&ateapipb.ListActorTemplatesRequest{PageToken: "%%%"})
246247
assertGrpcError(t, err, codes.InvalidArgument, "invalid page_token")
247248
}
249+
250+
func TestDeleteActorTemplate_Preconditions(t *testing.T) {
251+
ns := namespaceForTest("ns-delete-template-preconditions")
252+
tc := setupTest(t, ns)
253+
defer tc.cleanup()
254+
ctx := context.Background()
255+
tmpl := createTemplateWithSelector(t, tc, "tmpl1", nil)
256+
templateRef := resources.ActorTemplateRefFromActorTemplate(tmpl)
257+
ref := templateRef.ToObjectRef()
258+
del := func(opts *ateapipb.DeleteOptions) error {
259+
_, err := tc.client.DeleteActorTemplate(ctx, &ateapipb.DeleteActorTemplateRequest{ActorTemplate: ref, Options: opts})
260+
return err
261+
}
262+
263+
uid, version := tmpl.GetMetadata().GetUid(), tmpl.GetMetadata().GetVersion()
264+
265+
assertGrpcError(t, del(&ateapipb.DeleteOptions{Version: version + 1}), codes.Aborted, "concurrent update conflict, please retry")
266+
assertGrpcError(t, del(&ateapipb.DeleteOptions{Uid: uid, Version: version + 1}), codes.Aborted, "concurrent update conflict, please retry")
267+
assertGrpcError(t, del(&ateapipb.DeleteOptions{Uid: foreignUID}), codes.Aborted, "ActorTemplate "+templateRef.String()+" does not have uid "+foreignUID)
268+
assertGrpcError(t, del(&ateapipb.DeleteOptions{Uid: foreignUID, Version: version}), codes.Aborted, "ActorTemplate "+templateRef.String()+" does not have uid "+foreignUID)
269+
if _, err := tc.client.GetActorTemplate(ctx, &ateapipb.GetActorTemplateRequest{ActorTemplate: ref}); err != nil {
270+
t.Fatalf("a refused delete removed the template: %v", err)
271+
}
272+
273+
if err := del(&ateapipb.DeleteOptions{Version: version}); err != nil {
274+
t.Fatalf("DeleteActorTemplate with the matching version: %v", err)
275+
}
276+
tmpl = createTemplateWithSelector(t, tc, "tmpl1", nil)
277+
if err := del(&ateapipb.DeleteOptions{Uid: tmpl.GetMetadata().GetUid()}); err != nil {
278+
t.Fatalf("DeleteActorTemplate with the matching uid: %v", err)
279+
}
280+
tmpl = createTemplateWithSelector(t, tc, "tmpl1", nil)
281+
if err := del(&ateapipb.DeleteOptions{Uid: tmpl.GetMetadata().GetUid(), Version: tmpl.GetMetadata().GetVersion()}); err != nil {
282+
t.Fatalf("DeleteActorTemplate with both guards: %v", err)
283+
}
284+
_, err := tc.client.GetActorTemplate(ctx, &ateapipb.GetActorTemplateRequest{ActorTemplate: ref})
285+
assertGrpcError(t, err, codes.NotFound, "ActorTemplate "+templateRef.String()+" not found")
286+
}

‎cmd/ateapi/internal/controlapi/functionaltest/actor_test.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3806,3 +3806,56 @@ func TestRevertActor_NotFound(t *testing.T) {
38063806
t.Fatalf("RevertActor = %v, want NotFound", err)
38073807
}
38083808
}
3809+
3810+
func TestDeleteActor_Preconditions(t *testing.T) {
3811+
ns := namespaceForTest("ns-delete-preconditions")
3812+
tc := setupTest(t, ns)
3813+
defer tc.cleanup()
3814+
ctx := context.Background()
3815+
tmpl := createTemplate(t, tc, ns)
3816+
actorRef := resources.ActorRef{Atespace: testAtespace, Name: "id1"}
3817+
ref := actorRef.ToObjectRef()
3818+
create := func() *ateapipb.Actor {
3819+
created, err := tc.client.CreateActor(ctx, &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
3820+
Metadata: &ateapipb.ResourceMetadata{Atespace: actorRef.Atespace, Name: actorRef.Name},
3821+
ActorTemplate: resources.ActorTemplateRefFromActorTemplate(tmpl).ToObjectRef(),
3822+
}})
3823+
if err != nil {
3824+
t.Fatalf("CreateActor failed: %v", err)
3825+
}
3826+
return created
3827+
}
3828+
del := func(opts *ateapipb.DeleteOptions) error {
3829+
_, err := tc.client.DeleteActor(ctx, &ateapipb.DeleteActorRequest{Actor: ref, Options: opts})
3830+
return err
3831+
}
3832+
3833+
actor := create()
3834+
uid, version := actor.GetMetadata().GetUid(), actor.GetMetadata().GetVersion()
3835+
3836+
assertGrpcError(t, del(&ateapipb.DeleteOptions{Version: version + 1}), codes.Aborted, "concurrent update conflict, please retry")
3837+
assertGrpcError(t, del(&ateapipb.DeleteOptions{Uid: uid, Version: version + 1}), codes.Aborted, "concurrent update conflict, please retry")
3838+
assertGrpcError(t, del(&ateapipb.DeleteOptions{Uid: foreignUID}), codes.Aborted, "Actor "+actorRef.String()+" does not have uid "+foreignUID)
3839+
assertGrpcError(t, del(&ateapipb.DeleteOptions{Uid: foreignUID, Version: version}), codes.Aborted, "Actor "+actorRef.String()+" does not have uid "+foreignUID)
3840+
got, err := tc.client.GetActor(ctx, &ateapipb.GetActorRequest{Actor: ref})
3841+
if err != nil {
3842+
t.Fatalf("a refused delete removed the actor: %v", err)
3843+
}
3844+
if state := got.GetStatus().GetState(); state != ateapipb.ActorState_ACTOR_STATE_SUSPENDED {
3845+
t.Fatalf("state after the refused deletes = %v, want SUSPENDED", state)
3846+
}
3847+
3848+
if err := del(&ateapipb.DeleteOptions{Version: version}); err != nil {
3849+
t.Fatalf("DeleteActor with the matching version: %v", err)
3850+
}
3851+
actor = create()
3852+
if err := del(&ateapipb.DeleteOptions{Uid: actor.GetMetadata().GetUid()}); err != nil {
3853+
t.Fatalf("DeleteActor with the matching uid: %v", err)
3854+
}
3855+
actor = create()
3856+
if err := del(&ateapipb.DeleteOptions{Uid: actor.GetMetadata().GetUid(), Version: actor.GetMetadata().GetVersion()}); err != nil {
3857+
t.Fatalf("DeleteActor with both guards: %v", err)
3858+
}
3859+
_, err = tc.client.GetActor(ctx, &ateapipb.GetActorRequest{Actor: ref})
3860+
assertGrpcError(t, err, codes.NotFound, "Actor "+actorRef.String()+" not found")
3861+
}

0 commit comments

Comments
 (0)