Skip to content

Commit 365bf6f

Browse files
radimclaude
andcommitted
test(cli): push/pull sync semantics
Seven tests pin the diff-by-content-hash sync loop and the cross-store wire format: - Empty dst gets every row Copied, zero UpToDate. - Pre-seeded dst reports UpToDate for matching hashes and Copied for the rest; dst ends up holding both. - Multi-node activity copies each node_source as its own row, no collisions, label preserved through List -> Get -> Put. - Kind order is schema -> planner -> activity, verified by pushing into a FilesystemStore dst (which enforces the orphan rule). - --all routes through src.ListKeys and surfaces every key in the output block. - The acceptance round trip: SQLite -> FilesystemStore -> fresh SQLite, asserting content_hashes match per kind. If the bundle JSON layout ever drifts between encoder and decoder, this loses symmetry first. - Empty-key sync prints a human notice, not an empty buffer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4c06688 commit 365bf6f

1 file changed

Lines changed: 361 additions & 0 deletions

File tree

cmd/dryrun/snapshot_sync_test.go

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"path/filepath"
7+
"sort"
8+
"testing"
9+
"time"
10+
11+
"github.com/boringsql/dryrun/internal/history"
12+
"github.com/boringsql/dryrun/internal/schema"
13+
)
14+
15+
func syncKey(project, database string) history.SnapshotKey {
16+
return history.SnapshotKey{
17+
ProjectID: history.ProjectId(project),
18+
DatabaseID: history.DatabaseId(database),
19+
}
20+
}
21+
22+
func syncTestSchema(hash, db string, ts time.Time) *schema.SchemaSnapshot {
23+
return &schema.SchemaSnapshot{
24+
PgVersion: "PostgreSQL 17.0",
25+
Database: db,
26+
Timestamp: ts,
27+
ContentHash: hash,
28+
Tables: []schema.Table{{Schema: "public", Name: "users"}},
29+
}
30+
}
31+
32+
func syncTestPlanner(schemaRef, hash, db string, ts time.Time) *schema.PlannerStatsSnapshot {
33+
return &schema.PlannerStatsSnapshot{
34+
SchemaRefHash: schemaRef,
35+
ContentHash: hash,
36+
Database: db,
37+
Timestamp: ts,
38+
Tables: []schema.TableSizingEntry{{
39+
Table: schema.QualifiedName{Schema: "public", Name: "users"},
40+
Sizing: schema.TableSizing{Reltuples: 1, Relpages: 1, TableSize: 8192},
41+
}},
42+
}
43+
}
44+
45+
func syncTestActivity(schemaRef, hash, source string, ts time.Time, standby bool) *schema.ActivityStatsSnapshot {
46+
return &schema.ActivityStatsSnapshot{
47+
SchemaRefHash: schemaRef,
48+
ContentHash: hash,
49+
Node: schema.NodeIdentity{
50+
Source: source, IsStandby: standby, PgVersion: "PostgreSQL 17.0",
51+
Timestamp: ts,
52+
},
53+
Tables: []schema.TableActivityEntry{{
54+
Table: schema.QualifiedName{Schema: "public", Name: "users"},
55+
Activity: schema.TableActivity{SeqScan: 1, IdxScan: 2},
56+
}},
57+
}
58+
}
59+
60+
func openSQLite(t *testing.T) *history.Store {
61+
t.Helper()
62+
store, err := history.Open(filepath.Join(t.TempDir(), "history.db"))
63+
if err != nil {
64+
t.Fatal(err)
65+
}
66+
t.Cleanup(func() { store.Close() })
67+
return store
68+
}
69+
70+
func openFS(t *testing.T) *history.FilesystemStore {
71+
t.Helper()
72+
store, err := history.NewFilesystemStore(t.TempDir())
73+
if err != nil {
74+
t.Fatal(err)
75+
}
76+
return store
77+
}
78+
79+
// TestSyncKeysCopiesEverythingToEmptyDst seeds src with one snapshot per
80+
// kind under a single key, points sync at an empty dst, and asserts every
81+
// row lands as Copied with zero UpToDate. The empty-destination case is
82+
// where push/pull does its actual work; a 0/0 result here would mean the
83+
// content-hash diff is silently dropping rows.
84+
func TestSyncKeysCopiesEverythingToEmptyDst(t *testing.T) {
85+
ctx := context.Background()
86+
src := openSQLite(t)
87+
dst := openSQLite(t)
88+
k := syncKey("acme", "primary")
89+
now := time.Now().UTC().Truncate(time.Second)
90+
91+
s := syncTestSchema("sh-1", "appdb", now.Add(-time.Hour))
92+
if _, err := src.PutSchema(ctx, k, s); err != nil {
93+
t.Fatal(err)
94+
}
95+
if _, err := src.PutPlanner(ctx, k, syncTestPlanner("sh-1", "pl-1", "appdb", now)); err != nil {
96+
t.Fatal(err)
97+
}
98+
if _, err := src.PutActivity(ctx, k, syncTestActivity("sh-1", "ac-1", "primary", now, false)); err != nil {
99+
t.Fatal(err)
100+
}
101+
102+
outs, err := syncKeys(ctx, src, dst, []history.SnapshotKey{k})
103+
if err != nil {
104+
t.Fatalf("syncKeys: %v", err)
105+
}
106+
if len(outs) != 1 {
107+
t.Fatalf("got %d outcomes, want 1", len(outs))
108+
}
109+
o := outs[0]
110+
want := func(label string, got, copied, uptodate int) {
111+
if got != copied {
112+
t.Errorf("%s.Copied = %d, want %d", label, got, copied)
113+
}
114+
}
115+
want("schema", o.Schema.Copied, 1, 0)
116+
want("planner", o.Planner.Copied, 1, 0)
117+
want("activity", o.Activity.Copied, 1, 0)
118+
if o.Schema.UpToDate+o.Planner.UpToDate+o.Activity.UpToDate != 0 {
119+
t.Errorf("expected zero up-to-date on empty dst, got schema=%d planner=%d activity=%d",
120+
o.Schema.UpToDate, o.Planner.UpToDate, o.Activity.UpToDate)
121+
}
122+
}
123+
124+
// TestSyncKeysReportsUpToDateForMatchingHashes pre-seeds dst with the same
125+
// schema content_hash that src has, then adds a *second* schema only to
126+
// src. The diff must report 1 Copied + 1 UpToDate — anything else means
127+
// the dedup gate is reading the wrong column or the set is being rebuilt
128+
// per row.
129+
func TestSyncKeysReportsUpToDateForMatchingHashes(t *testing.T) {
130+
ctx := context.Background()
131+
src := openSQLite(t)
132+
dst := openSQLite(t)
133+
k := syncKey("acme", "primary")
134+
now := time.Now().UTC().Truncate(time.Second)
135+
136+
shared := syncTestSchema("sh-shared", "appdb", now.Add(-2*time.Hour))
137+
if _, err := src.PutSchema(ctx, k, shared); err != nil {
138+
t.Fatal(err)
139+
}
140+
if _, err := dst.PutSchema(ctx, k, shared); err != nil {
141+
t.Fatal(err)
142+
}
143+
144+
// src-only new snapshot; must be the one Copied count
145+
fresh := syncTestSchema("sh-fresh", "appdb", now)
146+
if _, err := src.PutSchema(ctx, k, fresh); err != nil {
147+
t.Fatal(err)
148+
}
149+
150+
outs, err := syncKeys(ctx, src, dst, []history.SnapshotKey{k})
151+
if err != nil {
152+
t.Fatalf("syncKeys: %v", err)
153+
}
154+
o := outs[0]
155+
if o.Schema.Copied != 1 || o.Schema.UpToDate != 1 {
156+
t.Errorf("schema counts = {Copied:%d UpToDate:%d}, want {1, 1}", o.Schema.Copied, o.Schema.UpToDate)
157+
}
158+
159+
// verify dst now actually holds both hashes
160+
list, err := dst.ListSchema(ctx, k, history.TimeRange{})
161+
if err != nil {
162+
t.Fatal(err)
163+
}
164+
got := map[string]bool{}
165+
for _, s := range list {
166+
got[s.ContentHash] = true
167+
}
168+
if !got["sh-shared"] || !got["sh-fresh"] {
169+
t.Errorf("dst missing a hash after sync: got %+v", got)
170+
}
171+
}
172+
173+
// TestSyncCopiesActivityPerNodeLabel: three activity rows under three
174+
// distinct node_source values must each land on dst keyed by the right
175+
// label. The risk this guards is a regression where ActivityKind("") on
176+
// List loses the label and Put on dst collapses everything under a single
177+
// node — silently destroying the multi-node fanout.
178+
func TestSyncCopiesActivityPerNodeLabel(t *testing.T) {
179+
ctx := context.Background()
180+
src := openSQLite(t)
181+
dst := openSQLite(t)
182+
k := syncKey("acme", "primary")
183+
now := time.Now().UTC().Truncate(time.Second)
184+
185+
if _, err := src.PutSchema(ctx, k, syncTestSchema("sh-1", "appdb", now.Add(-time.Hour))); err != nil {
186+
t.Fatal(err)
187+
}
188+
// schema must exist on dst too so the FilesystemStore-equivalent orphan
189+
// rule (when dst is a FS store) wouldn't reject; here dst is SQLite, but
190+
// we seed schema anyway to keep the test reflective of real sync order.
191+
if _, err := dst.PutSchema(ctx, k, syncTestSchema("sh-1", "appdb", now.Add(-time.Hour))); err != nil {
192+
t.Fatal(err)
193+
}
194+
195+
sources := []string{"primary", "replica-a", "replica-b"}
196+
for i, src1 := range sources {
197+
a := syncTestActivity("sh-1", "ac-"+src1, src1, now.Add(time.Duration(i)*time.Minute), src1 != "primary")
198+
if _, err := src.PutActivity(ctx, k, a); err != nil {
199+
t.Fatal(err)
200+
}
201+
}
202+
203+
outs, err := syncKeys(ctx, src, dst, []history.SnapshotKey{k})
204+
if err != nil {
205+
t.Fatalf("syncKeys: %v", err)
206+
}
207+
if outs[0].Activity.Copied != 3 {
208+
t.Errorf("activity copied = %d, want 3", outs[0].Activity.Copied)
209+
}
210+
211+
dstList, err := dst.List(ctx, k, history.ActivityKind(""), history.TimeRange{})
212+
if err != nil {
213+
t.Fatal(err)
214+
}
215+
gotLabels := make([]string, 0, len(dstList))
216+
for _, s := range dstList {
217+
gotLabels = append(gotLabels, s.NodeLabel)
218+
}
219+
sort.Strings(gotLabels)
220+
want := []string{"primary", "replica-a", "replica-b"}
221+
if len(gotLabels) != len(want) {
222+
t.Fatalf("dst activity labels = %v, want %v", gotLabels, want)
223+
}
224+
for i := range want {
225+
if gotLabels[i] != want[i] {
226+
t.Errorf("labels[%d] = %q, want %q", i, gotLabels[i], want[i])
227+
}
228+
}
229+
}
230+
231+
// TestSyncKindOrderIsSchemaPlannerActivity pushes into a FilesystemStore
232+
// destination, which enforces the orphan rule: any planner/activity put
233+
// before the matching schema bundle exists will fail. If kindOrder ever
234+
// regressed (e.g. someone reordered it alphabetically), this test would
235+
// blow up with ErrOrphanSnapshot. It's the cheapest insurance against
236+
// that class of refactor mistake.
237+
func TestSyncKindOrderIsSchemaPlannerActivity(t *testing.T) {
238+
ctx := context.Background()
239+
src := openSQLite(t)
240+
dst := openFS(t)
241+
k := syncKey("acme", "primary")
242+
now := time.Now().UTC().Truncate(time.Second)
243+
244+
if _, err := src.PutSchema(ctx, k, syncTestSchema("sh-1", "appdb", now.Add(-time.Hour))); err != nil {
245+
t.Fatal(err)
246+
}
247+
if _, err := src.PutPlanner(ctx, k, syncTestPlanner("sh-1", "pl-1", "appdb", now)); err != nil {
248+
t.Fatal(err)
249+
}
250+
if _, err := src.PutActivity(ctx, k, syncTestActivity("sh-1", "ac-1", "primary", now, false)); err != nil {
251+
t.Fatal(err)
252+
}
253+
254+
if _, err := syncKeys(ctx, src, dst, []history.SnapshotKey{k}); err != nil {
255+
t.Fatalf("syncKeys against FilesystemStore dst: %v", err)
256+
}
257+
}
258+
259+
// TestSyncAllUsesListKeys: a push/pull with --all must iterate every key
260+
// in the source rather than the resolved profile key. We drive runSync
261+
// directly with all=true against a multi-key src and assert both keys
262+
// surface in the output block.
263+
func TestSyncAllUsesListKeys(t *testing.T) {
264+
ctx := context.Background()
265+
src := openSQLite(t)
266+
dst := openSQLite(t)
267+
now := time.Now().UTC().Truncate(time.Second)
268+
269+
for _, k := range []history.SnapshotKey{syncKey("acme", "primary"), syncKey("zeta", "replica")} {
270+
if _, err := src.PutSchema(ctx, k, syncTestSchema("sh-"+string(k.ProjectID), "appdb", now)); err != nil {
271+
t.Fatal(err)
272+
}
273+
}
274+
275+
var buf bytes.Buffer
276+
if err := runSync(ctx, src, dst, true, &buf); err != nil {
277+
t.Fatalf("runSync(all=true): %v", err)
278+
}
279+
out := buf.String()
280+
for _, want := range []string{"acme/primary", "zeta/replica"} {
281+
if !bytes.Contains(buf.Bytes(), []byte(want)) {
282+
t.Errorf("output missing %q:\n%s", want, out)
283+
}
284+
}
285+
}
286+
287+
// TestRoundTripSQLiteToFsToSQLite is the acceptance test for v0.7's wire
288+
// format. We seed a SQLite Store, push it to a FilesystemStore, then pull
289+
// from that FilesystemStore into a *fresh* SQLite Store and confirm every
290+
// summary on the second SQLite store matches the first by content_hash.
291+
// If the bundle JSON shape drifts between encoder and decoder — a missing
292+
// snake_case alias, a swapped omitempty — this round trip stops being
293+
// symmetric and the test catches it.
294+
func TestRoundTripSQLiteToFsToSQLite(t *testing.T) {
295+
ctx := context.Background()
296+
srcA := openSQLite(t)
297+
fsMid := openFS(t)
298+
dstB := openSQLite(t)
299+
k := syncKey("acme", "primary")
300+
now := time.Now().UTC().Truncate(time.Second)
301+
302+
if _, err := srcA.PutSchema(ctx, k, syncTestSchema("sh-1", "appdb", now.Add(-2*time.Hour))); err != nil {
303+
t.Fatal(err)
304+
}
305+
if _, err := srcA.PutPlanner(ctx, k, syncTestPlanner("sh-1", "pl-1", "appdb", now.Add(-time.Hour))); err != nil {
306+
t.Fatal(err)
307+
}
308+
for _, src := range []string{"primary", "replica-a"} {
309+
a := syncTestActivity("sh-1", "ac-"+src, src, now, src != "primary")
310+
if _, err := srcA.PutActivity(ctx, k, a); err != nil {
311+
t.Fatal(err)
312+
}
313+
}
314+
315+
if _, err := syncKeys(ctx, srcA, fsMid, []history.SnapshotKey{k}); err != nil {
316+
t.Fatalf("push A -> FS: %v", err)
317+
}
318+
if _, err := syncKeys(ctx, fsMid, dstB, []history.SnapshotKey{k}); err != nil {
319+
t.Fatalf("pull FS -> B: %v", err)
320+
}
321+
322+
cmp := func(label string, a, b []history.SnapshotSummary) {
323+
if len(a) != len(b) {
324+
t.Errorf("%s: len A=%d B=%d", label, len(a), len(b))
325+
return
326+
}
327+
ah := map[string]bool{}
328+
for _, s := range a {
329+
ah[s.ContentHash] = true
330+
}
331+
for _, s := range b {
332+
if !ah[s.ContentHash] {
333+
t.Errorf("%s: B has content_hash %q missing from A", label, s.ContentHash)
334+
}
335+
}
336+
}
337+
for _, kind := range []history.SnapshotKind{
338+
history.SchemaKind(), history.PlannerKind(), history.ActivityKind(""),
339+
} {
340+
a, err := srcA.List(ctx, k, kind, history.TimeRange{})
341+
if err != nil {
342+
t.Fatal(err)
343+
}
344+
b, err := dstB.List(ctx, k, kind, history.TimeRange{})
345+
if err != nil {
346+
t.Fatal(err)
347+
}
348+
cmp(kind.String(), a, b)
349+
}
350+
}
351+
352+
// TestPrintSyncOutcomesEmpty: with no keys to sync, the output must be a
353+
// single human-readable line — not an empty buffer. CI scripts grep for
354+
// this; silence would be misread as a hang.
355+
func TestPrintSyncOutcomesEmpty(t *testing.T) {
356+
var buf bytes.Buffer
357+
printSyncOutcomes(&buf, nil)
358+
if !bytes.Contains(buf.Bytes(), []byte("No keys to sync")) {
359+
t.Errorf("got %q, want a 'No keys to sync' notice", buf.String())
360+
}
361+
}

0 commit comments

Comments
 (0)