|
| 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 atepg |
| 16 | + |
| 17 | +import ( |
| 18 | + "context" |
| 19 | + "fmt" |
| 20 | + "regexp" |
| 21 | + "slices" |
| 22 | + "strings" |
| 23 | + "sync" |
| 24 | + "testing" |
| 25 | + |
| 26 | + "github.com/jackc/pgx/v5" |
| 27 | + "github.com/jackc/pgx/v5/pgxpool" |
| 28 | + |
| 29 | + "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" |
| 30 | + "github.com/agent-substrate/substrate/cmd/ateapi/internal/store/storecontract" |
| 31 | +) |
| 32 | + |
| 33 | +// TestActorsTablePartitionable exists to keep it possible to partition the |
| 34 | +// actors table by atespace or by name later, with the other atespace-scoped |
| 35 | +// tables partitioned alongside it by atespace. It runs the store contract |
| 36 | +// suite against a copy of the schema partitioned that way, and fails on any |
| 37 | +// schema change or query that would not work in that layout. |
| 38 | +func TestActorsTablePartitionable(t *testing.T) { |
| 39 | + // exemptions are statements allowed to read every partition even though |
| 40 | + // their result lives in one. Do not add one without discussion and |
| 41 | + // agreement in the community. |
| 42 | + var exemptions []string |
| 43 | + |
| 44 | + // spansPartitions are the statements whose result covers every partition |
| 45 | + // by definition, under each partition key. |
| 46 | + spansPartitions := map[string][]string{ |
| 47 | + // A global list walks every atespace. |
| 48 | + "atespace": {globalList("actors"), globalList("actor_templates"), globalList("actor_snapshots")}, |
| 49 | + // A global list walks every name, and a list within one atespace |
| 50 | + // spans every name hash. |
| 51 | + "name": {globalList("actors"), scopedActorList}, |
| 52 | + } |
| 53 | + |
| 54 | + t.Run("by atespace", func(t *testing.T) { |
| 55 | + runContractSuitePartitioned(t, "atespace", atespaceScopedTables, slices.Concat(spansPartitions["atespace"], exemptions)) |
| 56 | + }) |
| 57 | + t.Run("by name", func(t *testing.T) { |
| 58 | + runContractSuitePartitioned(t, "name", []string{"actors"}, slices.Concat(spansPartitions["name"], exemptions)) |
| 59 | + }) |
| 60 | + |
| 61 | + t.Run("rejects a unique index that omits the key", func(t *testing.T) { |
| 62 | + pool := migratedPool(t, "partitioned-unique") |
| 63 | + // This also rules out foreign keys onto uid, which need this index. |
| 64 | + if _, err := pool.Exec(t.Context(), `CREATE UNIQUE INDEX actors_uid_key ON actors (uid)`); err != nil { |
| 65 | + t.Fatal(err) |
| 66 | + } |
| 67 | + err := partitionTable(t.Context(), pool, "actors", "atespace") |
| 68 | + if err == nil || !strings.Contains(err.Error(), "must include all partitioning columns") { |
| 69 | + t.Fatalf("partitionTable error = %v, want unique index rejection", err) |
| 70 | + } |
| 71 | + t.Log(err) |
| 72 | + }) |
| 73 | + t.Run("rejects a query that omits the key", func(t *testing.T) { |
| 74 | + var got string |
| 75 | + pool := partitionedPool(t, "partitioned-fanout", "atespace", []string{"actors"}) |
| 76 | + check := newFanOutCheck("atespace", []string{"actors"}, nil, pool, func(format string, args ...any) { got = fmt.Sprintf(format, args...) }) |
| 77 | + var n int |
| 78 | + if err := openPool(t, "partitioned-fanout", check).QueryRow(t.Context(), `SELECT count(*) FROM actors WHERE uid = $1`, "u1").Scan(&n); err != nil { |
| 79 | + t.Fatal(err) |
| 80 | + } |
| 81 | + if !strings.Contains(got, "[actors_p0 actors_p1]") { |
| 82 | + t.Fatalf("fan-out check reported %q, want the uid lookup reading both partitions", got) |
| 83 | + } |
| 84 | + t.Log(got) |
| 85 | + }) |
| 86 | +} |
| 87 | + |
| 88 | +// atespaceScopedTables hold one atespace's resources and partition together. |
| 89 | +var atespaceScopedTables = []string{"actors", "actor_egress_policies", "actor_templates", "actor_snapshots", "actor_snapshot_tags"} |
| 90 | + |
| 91 | +// globalList is the statement that lists table across every atespace. |
| 92 | +func globalList(table string) string { |
| 93 | + return normalizeSQL(` |
| 94 | + SELECT atespace, name, proto FROM ` + table + ` |
| 95 | + WHERE $1::text IS NULL OR (atespace, name) > ($1, $2) |
| 96 | + ORDER BY atespace, name |
| 97 | + LIMIT $3`) |
| 98 | +} |
| 99 | + |
| 100 | +// scopedActorList is the statement that lists one atespace's actors. |
| 101 | +var scopedActorList = normalizeSQL(` |
| 102 | + SELECT name, proto FROM actors |
| 103 | + WHERE atespace = $1 AND ($2::text IS NULL OR name > $2) |
| 104 | + ORDER BY name |
| 105 | + LIMIT $3`) |
| 106 | + |
| 107 | +// runContractSuitePartitioned runs the store contract suite with tables |
| 108 | +// partitioned on key and fails on any statement, other than the allowed |
| 109 | +// ones, whose plan reads more than one partition of a table. |
| 110 | +func runContractSuitePartitioned(t *testing.T, key string, tables []string, allowed []string) { |
| 111 | + schema := "partitioned-by-" + key |
| 112 | + pool := partitionedPool(t, schema, key, tables) |
| 113 | + check := newFanOutCheck(key, tables, allowed, pool, t.Errorf) |
| 114 | + traced := openPool(t, schema, check) |
| 115 | + storecontract.RunContractTests(t, func(t *testing.T) store.Interface { |
| 116 | + p, err := NewPersistence(t.Context(), traced) |
| 117 | + if err != nil { |
| 118 | + t.Fatal(err) |
| 119 | + } |
| 120 | + t.Cleanup(p.Close) |
| 121 | + clearAll(t, p) |
| 122 | + return p |
| 123 | + }) |
| 124 | + if len(check.seen) == 0 { |
| 125 | + t.Fatal("no statements on partitioned tables were traced") |
| 126 | + } |
| 127 | +} |
| 128 | + |
| 129 | +// partitionTable rebuilds the empty, freshly migrated table as a two-way |
| 130 | +// hash-partitioned table on key, keeping its indexes, constraints and |
| 131 | +// foreign keys. PostgreSQL rejects any of them that omits key. |
| 132 | +func partitionTable(ctx context.Context, pool *pgxpool.Pool, table, key string) error { |
| 133 | + rows, err := pool.Query(ctx, ` |
| 134 | + SELECT format('ALTER TABLE %s ADD CONSTRAINT %I %s', conrelid::regclass, conname, pg_get_constraintdef(oid)) |
| 135 | + FROM pg_constraint |
| 136 | + WHERE contype = 'f' AND conparentid = 0 AND $1::regclass IN (conrelid, confrelid)`, table) |
| 137 | + if err != nil { |
| 138 | + return err |
| 139 | + } |
| 140 | + foreignKeys, err := pgx.CollectRows(rows, pgx.RowTo[string]) |
| 141 | + if err != nil { |
| 142 | + return err |
| 143 | + } |
| 144 | + if _, err := pool.Exec(ctx, fmt.Sprintf(` |
| 145 | + CREATE TABLE %[1]s_partitioned (LIKE %[1]s INCLUDING ALL) PARTITION BY HASH (%[2]s); |
| 146 | + CREATE TABLE %[1]s_p0 PARTITION OF %[1]s_partitioned FOR VALUES WITH (MODULUS 2, REMAINDER 0); |
| 147 | + CREATE TABLE %[1]s_p1 PARTITION OF %[1]s_partitioned FOR VALUES WITH (MODULUS 2, REMAINDER 1); |
| 148 | + DROP TABLE %[1]s CASCADE; |
| 149 | + ALTER TABLE %[1]s_partitioned RENAME TO %[1]s`, table, key)); err != nil { |
| 150 | + return fmt.Errorf("%s cannot be partitioned by %s: %w", table, key, err) |
| 151 | + } |
| 152 | + for _, fk := range foreignKeys { |
| 153 | + if _, err := pool.Exec(ctx, fk); err != nil { |
| 154 | + return fmt.Errorf("foreign key cannot reference %s partitioned by %s: %s: %w", table, key, fk, err) |
| 155 | + } |
| 156 | + } |
| 157 | + return nil |
| 158 | +} |
| 159 | + |
| 160 | +// fanOutCheck is a pgx tracer that explains each distinct statement on the |
| 161 | +// partitioned tables and fails when its plan reads more than one partition |
| 162 | +// of any of them. |
| 163 | +type fanOutCheck struct { |
| 164 | + key string |
| 165 | + tables []string |
| 166 | + partition *regexp.Regexp // matches a partition name, capturing its table |
| 167 | + allowed []string |
| 168 | + explain *pgxpool.Pool |
| 169 | + fail func(format string, args ...any) |
| 170 | + mu sync.Mutex |
| 171 | + seen map[string]bool |
| 172 | +} |
| 173 | + |
| 174 | +func newFanOutCheck(key string, tables []string, allowed []string, explain *pgxpool.Pool, fail func(string, ...any)) *fanOutCheck { |
| 175 | + return &fanOutCheck{ |
| 176 | + key: key, |
| 177 | + tables: tables, |
| 178 | + partition: regexp.MustCompile(`\b(` + strings.Join(tables, "|") + `)_p\d+\b`), |
| 179 | + allowed: allowed, |
| 180 | + explain: explain, |
| 181 | + fail: fail, |
| 182 | + seen: map[string]bool{}, |
| 183 | + } |
| 184 | +} |
| 185 | + |
| 186 | +func (c *fanOutCheck) TraceQueryStart(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryStartData) context.Context { |
| 187 | + sql := normalizeSQL(data.SQL) |
| 188 | + verb, _, _ := strings.Cut(strings.ToUpper(sql), " ") |
| 189 | + if !strings.Contains("SELECT INSERT UPDATE DELETE", verb) || !slices.ContainsFunc(c.tables, func(t string) bool { return strings.Contains(sql, t) }) { |
| 190 | + return ctx |
| 191 | + } |
| 192 | + if slices.Contains(c.allowed, sql) { |
| 193 | + return ctx |
| 194 | + } |
| 195 | + c.mu.Lock() |
| 196 | + defer c.mu.Unlock() |
| 197 | + if c.seen[sql] { |
| 198 | + return ctx |
| 199 | + } |
| 200 | + c.seen[sql] = true |
| 201 | + // EXPLAIN without ANALYZE plans but never executes, so writes are safe. |
| 202 | + var plan []string |
| 203 | + rows, err := c.explain.Query(ctx, "EXPLAIN "+data.SQL, data.Args...) |
| 204 | + if err == nil { |
| 205 | + plan, err = pgx.CollectRows(rows, pgx.RowTo[string]) |
| 206 | + } |
| 207 | + if err != nil { |
| 208 | + c.fail("explaining %s: %v", sql, err) |
| 209 | + return ctx |
| 210 | + } |
| 211 | + byTable := map[string][]string{} |
| 212 | + for _, m := range c.partition.FindAllStringSubmatch(strings.Join(plan, "\n"), -1) { |
| 213 | + byTable[m[1]] = append(byTable[m[1]], m[0]) |
| 214 | + } |
| 215 | + for table, partitions := range byTable { |
| 216 | + if partitions = slices.Compact(slices.Sorted(slices.Values(partitions))); len(partitions) > 1 { |
| 217 | + c.fail("statement on %s reads partitions %v instead of one; filter on %s or list it in TestActorsTablePartitionable:\n\t%s\n\targs=%v", table, partitions, c.key, sql, data.Args) |
| 218 | + } |
| 219 | + } |
| 220 | + return ctx |
| 221 | +} |
| 222 | + |
| 223 | +func (c *fanOutCheck) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {} |
| 224 | + |
| 225 | +func normalizeSQL(sql string) string { |
| 226 | + return strings.Join(strings.Fields(sql), " ") |
| 227 | +} |
| 228 | + |
| 229 | +// migratedPool opens a pool on a fresh schema with the migrations applied. |
| 230 | +func migratedPool(t *testing.T, schema string) *pgxpool.Pool { |
| 231 | + t.Helper() |
| 232 | + admin := requirePool(t) |
| 233 | + quoted := pgx.Identifier{schema}.Sanitize() |
| 234 | + if _, err := admin.Exec(t.Context(), `DROP SCHEMA IF EXISTS `+quoted+` CASCADE; CREATE SCHEMA `+quoted); err != nil { |
| 235 | + t.Fatal(err) |
| 236 | + } |
| 237 | + t.Cleanup(func() { _, _ = admin.Exec(context.Background(), `DROP SCHEMA IF EXISTS `+quoted+` CASCADE`) }) |
| 238 | + pool := openPool(t, schema, nil) |
| 239 | + p, err := NewPersistence(t.Context(), pool) |
| 240 | + if err != nil { |
| 241 | + t.Fatal(err) |
| 242 | + } |
| 243 | + p.Close() |
| 244 | + return pool |
| 245 | +} |
| 246 | + |
| 247 | +func partitionedPool(t *testing.T, schema, key string, tables []string) *pgxpool.Pool { |
| 248 | + t.Helper() |
| 249 | + pool := migratedPool(t, schema) |
| 250 | + for _, table := range tables { |
| 251 | + if err := partitionTable(t.Context(), pool, table, key); err != nil { |
| 252 | + t.Fatal(err) |
| 253 | + } |
| 254 | + } |
| 255 | + return pool |
| 256 | +} |
| 257 | + |
| 258 | +// openPool opens a pool on schema, tracing every statement with tracer. |
| 259 | +func openPool(t *testing.T, schema string, tracer pgx.QueryTracer) *pgxpool.Pool { |
| 260 | + t.Helper() |
| 261 | + cfg, err := pgxpool.ParseConfig(containerDSN) |
| 262 | + if err != nil { |
| 263 | + t.Fatal(err) |
| 264 | + } |
| 265 | + cfg.ConnConfig.RuntimeParams["search_path"] = pgx.Identifier{schema}.Sanitize() |
| 266 | + cfg.ConnConfig.Tracer = tracer |
| 267 | + pool, err := pgxpool.NewWithConfig(t.Context(), cfg) |
| 268 | + if err != nil { |
| 269 | + t.Fatal(err) |
| 270 | + } |
| 271 | + t.Cleanup(pool.Close) |
| 272 | + return pool |
| 273 | +} |
0 commit comments