mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-11-10 10:13:50 +08:00
Part of #731 and follow up to #640. This PR makes improvements to the Postgres store by addressing review comments on #940 and optimizing Postgres updates as suggested in #988. - Replaces Postgres row-locking updates for actors, templates, and snapshot tags with bounded optimistic concurrency control using UID/version CAS checks. - Strengthens relational integrity with missing FKs and indexes, and replace snapshot-tag creation pre-check with FK - Removes unnecessary UID uniqueness constraints - Standardize pagination and validation across stores, map malformed tokens and invalid sizes to appropriate gRPC errors - Improves actor resume race handling when workers disappear or concurrent assignments exhaust their retry budget - Improves reliability with startup retries, expired-lease cleanup, worker-watch reconnection after malformed msg - Expand Postgres, Redis, Contract, and Control API tests to cover new constraints, error semantics, concurrency --------- Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
253 lines
9.9 KiB
Go
253 lines
9.9 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package functionaltest
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
|
|
"github.com/google/go-cmp/cmp"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/protobuf/testing/protocmp"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
)
|
|
|
|
func TestCreateAtespace_Success(t *testing.T) {
|
|
ns := namespaceForTest("ns-create-atespace")
|
|
tc := setupTest(t, ns)
|
|
defer tc.cleanup()
|
|
createTemplate(t, tc, ns)
|
|
|
|
resp, err := tc.client.CreateAtespace(context.Background(), &ateapipb.CreateAtespaceRequest{
|
|
Atespace: &ateapipb.Atespace{
|
|
Metadata: &ateapipb.ResourceMetadata{
|
|
Name: "team-a",
|
|
Uid: "caller-supplied-uid",
|
|
Version: 999,
|
|
CreateTime: timestamppb.New(time.Unix(1, 0)),
|
|
UpdateTime: timestamppb.New(time.Unix(1, 0)),
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateAtespace failed: %v", err)
|
|
}
|
|
md := resp.GetMetadata()
|
|
if md.GetName() != "team-a" {
|
|
t.Errorf("Name = %q, want team-a", md.GetName())
|
|
}
|
|
if md.GetAtespace() != "" {
|
|
t.Errorf("Atespace = %q, want empty (global-scoped)", md.GetAtespace())
|
|
}
|
|
if md.GetVersion() != 1 {
|
|
t.Errorf("Version = %d, want 1 (caller-set 999 must be ignored)", md.GetVersion())
|
|
}
|
|
if md.GetUid() == "" || md.GetUid() == "caller-supplied-uid" {
|
|
t.Errorf("uid = %q, want a server-generated value", md.GetUid())
|
|
}
|
|
|
|
if _, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{
|
|
Actor: &ateapipb.Actor{
|
|
Metadata: &ateapipb.ResourceMetadata{
|
|
Atespace: "team-a",
|
|
Name: "id1"},
|
|
ActorTemplateNamespace: ns,
|
|
ActorTemplateName: "tmpl1",
|
|
}}); err != nil {
|
|
t.Errorf("CreateActor into freshly created atespace failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateAtespace_AlreadyExists(t *testing.T) {
|
|
ns := namespaceForTest("ns-create-atespace-dup")
|
|
tc := setupTest(t, ns)
|
|
defer tc.cleanup()
|
|
|
|
if _, err := tc.client.CreateAtespace(context.Background(), &ateapipb.CreateAtespaceRequest{Atespace: &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: "team-a"}}}); err != nil {
|
|
t.Fatalf("first CreateAtespace failed: %v", err)
|
|
}
|
|
_, err := tc.client.CreateAtespace(context.Background(), &ateapipb.CreateAtespaceRequest{Atespace: &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: "team-a"}}})
|
|
assertGrpcError(t, err, codes.AlreadyExists, "Atespace team-a already exists")
|
|
}
|
|
|
|
func TestGetAtespace_Found(t *testing.T) {
|
|
ns := namespaceForTest("ns-get-atespace")
|
|
tc := setupTest(t, ns)
|
|
defer tc.cleanup()
|
|
|
|
created, err := tc.client.CreateAtespace(context.Background(), &ateapipb.CreateAtespaceRequest{Atespace: &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: "team-a"}}})
|
|
if err != nil {
|
|
t.Fatalf("CreateAtespace failed: %v", err)
|
|
}
|
|
resp, err := tc.client.GetAtespace(context.Background(), &ateapipb.GetAtespaceRequest{Atespace: &ateapipb.ObjectRef{Name: "team-a"}})
|
|
if err != nil {
|
|
t.Fatalf("GetAtespace failed: %v", err)
|
|
}
|
|
if diff := cmp.Diff(created, resp, protocmp.Transform()); diff != "" {
|
|
t.Errorf("GetAtespace mismatch (-created +got):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
func TestGetAtespace_NotFound(t *testing.T) {
|
|
ns := namespaceForTest("ns-get-atespace-missing")
|
|
tc := setupTest(t, ns)
|
|
defer tc.cleanup()
|
|
|
|
_, err := tc.client.GetAtespace(context.Background(), &ateapipb.GetAtespaceRequest{Atespace: &ateapipb.ObjectRef{Name: "nope"}})
|
|
assertGrpcError(t, err, codes.NotFound, "Atespace nope not found")
|
|
}
|
|
|
|
func TestListAtespaces(t *testing.T) {
|
|
ns := namespaceForTest("ns-list-atespaces")
|
|
tc := setupTest(t, ns)
|
|
defer tc.cleanup()
|
|
|
|
for _, n := range []string{"team-a", "team-b"} {
|
|
if _, err := tc.client.CreateAtespace(context.Background(), &ateapipb.CreateAtespaceRequest{Atespace: &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: n}}}); err != nil {
|
|
t.Fatalf("CreateAtespace(%s) failed: %v", n, err)
|
|
}
|
|
}
|
|
resp, err := tc.client.ListAtespaces(context.Background(), &ateapipb.ListAtespacesRequest{})
|
|
if err != nil {
|
|
t.Fatalf("ListAtespaces failed: %v", err)
|
|
}
|
|
got := map[string]bool{}
|
|
for _, a := range resp.GetAtespaces() {
|
|
got[a.GetMetadata().GetName()] = true
|
|
}
|
|
// setupTest seeds testAtespace; team-a and team-b were created above.
|
|
for _, n := range []string{testAtespace, "team-a", "team-b"} {
|
|
if !got[n] {
|
|
t.Errorf("ListAtespaces missing %q; got %v", n, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDeleteAtespace_Empty_Success(t *testing.T) {
|
|
ns := namespaceForTest("ns-delete-atespace-empty")
|
|
tc := setupTest(t, ns)
|
|
defer tc.cleanup()
|
|
|
|
if _, err := tc.client.CreateAtespace(context.Background(), &ateapipb.CreateAtespaceRequest{Atespace: &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: "team-a"}}}); err != nil {
|
|
t.Fatalf("CreateAtespace failed: %v", err)
|
|
}
|
|
deleted, err := tc.client.DeleteAtespace(context.Background(), &ateapipb.DeleteAtespaceRequest{Atespace: &ateapipb.ObjectRef{Name: "team-a"}})
|
|
if err != nil {
|
|
t.Fatalf("DeleteAtespace failed: %v", err)
|
|
}
|
|
// DeleteAtespace returns the deleted resource.
|
|
if got := deleted.GetMetadata().GetName(); got != "team-a" {
|
|
t.Errorf("deleted atespace name = %q, want team-a", got)
|
|
}
|
|
|
|
_, err = tc.client.GetAtespace(context.Background(), &ateapipb.GetAtespaceRequest{Atespace: &ateapipb.ObjectRef{Name: "team-a"}})
|
|
assertGrpcError(t, err, codes.NotFound, "Atespace team-a not found")
|
|
}
|
|
|
|
func TestDeleteAtespace_NonEmpty_Rejected(t *testing.T) {
|
|
ns := namespaceForTest("ns-delete-atespace-nonempty")
|
|
tc := setupTest(t, ns)
|
|
defer tc.cleanup()
|
|
createTemplate(t, tc, ns)
|
|
|
|
if _, err := tc.client.CreateAtespace(context.Background(), &ateapipb.CreateAtespaceRequest{Atespace: &ateapipb.Atespace{Metadata: &ateapipb.ResourceMetadata{Name: "team-a"}}}); err != nil {
|
|
t.Fatalf("CreateAtespace failed: %v", err)
|
|
}
|
|
if _, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
|
|
Metadata: &ateapipb.ResourceMetadata{Atespace: "team-a", Name: "id1"},
|
|
ActorTemplateNamespace: ns,
|
|
ActorTemplateName: "tmpl1",
|
|
}}); err != nil {
|
|
t.Fatalf("CreateActor failed: %v", err)
|
|
}
|
|
_, err := tc.client.DeleteAtespace(context.Background(), &ateapipb.DeleteAtespaceRequest{Atespace: &ateapipb.ObjectRef{Name: "team-a"}})
|
|
assertGrpcError(t, err, codes.FailedPrecondition, "Atespace team-a is not empty")
|
|
// The atespace must survive a rejected delete.
|
|
if _, err := tc.client.GetAtespace(context.Background(), &ateapipb.GetAtespaceRequest{Atespace: &ateapipb.ObjectRef{Name: "team-a"}}); err != nil {
|
|
t.Errorf("atespace should survive a rejected delete, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDeleteAtespace_ScopedToTargetAtespace pins (at the RPC layer) that the
|
|
// emptiness check is scoped to the target atespace: deleting an empty atespace
|
|
// succeeds even when a different atespace holds actors.
|
|
func TestDeleteAtespace_ScopedToTargetAtespace(t *testing.T) {
|
|
ns := namespaceForTest("ns-delete-atespace-scoped")
|
|
tc := setupTest(t, ns)
|
|
defer tc.cleanup()
|
|
createTemplate(t, tc, ns)
|
|
createAtespace(t, tc, "team-a")
|
|
createAtespace(t, tc, "team-b")
|
|
|
|
// Actor only in team-b.
|
|
if _, err := tc.client.CreateActor(context.Background(), &ateapipb.CreateActorRequest{Actor: &ateapipb.Actor{
|
|
Metadata: &ateapipb.ResourceMetadata{Atespace: "team-b", Name: "id1"},
|
|
ActorTemplateNamespace: ns,
|
|
ActorTemplateName: "tmpl1",
|
|
}}); err != nil {
|
|
t.Fatalf("CreateActor failed: %v", err)
|
|
}
|
|
|
|
// Empty team-a deletes fine despite team-b holding an actor.
|
|
if _, err := tc.client.DeleteAtespace(context.Background(), &ateapipb.DeleteAtespaceRequest{Atespace: &ateapipb.ObjectRef{Name: "team-a"}}); err != nil {
|
|
t.Errorf("DeleteAtespace(team-a, empty) failed: %v", err)
|
|
}
|
|
// team-b is still non-empty → rejected.
|
|
_, err := tc.client.DeleteAtespace(context.Background(), &ateapipb.DeleteAtespaceRequest{Atespace: &ateapipb.ObjectRef{Name: "team-b"}})
|
|
assertGrpcError(t, err, codes.FailedPrecondition, "Atespace team-b is not empty")
|
|
}
|
|
|
|
func TestDeleteAtespace_NotFound(t *testing.T) {
|
|
ns := namespaceForTest("ns-delete-atespace-missing")
|
|
tc := setupTest(t, ns)
|
|
defer tc.cleanup()
|
|
|
|
_, err := tc.client.DeleteAtespace(context.Background(), &ateapipb.DeleteAtespaceRequest{Atespace: &ateapipb.ObjectRef{Name: "nope"}})
|
|
assertGrpcError(t, err, codes.NotFound, "Atespace nope not found")
|
|
}
|
|
|
|
func TestValidation_Atespace(t *testing.T) {
|
|
ns := namespaceForTest("ns-validation-atespace")
|
|
tc := setupTest(t, ns)
|
|
defer tc.cleanup()
|
|
|
|
t.Run("ListAtespaces", func(t *testing.T) {
|
|
_, err := tc.client.ListAtespaces(context.Background(), &ateapipb.ListAtespacesRequest{PageSize: -1})
|
|
assertGrpcErrorRegex(t, err, codes.InvalidArgument, "page_size: Invalid value")
|
|
})
|
|
|
|
t.Run("ListAtespaces invalid token", func(t *testing.T) {
|
|
_, err := tc.client.ListAtespaces(context.Background(), &ateapipb.ListAtespacesRequest{PageToken: "%%%"})
|
|
assertGrpcError(t, err, codes.InvalidArgument, "invalid page_token")
|
|
})
|
|
|
|
t.Run("CreateAtespace", func(t *testing.T) {
|
|
_, err := tc.client.CreateAtespace(context.Background(), &ateapipb.CreateAtespaceRequest{})
|
|
assertGrpcErrorRegex(t, err, codes.InvalidArgument, "atespace: Required value")
|
|
})
|
|
|
|
t.Run("GetAtespace", func(t *testing.T) {
|
|
_, err := tc.client.GetAtespace(context.Background(), &ateapipb.GetAtespaceRequest{})
|
|
assertGrpcErrorRegex(t, err, codes.InvalidArgument, "atespace: Required value")
|
|
})
|
|
|
|
t.Run("DeleteAtespace", func(t *testing.T) {
|
|
_, err := tc.client.DeleteAtespace(context.Background(), &ateapipb.DeleteAtespaceRequest{})
|
|
assertGrpcErrorRegex(t, err, codes.InvalidArgument, "atespace: Required value")
|
|
})
|
|
}
|