Skip to content

Commit 808ac88

Browse files
committed
atepg: Add guardrail to preserve ability of actors table to be partitioned by atespace or name
1 parent 1ec75f9 commit 808ac88

2 files changed

Lines changed: 281 additions & 0 deletions

File tree

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
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+
}

‎docs/dev/postgresql-schema-evolution.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ If two schema structures hold the same data, keep them consistent while both bin
2727

2828
Do not run a large data backfill during startup. Propose a separate migration process before you add such a change.
2929

30+
## Keep the actors table partitionable
31+
32+
To preserve the option to partition the `actors` table by `atespace` or by `name`, and the other atespace-scoped tables (`actor_egress_policies`, `actor_templates`, `actor_snapshots`, `actor_snapshot_tags`) by `atespace`, do not add a schema change or a query that introduces:
33+
34+
- A unique index or constraint on one of these tables that omits `atespace`, or on `actors` that omits `name`.
35+
- A foreign key that references one of these tables by columns that omit `atespace`, or `actors` by anything other than `(atespace, name)`.
36+
- A query on one of these tables that does not filter on `atespace`. A statement whose result spans every partition by definition, such as a global list, must be listed in `TestActorsTablePartitionable`. Anything else that reads more than one partition needs an exemption there, agreed with the community.
37+
3038
## Expand and contract
3139

3240
Use an expand and contract sequence for a schema replacement or removal:

0 commit comments

Comments
 (0)