carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Package litestreamfacts establishes, by observation of the pinned
2// libraries (litestream v0.5.14, superfly/ltx v0.5.1), the facts the
3// move-any-app spec's restore contract rests on (moves spec rev 7.7 §5.1;
4// joint rounds 5 and 6, R5-1 to R5-3, R6-2, R6-5). It is test-only and
5// drives litestream as internal/lease does, with the file replica client
6// the lease tests use. Each test asserts the exact fact the spec relies on
7// and fails on any alternative behaviour, so a library upgrade that
8// changes a fact fails here first (R6-2): a passing run is the contract,
9// not a record of one observation. TestGate_AnchoredLineageThroughStore
10// takes one anchored lineage through the production Store configuration
11// (internal/lease's Held.Replicate, intervals shortened) with repeated
12// writes, compaction, snapshot rotation, time-based level-0 expiry, disk
13// loss and repeated cold restores, asserting exact positions and contents.
14//
15// What the file client cannot show (R6 and R7 reviews): S3 lists LTX
16// files in filename order and ages them for retention by LastModified,
17// the file client by the encoded LTX timestamp kept as the file's mtime;
18// S3 keeps versions and delete markers, the file client replaces and
19// deletes outright; S3 requests can complete late, and an upload
20// outstanding across a writer replacement is the R7-1 hazard the model
21// (internal/sim/hostlife) encodes. The repository has no S3 fake that
22// litestream's client can talk to (the lease tests' fakeMirror is a
23// MirrorStore seam), so those remain the model's and §15 step 8's.
24// TestFact_R7_1_UnconditionalPut reads the pinned S3 client's source for
25// the two facts the hazard rests on: an unconditional PutObject, and a key
26// of prefix, level and TXID range only.
27//
28// The observed facts are recorded in ~/.cache/nearest-region/litestream-facts.md.
29package litestreamfacts
30
31import (
32 "bytes"
33 "context"
34 "database/sql"
35 "fmt"
36 "go/build"
37 "io"
38 "os"
39 "path/filepath"
40 "strings"
41 "testing"
42 "time"
43
44 "github.com/benbjohnson/litestream"
45 lsfile "github.com/benbjohnson/litestream/file"
46 "github.com/superfly/ltx"
47 _ "modernc.org/sqlite"
48)
49
50// openDB opens path under litestream against client, with its own metadata
51// directory, no background monitors (every sync is explicit), and returns
52// the DB. The caller closes it.
53func openDB(t *testing.T, path, meta string, client litestream.ReplicaClient) *litestream.DB {
54 t.Helper()
55 db := litestream.NewDB(path)
56 db.SetMetaPath(meta)
57 db.MonitorInterval = time.Hour
58 db.Replica = litestream.NewReplicaWithClient(db, client)
59 db.Replica.SyncInterval = time.Hour
60 if err := db.Open(); err != nil {
61 t.Fatalf("open %s: %v", path, err)
62 }
63 return db
64}
65
66func closeDB(t *testing.T, db *litestream.DB) {
67 t.Helper()
68 if err := db.Close(context.Background()); err != nil {
69 t.Fatalf("close: %v", err)
70 }
71}
72
73// write inserts n rows, each in its own transaction, so each is a
74// transaction litestream can see.
75func write(t *testing.T, path string, n int) {
76 t.Helper()
77 d, err := sql.Open("sqlite", path)
78 if err != nil {
79 t.Fatal(err)
80 }
81 defer d.Close()
82 if _, err := d.Exec(`CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, body TEXT)`); err != nil {
83 t.Fatal(err)
84 }
85 for i := 0; i < n; i++ {
86 if _, err := d.Exec(`INSERT INTO t (body) VALUES (?)`, strings.Repeat("x", 100)); err != nil {
87 t.Fatal(err)
88 }
89 }
90}
91
92// rows reads the table's contents, id -> body, so a restore can be
93// compared row by row, updates and deletes included (R7-5).
94func rows(t *testing.T, path string) map[int]string {
95 t.Helper()
96 d, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
97 if err != nil {
98 t.Fatal(err)
99 }
100 defer d.Close()
101 rs, err := d.Query(`SELECT id, body FROM t ORDER BY id`)
102 if err != nil {
103 t.Fatal(err)
104 }
105 defer rs.Close()
106 out := map[int]string{}
107 for rs.Next() {
108 var id int
109 var body string
110 if err := rs.Scan(&id, &body); err != nil {
111 t.Fatal(err)
112 }
113 out[id] = body
114 }
115 return out
116}
117
118// mustRows fails unless the database at path holds exactly want.
119func mustRows(t *testing.T, path string, want map[int]string) {
120 t.Helper()
121 got := rows(t, path)
122 if len(got) != len(want) {
123 t.Fatalf("%s: %d rows, want %d", filepath.Base(path), len(got), len(want))
124 }
125 for id, body := range want {
126 if got[id] != body {
127 t.Fatalf("%s: row %d is %q, want %q", filepath.Base(path), id, got[id], body)
128 }
129 }
130}
131
132// exec runs one statement in its own transaction.
133func exec(t *testing.T, path, stmt string, args ...any) {
134 t.Helper()
135 d, err := sql.Open("sqlite", path)
136 if err != nil {
137 t.Fatal(err)
138 }
139 defer d.Close()
140 if _, err := d.Exec(stmt, args...); err != nil {
141 t.Fatal(err)
142 }
143}
144
145// waitFor polls cond until it holds or the deadline passes (R7-5: a
146// completion condition, not a sleep).
147func waitFor(t *testing.T, what string, d time.Duration, cond func() bool) {
148 t.Helper()
149 deadline := time.Now().Add(d)
150 for !cond() {
151 if time.Now().After(deadline) {
152 t.Fatalf("timed out waiting for %s", what)
153 }
154 time.Sleep(20 * time.Millisecond)
155 }
156}
157
158func count(t *testing.T, path string) int {
159 t.Helper()
160 d, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
161 if err != nil {
162 t.Fatal(err)
163 }
164 defer d.Close()
165 var n int
166 if err := d.QueryRow(`SELECT count(*) FROM t`).Scan(&n); err != nil {
167 t.Fatal(err)
168 }
169 return n
170}
171
172func sync(t *testing.T, db *litestream.DB) {
173 t.Helper()
174 if err := db.SyncAndWait(context.Background()); err != nil {
175 t.Fatalf("sync: %v", err)
176 }
177}
178
179// files lists one level of a replica, in litestream's order.
180func files(t *testing.T, client litestream.ReplicaClient, level int) []*ltx.FileInfo {
181 t.Helper()
182 itr, err := client.LTXFiles(context.Background(), level, 0, false)
183 if err != nil {
184 t.Fatal(err)
185 }
186 infos, err := ltx.SliceFileIterator(itr)
187 if err != nil {
188 t.Fatal(err)
189 }
190 return infos
191}
192
193func ranges(infos []*ltx.FileInfo) string {
194 var out []string
195 for _, f := range infos {
196 out = append(out, fmt.Sprintf("[%d,%d]", f.MinTXID, f.MaxTXID))
197 }
198 if len(out) == 0 {
199 return "none"
200 }
201 return strings.Join(out, " ")
202}
203
204// copyFile copies one LTX file between replica clients, as the install's
205// CopyObject would.
206func copyFile(t *testing.T, from, to litestream.ReplicaClient, info *ltx.FileInfo) {
207 t.Helper()
208 ctx := context.Background()
209 rc, err := from.OpenLTXFile(ctx, info.Level, info.MinTXID, info.MaxTXID, 0, 0)
210 if err != nil {
211 t.Fatal(err)
212 }
213 defer rc.Close()
214 if _, err := to.WriteLTXFile(ctx, info.Level, info.MinTXID, info.MaxTXID, rc); err != nil {
215 t.Fatal(err)
216 }
217}
218
219// restore restores client's newest state to out, litestream's own planner.
220func restore(t *testing.T, client litestream.ReplicaClient, out string) error {
221 t.Helper()
222 r := litestream.NewReplicaWithClient(nil, client)
223 opt := litestream.NewRestoreOptions()
224 opt.OutputPath = out
225 return r.Restore(context.Background(), opt)
226}
227
228// decode reads an LTX file's header and trailer and verifies it.
229func decode(t *testing.T, client litestream.ReplicaClient, info *ltx.FileInfo) (ltx.Header, ltx.Trailer) {
230 t.Helper()
231 rc, err := client.OpenLTXFile(context.Background(), info.Level, info.MinTXID, info.MaxTXID, 0, 0)
232 if err != nil {
233 t.Fatal(err)
234 }
235 defer rc.Close()
236 b, err := io.ReadAll(rc)
237 if err != nil {
238 t.Fatal(err)
239 }
240 dec := ltx.NewDecoder(bytes.NewReader(b))
241 if err := dec.Verify(); err != nil {
242 t.Fatalf("verify %s: %v", ltx.FormatFilename(info.MinTXID, info.MaxTXID), err)
243 }
244 return dec.Header(), dec.Trailer()
245}
246
247// encodeAnchor re-encodes a snapshot [1,N] as a level-0 file [N,N] with
248// every page: the file litestream's position initialization fetches, so
249// that replication continues from N (R5-1, the remedy under test). The
250// header timestamp is an input (R6-5): with the same inputs the bytes are
251// identical, which is what lets a replayed PUT repeat the same object.
252func encodeAnchor(t *testing.T, from litestream.ReplicaClient, snap *ltx.FileInfo, ts int64) []byte {
253 t.Helper()
254 ctx := context.Background()
255 rc, err := from.OpenLTXFile(ctx, snap.Level, snap.MinTXID, snap.MaxTXID, 0, 0)
256 if err != nil {
257 t.Fatal(err)
258 }
259 defer rc.Close()
260 dec := ltx.NewDecoder(rc)
261 if err := dec.DecodeHeader(); err != nil {
262 t.Fatal(err)
263 }
264 hdr := dec.Header()
265 var buf bytes.Buffer
266 enc, err := ltx.NewEncoder(&buf)
267 if err != nil {
268 t.Fatal(err)
269 }
270 // WALOffset at the WAL header: litestream's verify reads the position
271 // from the newest local level-0 file and, finding the WAL does not
272 // match it, takes a fresh snapshot at the next TXID; an offset below
273 // the header size is refused outright ("prev WAL offset is less than
274 // the header size", observed)
275 if err := enc.EncodeHeader(ltx.Header{
276 Version: ltx.Version,
277 Flags: ltx.HeaderFlagNoChecksum,
278 PageSize: hdr.PageSize,
279 Commit: hdr.Commit,
280 MinTXID: snap.MaxTXID,
281 MaxTXID: snap.MaxTXID,
282 Timestamp: ts,
283 WALOffset: litestream.WALHeaderSize,
284 }); err != nil {
285 t.Fatal(err)
286 }
287 page := make([]byte, hdr.PageSize)
288 for {
289 var ph ltx.PageHeader
290 if err := dec.DecodePage(&ph, page); err == io.EOF {
291 break
292 } else if err != nil {
293 t.Fatal(err)
294 }
295 if err := enc.EncodePage(ph, page); err != nil {
296 t.Fatal(err)
297 }
298 }
299 if err := dec.Close(); err != nil {
300 t.Fatal(err)
301 }
302 if err := enc.Close(); err != nil {
303 t.Fatal(err)
304 }
305 return buf.Bytes()
306}
307
308// anchorL0 writes the anchor for snap into to.
309func anchorL0(t *testing.T, from litestream.ReplicaClient, snap *ltx.FileInfo, to litestream.ReplicaClient) {
310 t.Helper()
311 b := encodeAnchor(t, from, snap, time.Now().UnixMilli())
312 if _, err := to.WriteLTXFile(context.Background(), 0, snap.MaxTXID, snap.MaxTXID, bytes.NewReader(b)); err != nil {
313 t.Fatal(err)
314 }
315}
316
317// mustRanges fails unless the level's current files are exactly want.
318func mustRanges(t *testing.T, client litestream.ReplicaClient, level int, want string) {
319 t.Helper()
320 if got := ranges(files(t, client, level)); got != want {
321 t.Fatalf("level %d: have %s, want exactly %s", level, got, want)
322 }
323}
324
325// mustCount fails unless the database at path holds exactly want rows.
326func mustCount(t *testing.T, path string, want int) {
327 t.Helper()
328 if n := count(t, path); n != want {
329 t.Fatalf("%s: %d rows, want exactly %d", filepath.Base(path), n, want)
330 }
331}
332
333// source builds a replicated source database with n rows and a level-9
334// snapshot of it, and returns the client and the snapshot's info.
335func source(t *testing.T, n int) (litestream.ReplicaClient, *ltx.FileInfo) {
336 t.Helper()
337 dir := t.TempDir()
338 path := filepath.Join(dir, "src.db")
339 write(t, path, 1)
340 client := lsfile.NewReplicaClient(filepath.Join(dir, "replica"))
341 db := openDB(t, path, filepath.Join(dir, "meta"), client)
342 sync(t, db)
343 write(t, path, n-1)
344 sync(t, db)
345 snap, err := db.Snapshot(context.Background())
346 if err != nil {
347 t.Fatal(err)
348 }
349 closeDB(t, db)
350 t.Logf("source: L0 %s; snapshot [%d,%d]", ranges(files(t, client, 0)), snap.MinTXID, snap.MaxTXID)
351 return client, snap
352}
353
354// wipe removes the local database, its WAL and SHM, and litestream's
355// metadata: a lost disk.
356func wipe(t *testing.T, path, meta string) {
357 t.Helper()
358 for _, p := range []string{path, path + "-wal", path + "-shm", path + "-journal"} {
359 os.Remove(p)
360 }
361 if err := os.RemoveAll(meta); err != nil {
362 t.Fatal(err)
363 }
364}
365
366// TestFact_R5_1_SnapshotOnlyInstallRestartsAtOne: install only the level-9
367// snapshot [1,N], restore it, start replication with fresh metadata and no
368// remote level 0. The next upload carries TXID 1, not N+1, and a cold
369// restore afterwards loses the new writes.
370func TestFact_R5_1_SnapshotOnlyInstallRestartsAtOne(t *testing.T) {
371 src, snap := source(t, 5)
372 dir := t.TempDir()
373 gen := lsfile.NewReplicaClient(filepath.Join(dir, "gen"))
374 copyFile(t, src, gen, snap)
375 out := filepath.Join(dir, "out.db")
376 if err := restore(t, gen, out); err != nil {
377 t.Fatal(err)
378 }
379 if n := count(t, out); n != 5 {
380 t.Fatalf("restored %d rows, want 5", n)
381 }
382 meta := filepath.Join(dir, "meta")
383 db := openDB(t, out, meta, gen)
384 write(t, out, 2)
385 sync(t, db)
386 closeDB(t, db)
387 l0 := files(t, gen, 0)
388 t.Logf("FACT R5-1a: after a snapshot-only install (snapshot [1,%d]) the first upload is level 0 %s", snap.MaxTXID, ranges(l0))
389 if len(l0) == 0 || l0[0].MinTXID != 1 {
390 t.Fatalf("expected replication to restart at TXID 1, got %s", ranges(l0))
391 }
392 wipe(t, out, meta)
393 out2 := filepath.Join(dir, "out2.db")
394 if err := restore(t, gen, out2); err != nil {
395 t.Fatal(err)
396 }
397 n := count(t, out2)
398 t.Logf("FACT R5-1b: a cold restore after that holds %d rows of 7: the planner roots at the snapshot and skips level-0 files below its TXID", n)
399 if n != 5 {
400 t.Fatalf("expected the two new rows to be lost (5 rows), got %d", n)
401 }
402}
403
404// TestFact_R5_1_AnchorL0ContinuesFromN: the remedy. The install also writes
405// a level-0 file [N,N] with every page. Litestream's open fetches the
406// newest remote level-0 file as its local position, so the next upload
407// carries N+1 and a cold restore has the new writes.
408func TestFact_R5_1_AnchorL0ContinuesFromN(t *testing.T) {
409 src, snap := source(t, 5)
410 dir := t.TempDir()
411 gen := lsfile.NewReplicaClient(filepath.Join(dir, "gen"))
412 copyFile(t, src, gen, snap)
413 anchorL0(t, src, snap, gen)
414 out := filepath.Join(dir, "out.db")
415 if err := restore(t, gen, out); err != nil {
416 t.Fatal(err)
417 }
418 if n := count(t, out); n != 5 {
419 t.Fatalf("restored %d rows, want 5", n)
420 }
421 meta := filepath.Join(dir, "meta")
422 db := openDB(t, out, meta, gen)
423 write(t, out, 2)
424 sync(t, db)
425 closeDB(t, db)
426 l0 := files(t, gen, 0)
427 t.Logf("FACT R5-1c: with a level-0 anchor [%d,%d] beside the snapshot, level 0 after two writes and one sync is %s", snap.MaxTXID, snap.MaxTXID, ranges(l0))
428 // exactly: the anchor, then the next TXID N+1 and nothing else (R6-2)
429 n := snap.MaxTXID
430 mustRanges(t, gen, 0, fmt.Sprintf("[%d,%d] [%d,%d]", n, n, n+1, n+1))
431 wipe(t, out, meta)
432 out2 := filepath.Join(dir, "out2.db")
433 if err := restore(t, gen, out2); err != nil {
434 t.Fatal(err)
435 }
436 t.Logf("FACT R5-1d: a cold restore after that holds %d rows of 7", count(t, out2))
437 mustCount(t, out2, 7)
438}
439
440// TestFact_R5_1_TXIDSidecarIsFollowOnly: a -txid sidecar beside the
441// restored database does not set the replication position of an
442// ordinary open.
443func TestFact_R5_1_TXIDSidecarIsFollowOnly(t *testing.T) {
444 src, snap := source(t, 5)
445 dir := t.TempDir()
446 gen := lsfile.NewReplicaClient(filepath.Join(dir, "gen"))
447 copyFile(t, src, gen, snap)
448 out := filepath.Join(dir, "out.db")
449 if err := restore(t, gen, out); err != nil {
450 t.Fatal(err)
451 }
452 if err := litestream.WriteTXIDFile(out, snap.MaxTXID); err != nil {
453 t.Fatal(err)
454 }
455 db := openDB(t, out, filepath.Join(dir, "meta"), gen)
456 write(t, out, 1)
457 sync(t, db)
458 closeDB(t, db)
459 l0 := files(t, gen, 0)
460 t.Logf("FACT R5-1e: with a -txid sidecar of %d, the first upload is level 0 %s", snap.MaxTXID, ranges(l0))
461 if len(l0) == 0 || l0[0].MinTXID != 1 {
462 t.Fatalf("the sidecar unexpectedly set the position: %s", ranges(l0))
463 }
464}
465
466// TestFact_R5_1_L0ExpiryAndAFreshBox: a fresh box's position after
467// litestream's own level-0 expiry. TXID-based retention keeps the newest
468// level-0 file, and so does time-based retention (L0Retention, the store's
469// monitor); a fresh box restored from the replica therefore continues at
470// N+1 and a cold restore afterwards holds every row. Each of those is
471// asserted exactly (R6-2): a library that removed the newest level-0 file,
472// restarted numbering or lost the new writes fails here.
473func TestFact_R5_1_L0ExpiryAndAFreshBox(t *testing.T) {
474 dir := t.TempDir()
475 path := filepath.Join(dir, "src.db")
476 write(t, path, 1)
477 client := lsfile.NewReplicaClient(filepath.Join(dir, "replica"))
478 db := openDB(t, path, filepath.Join(dir, "meta"), client)
479 for i := 0; i < 3; i++ {
480 write(t, path, 2)
481 sync(t, db)
482 }
483 if _, err := db.Compact(context.Background(), 1); err != nil {
484 t.Fatal(err)
485 }
486 snap, err := db.Snapshot(context.Background())
487 if err != nil {
488 t.Fatal(err)
489 }
490 // TXID-based retention at the snapshot level
491 if err := db.EnforceRetentionByTXID(context.Background(), 0, snap.MaxTXID+1); err != nil {
492 t.Fatal(err)
493 }
494 n := snap.MaxTXID // the newest level-0 file's TXID, the position
495 newest := fmt.Sprintf("[%d,%d]", n, n)
496 byTXID := ranges(files(t, client, 0))
497 if byTXID != newest {
498 t.Fatalf("TXID-based retention at %d left level 0 %s, want exactly the newest file %s retained", n+1, byTXID, newest)
499 }
500 // time-based level-0 expiry, as the store's L0 retention monitor runs it
501 db.L0Retention = time.Millisecond
502 time.Sleep(20 * time.Millisecond)
503 if err := db.EnforceL0RetentionByTime(context.Background()); err != nil {
504 t.Fatal(err)
505 }
506 closeDB(t, db)
507 t.Logf("FACT R5-1f: after TXID-based level-0 retention at %d the level-0 listing is %s; after time-based L0 expiry it is %s (L9 %s; L1 %s)", n+1, byTXID, ranges(files(t, client, 0)), ranges(files(t, client, 9)), ranges(files(t, client, 1)))
508 mustRanges(t, client, 0, newest) // the newest level-0 file survives time-based expiry too, by identity
509 out := filepath.Join(dir, "out.db")
510 if err := restore(t, client, out); err != nil {
511 t.Fatal(err)
512 }
513 mustCount(t, out, 7)
514 meta := filepath.Join(dir, "meta2")
515 db2 := openDB(t, out, meta, client)
516 write(t, out, 2)
517 sync(t, db2)
518 closeDB(t, db2)
519 t.Logf("FACT R5-1g: on a fresh box restored at TXID %d, the first upload is level 0 %s", n, ranges(files(t, client, 0)))
520 mustRanges(t, client, 0, fmt.Sprintf("%s [%d,%d]", newest, n+1, n+1)) // exactly N+1: a restart at 1 fails here
521 wipe(t, out, meta)
522 out2 := filepath.Join(dir, "out2.db")
523 if err := restore(t, client, out2); err != nil {
524 t.Fatal(err)
525 }
526 t.Logf("FACT R5-1h: a cold restore after that holds %d rows of 9", count(t, out2))
527 mustCount(t, out2, 9) // every row: a run that kept 7 of 9 fails here
528}
529
530// TestFact_R5_2_Checksums: what the pinned writers put in LTX headers and
531// trailers: the level-0 writer, the snapshot writer and the compactor.
532func TestFact_R5_2_Checksums(t *testing.T) {
533 dir := t.TempDir()
534 path := filepath.Join(dir, "src.db")
535 write(t, path, 1)
536 client := lsfile.NewReplicaClient(filepath.Join(dir, "replica"))
537 db := openDB(t, path, filepath.Join(dir, "meta"), client)
538 for i := 0; i < 3; i++ {
539 write(t, path, 2)
540 sync(t, db)
541 }
542 snap, err := db.Snapshot(context.Background())
543 if err != nil {
544 t.Fatal(err)
545 }
546 l1, err := db.Compact(context.Background(), 1)
547 if err != nil {
548 t.Fatal(err)
549 }
550 closeDB(t, db)
551 check := func(name string, info *ltx.FileInfo) {
552 hdr, tr := decode(t, client, info)
553 t.Logf("FACT R5-2 %s [%d,%d]: HeaderFlagNoChecksum=%v PreApplyChecksum=%#x PostApplyChecksum=%#x FileChecksum!=0=%v (Verify passed: CRC-64 of the file)",
554 name, info.MinTXID, info.MaxTXID, hdr.NoChecksum(), uint64(hdr.PreApplyChecksum), uint64(tr.PostApplyChecksum), tr.FileChecksum != 0)
555 if !hdr.NoChecksum() || tr.PostApplyChecksum != 0 || tr.FileChecksum == 0 {
556 t.Fatalf("%s: unexpected checksum fields", name)
557 }
558 }
559 l0 := files(t, client, 0)
560 check("level 0", l0[len(l0)-1])
561 check("snapshot", snap)
562 check("level 1 (compactor)", l1)
563}
564
565// TestFact_R5_3_SnapshotRetention: newer snapshots appear, and snapshot
566// retention expires older snapshots and the lower-level ranges below the
567// oldest remaining one. Afterwards no current file continues from the
568// original snapshot's TXID, so a restore rooted there finds a gap, while
569// litestream's own restore from the newest snapshot succeeds.
570func TestFact_R5_3_SnapshotRetention(t *testing.T) {
571 dir := t.TempDir()
572 path := filepath.Join(dir, "src.db")
573 write(t, path, 1)
574 client := lsfile.NewReplicaClient(filepath.Join(dir, "replica"))
575 db := openDB(t, path, filepath.Join(dir, "meta"), client)
576 sync(t, db)
577 snap1, err := db.Snapshot(context.Background())
578 if err != nil {
579 t.Fatal(err)
580 }
581 // keep the original snapshot's bytes: on S3 it would be a pinned,
582 // noncurrent version; the file client deletes outright
583 rc, err := client.OpenLTXFile(context.Background(), 9, snap1.MinTXID, snap1.MaxTXID, 0, 0)
584 if err != nil {
585 t.Fatal(err)
586 }
587 snap1Bytes, _ := io.ReadAll(rc)
588 rc.Close()
589 // two more rotations: writes, a level-1 compaction, a snapshot, as the
590 // store's monitors would over days
591 var snap2 *ltx.FileInfo
592 for round := 0; round < 2; round++ {
593 for i := 0; i < 3; i++ {
594 write(t, path, 2)
595 sync(t, db)
596 }
597 if _, err := db.Compact(context.Background(), 1); err != nil {
598 t.Fatal(err)
599 }
600 time.Sleep(20 * time.Millisecond)
601 if snap2, err = db.Snapshot(context.Background()); err != nil {
602 t.Fatal(err)
603 }
604 }
605 before := fmt.Sprintf("L9 %s; L1 %s; L0 %s", ranges(files(t, client, 9)), ranges(files(t, client, 1)), ranges(files(t, client, 0)))
606 // snapshot retention keeps the newest snapshot; minSnapshotTXID is the
607 // MaxTXID of the snapshot before the oldest kept one, and the store
608 // then expires levels 1 and up below it (level 0 expires by time,
609 // L0Retention, 5 minutes by default: here it is enforced by TXID too)
610 minSnap, err := db.EnforceSnapshotRetention(context.Background(), time.Now().Add(time.Second))
611 if err != nil {
612 t.Fatal(err)
613 }
614 if err := db.EnforceRetentionByTXID(context.Background(), 1, minSnap); err != nil {
615 t.Fatal(err)
616 }
617 if err := db.EnforceRetentionByTXID(context.Background(), 0, minSnap); err != nil {
618 t.Fatal(err)
619 }
620 after := fmt.Sprintf("L9 %s; L1 %s; L0 %s", ranges(files(t, client, 9)), ranges(files(t, client, 1)), ranges(files(t, client, 0)))
621 t.Logf("FACT R5-3a: before retention: %s", before)
622 t.Logf("FACT R5-3b: after snapshot retention (minSnapshotTXID %d): %s", minSnap, after)
623 if len(files(t, client, 9)) != 1 || files(t, client, 9)[0].MaxTXID != snap2.MaxTXID {
624 t.Fatalf("expected only the newest snapshot to remain")
625 }
626 continuation := func() bool {
627 // any current file with MinTXID <= snap1.MaxTXID+1 and MaxTXID > snap1.MaxTXID
628 for _, lvl := range []int{0, 1, 2, 3} {
629 for _, f := range files(t, client, lvl) {
630 if f.MinTXID <= snap1.MaxTXID+1 && f.MaxTXID > snap1.MaxTXID {
631 return true
632 }
633 }
634 }
635 return false
636 }
637 t.Logf("FACT R5-3c: after one retention pass a continuation from the original snapshot [1,%d] remains: %v (the level-1 file that starts at 1 ends at minSnapshotTXID and deletion is strictly below)", snap1.MaxTXID, continuation())
638 if !continuation() {
639 t.Fatalf("expected the level-1 file starting at 1 to survive the first pass (it ends at minSnapshotTXID %d)", minSnap)
640 }
641 // one more rotation: the level-1 file starting at 1 now ends below the
642 // new minSnapshotTXID and is expired
643 for i := 0; i < 3; i++ {
644 write(t, path, 2)
645 sync(t, db)
646 }
647 if _, err := db.Compact(context.Background(), 1); err != nil {
648 t.Fatal(err)
649 }
650 time.Sleep(20 * time.Millisecond)
651 if snap2, err = db.Snapshot(context.Background()); err != nil {
652 t.Fatal(err)
653 }
654 if minSnap, err = db.EnforceSnapshotRetention(context.Background(), time.Now().Add(time.Second)); err != nil {
655 t.Fatal(err)
656 }
657 for _, lvl := range []int{1, 0} {
658 if err := db.EnforceRetentionByTXID(context.Background(), lvl, minSnap); err != nil {
659 t.Fatal(err)
660 }
661 }
662 closeDB(t, db)
663 after2 := fmt.Sprintf("L9 %s; L1 %s; L0 %s", ranges(files(t, client, 9)), ranges(files(t, client, 1)), ranges(files(t, client, 0)))
664 cont := continuation()
665 t.Logf("FACT R5-3f: after a second rotation and retention (minSnapshotTXID %d): %s; a continuation from the original snapshot remains: %v", minSnap, after2, cont)
666 if cont {
667 t.Fatalf("expected every continuation from the original snapshot [1,%d] to be expired after the second rotation", snap1.MaxTXID)
668 }
669 mustRanges(t, client, 9, fmt.Sprintf("[1,%d]", snap2.MaxTXID)) // only the newest snapshot
670 // restore rooted at the original snapshot: the original's bytes plus the
671 // current lower levels, without the newer snapshot
672 rooted := lsfile.NewReplicaClient(filepath.Join(dir, "rooted"))
673 if _, err := rooted.WriteLTXFile(context.Background(), 9, snap1.MinTXID, snap1.MaxTXID, bytes.NewReader(snap1Bytes)); err != nil {
674 t.Fatal(err)
675 }
676 for _, lvl := range []int{0, 1, 2, 3} {
677 for _, f := range files(t, client, lvl) {
678 copyFile(t, client, rooted, f)
679 }
680 }
681 errRooted := restore(t, rooted, filepath.Join(dir, "rooted.db"))
682 t.Logf("FACT R5-3d: restore rooted at the original snapshot after retention: %v", errRooted)
683 errNewest := restore(t, client, filepath.Join(dir, "newest.db"))
684 if errNewest != nil {
685 t.Fatal(errNewest)
686 }
687 t.Logf("FACT R5-3e: restore from the newest snapshot: ok (rows %d)", count(t, filepath.Join(dir, "newest.db")))
688 mustCount(t, filepath.Join(dir, "newest.db"), 19) // 1 row, then 3 rounds of 3 syncs of 2 rows
689 // the rooted restore fails closed, with litestream's own error (R6-2)
690 want := fmt.Sprintf("non-contiguous ltx files: have up to %016x but next file starts at %016x", uint64(snap1.MaxTXID), 5)
691 if errRooted == nil || !strings.Contains(errRooted.Error(), want) {
692 t.Fatalf("expected the rooted restore to fail closed with %q, got %v", want, errRooted)
693 }
694}
695
696// TestFact_R6_5_AnchorReplayBytes: the anchor's bytes depend on the header
697// timestamp. Encoded twice with the same inputs the bytes are identical;
698// with another timestamp they differ, so a builder that replays the anchor
699// PUT (a restarted install) must persist the timestamp it used, or reuse
700// the object it already wrote (moves spec §5.1 step 2, §7.6 building).
701func TestFact_R6_5_AnchorReplayBytes(t *testing.T) {
702 src, snap := source(t, 5)
703 ts := time.Now().UnixMilli()
704 a := encodeAnchor(t, src, snap, ts)
705 b := encodeAnchor(t, src, snap, ts)
706 c := encodeAnchor(t, src, snap, ts+1)
707 t.Logf("FACT R6-5: anchor [%d,%d], %d bytes; same timestamp: identical=%v; timestamp+1 ms: identical=%v", snap.MaxTXID, snap.MaxTXID, len(a), bytes.Equal(a, b), bytes.Equal(a, c))
708 if !bytes.Equal(a, b) {
709 t.Fatalf("the same inputs produced different anchor bytes")
710 }
711 if bytes.Equal(a, c) {
712 t.Fatalf("a different timestamp produced identical bytes: the premise of persisting it is gone, review the contract")
713 }
714 // the replayed object is accepted by the decoder and equals the first
715 dec := ltx.NewDecoder(bytes.NewReader(b))
716 if err := dec.Verify(); err != nil {
717 t.Fatal(err)
718 }
719}
720
721// TestGate_AnchoredLineageThroughStore: one anchored lineage through the
722// production Store configuration (internal/lease Held.Replicate: a DB with
723// its own metadata directory, a replica client, litestream.NewStore with
724// the compaction ladder, the L0 retention monitor), with every interval
725// shortened from hours to fractions of a second so the monitors run inside
726// the test: repeated writes with distinct bodies, updates and deletes,
727// level-1 to level-3 compaction, snapshot rotation and retention,
728// time-based level-0 expiry, then disk loss and a cold restore, three
729// times over. Exact positions (every sync advances the position by one,
730// and a reopened box continues at exactly the newest remote level-0 TXID
731// plus one) and exact row contents are asserted; each maintenance event
732// is awaited as a condition, not assumed after a sleep; the last round
733// forces a restore that consumes compacted continuation files (R7-5).
734func TestGate_AnchoredLineageThroughStore(t *testing.T) {
735 src, snap := source(t, 5)
736 dir := t.TempDir()
737 gen := lsfile.NewReplicaClient(filepath.Join(dir, "gen"))
738 copyFile(t, src, gen, snap)
739 anchorL0(t, src, snap, gen)
740 path := filepath.Join(dir, "db")
741 if err := restore(t, gen, path); err != nil {
742 t.Fatal(err)
743 }
744 want := rows(t, path) // the source's five rows, distinct bodies
745 if len(want) != 5 {
746 t.Fatalf("restored %d rows, want 5", len(want))
747 }
748 pos := int(snap.MaxTXID)
749 next := 6 // the next row id to insert
750 // internal/lease's ladder is L1 1h, L2 6h, L3 24h, the snapshot interval
751 // 24h, L0 retention 5 min checked hourly, the DB monitor 10 s: here each
752 // is shortened by the same shape
753 levels := litestream.CompactionLevels{
754 {Level: 0, Interval: 0},
755 {Level: 1, Interval: 150 * time.Millisecond},
756 {Level: 2, Interval: 400 * time.Millisecond},
757 {Level: 3, Interval: 900 * time.Millisecond},
758 }
759 ctx := context.Background()
760 maxOf := func(level int) int {
761 fs := files(t, gen, level)
762 if len(fs) == 0 {
763 return 0
764 }
765 return int(fs[len(fs)-1].MaxTXID)
766 }
767 for round := 0; round < 3; round++ {
768 meta := filepath.Join(dir, fmt.Sprintf("meta%d", round))
769 db := litestream.NewDB(path)
770 db.SetMetaPath(meta)
771 db.MonitorInterval = 50 * time.Millisecond
772 db.Replica = litestream.NewReplicaWithClient(db, gen)
773 db.Replica.SyncInterval = 50 * time.Millisecond
774 store := litestream.NewStore([]*litestream.DB{db}, levels)
775 store.SnapshotInterval = 300 * time.Millisecond
776 store.SnapshotRetention = 400 * time.Millisecond
777 store.SetL0Retention(200 * time.Millisecond) // NewStore copied the default into the DB; SetL0Retention propagates
778 store.L0RetentionCheckInterval = 50 * time.Millisecond
779 if err := store.Open(ctx); err != nil {
780 t.Fatalf("round %d: open store: %v", round, err)
781 }
782 snapsBefore := maxOf(9)
783 l0Before := len(files(t, gen, 0))
784 // eight transactions: inserts of distinct bodies, an update and a
785 // delete among them, each advancing the position by exactly one
786 for i := 0; i < 8; i++ {
787 switch {
788 case i == 3:
789 id := next - 2
790 exec(t, path, `UPDATE t SET body = ? WHERE id = ?`, fmt.Sprintf("r%d-updated-in-round-%d", id, round), id)
791 want[id] = fmt.Sprintf("r%d-updated-in-round-%d", id, round)
792 case i == 6:
793 id := next - 4
794 exec(t, path, `DELETE FROM t WHERE id = ?`, id)
795 delete(want, id)
796 default:
797 exec(t, path, `INSERT INTO t (id, body) VALUES (?, ?)`, next, fmt.Sprintf("r%d-round-%d", next, round))
798 want[next] = fmt.Sprintf("r%d-round-%d", next, round)
799 next++
800 }
801 sync(t, db)
802 pos++
803 p, err := db.Pos()
804 if err != nil {
805 t.Fatal(err)
806 }
807 if int(p.TXID) != pos {
808 t.Fatalf("round %d write %d: position %d, want exactly %d (a reopened box continues at the newest remote level-0 TXID plus one)", round, i, p.TXID, pos)
809 }
810 time.Sleep(120 * time.Millisecond) // the monitors run between writes
811 }
812 // the maintenance events, each as a condition: a snapshot rotated
813 // past the previous newest, level 1, 2 and 3 files exist, the older
814 // snapshot was removed by retention, and level-0 expiry removed
815 // compacted files while keeping the newest
816 waitFor(t, "a snapshot rotation", 5*time.Second, func() bool { return maxOf(9) > snapsBefore })
817 for _, lvl := range []int{1, 2, 3} {
818 waitFor(t, fmt.Sprintf("a level-%d compaction", lvl), 5*time.Second, func() bool { return len(files(t, gen, lvl)) > 0 })
819 }
820 // Retention is awaited at the state that lasts: one snapshot, at the
821 // round's final position. One snapshot alone can be a moment in
822 // passing. The monitor snapshots every interval and keeps the previous
823 // snapshot while it is younger than the retention, so a tick that runs
824 // 100 ms late removes the previous one and leaves a single snapshot
825 // that is behind the position; the next tick adds the final snapshot
826 // beside it, and a round that had stopped waiting closed the store
827 // with two (#266). Once the only snapshot is at the final position a
828 // later tick can only rewrite that same file.
829 waitFor(t, "snapshot retention", 5*time.Second, func() bool {
830 l9 := files(t, gen, 9)
831 return len(l9) == 1 && int(l9[0].MaxTXID) == pos
832 })
833 waitFor(t, "level-0 expiry", 5*time.Second, func() bool { return len(files(t, gen, 0)) < l0Before+8 })
834 if err := store.Close(ctx); err != nil {
835 t.Fatal(err)
836 }
837 l0, l1, l9 := files(t, gen, 0), files(t, gen, 1), files(t, gen, 9)
838 t.Logf("GATE round %d: position %d, rows %d; L0 %s (%d files, %d before); L1 %s; L2 %s; L3 %s; L9 %s", round, pos, len(want), ranges(l0), len(l0), l0Before, ranges(l1), ranges(files(t, gen, 2)), ranges(files(t, gen, 3)), ranges(l9))
839 if len(l0) == 0 || int(l0[len(l0)-1].MaxTXID) != pos {
840 t.Fatalf("round %d: newest remote level-0 file is %s, want MaxTXID exactly %d", round, ranges(l0), pos)
841 }
842 if len(l9) != 1 || int(l9[0].MaxTXID) <= snapsBefore {
843 t.Fatalf("round %d: snapshot retention did not leave exactly the newest snapshot: L9 %s", round, ranges(l9))
844 }
845 // disk loss, cold restore: exact contents, row by row
846 wipe(t, path, meta)
847 if err := restore(t, gen, path); err != nil {
848 t.Fatalf("round %d: cold restore: %v", round, err)
849 }
850 mustRows(t, path, want)
851 }
852 // a restore that must consume compacted continuation files: with the
853 // monitors stopped, more writes are synced, compacted into level 1, and
854 // every level-0 file but the newest is expired by time; the newest
855 // snapshot is behind them, so the planner has to take the level-1 file
856 meta := filepath.Join(dir, "meta-tail")
857 db := openDB(t, path, meta, gen)
858 for i := 0; i < 3; i++ {
859 exec(t, path, `INSERT INTO t (id, body) VALUES (?, ?)`, next, fmt.Sprintf("r%d-tail", next))
860 want[next] = fmt.Sprintf("r%d-tail", next)
861 next++
862 sync(t, db)
863 pos++
864 }
865 exec(t, path, `DELETE FROM t WHERE id = ?`, next-1)
866 delete(want, next-1)
867 sync(t, db)
868 pos++
869 if _, err := db.Compact(ctx, 1); err != nil {
870 t.Fatal(err)
871 }
872 db.L0Retention = time.Millisecond
873 time.Sleep(20 * time.Millisecond)
874 if err := db.EnforceL0RetentionByTime(ctx); err != nil {
875 t.Fatal(err)
876 }
877 closeDB(t, db)
878 l0, l1, l9 := files(t, gen, 0), files(t, gen, 1), files(t, gen, 9)
879 t.Logf("GATE tail: position %d; L0 %s; L1 %s; L9 %s", pos, ranges(l0), ranges(l1), ranges(l9))
880 if len(l0) != 1 || int(l0[0].MaxTXID) != pos {
881 t.Fatalf("tail: level 0 should hold the newest file alone, have %s", ranges(l0))
882 }
883 if maxOf(9) >= pos || maxOf(1) != pos {
884 t.Fatalf("tail: the newest snapshot (%d) must be behind the level-1 file ending at %d", maxOf(9), maxOf(1))
885 }
886 wipe(t, path, meta)
887 if err := restore(t, gen, path); err != nil {
888 t.Fatalf("tail: cold restore: %v", err)
889 }
890 mustRows(t, path, want) // the rows past the snapshot came through level 1
891}
892
893// TestFact_R7_1_UnconditionalPut: the pinned S3 client's PutObject carries
894// no If-Match or If-None-Match, and its key is prefix, level and TXID range
895// only, so two writers of one generation write the same key with
896// unconditional PUTs: the R7-1 hazard (claim rev 6.6 §6.4a). Read from
897// the pinned source so an upgrade that changes either fails here.
898func TestFact_R7_1_UnconditionalPut(t *testing.T) {
899 pkg, err := build.Default.Import("github.com/benbjohnson/litestream/s3", "", build.FindOnly)
900 if err != nil {
901 t.Skipf("pinned s3 client source not found: %v", err)
902 }
903 src, err := os.ReadFile(filepath.Join(pkg.Dir, "replica_client.go"))
904 if err != nil {
905 t.Fatal(err)
906 }
907 text := string(src)
908 i := strings.Index(text, "s3.PutObjectInput{")
909 if i < 0 {
910 t.Fatalf("no PutObjectInput in the pinned client")
911 }
912 block := text[i:]
913 block = block[:strings.Index(block, "}")]
914 t.Logf("FACT R7-1a: the pinned PutObjectInput fields: %s", strings.Join(strings.Fields(strings.ReplaceAll(block, "\n", " ")), " "))
915 for _, cond := range []string{"IfNoneMatch", "IfMatch"} {
916 if strings.Contains(block, cond) {
917 t.Fatalf("the pinned client's PutObject is conditional (%s): the writer-epoch premise changed, review the contract", cond)
918 }
919 }
920 if !strings.Contains(text, `fmt.Sprintf("%04x/%s", level, filename)`) || !strings.Contains(text, "ltx.FormatFilename(minTXID, maxTXID)") {
921 t.Fatalf("the pinned client's key is no longer prefix/level/TXID range")
922 }
923 t.Logf("FACT R7-1b: the key is c.Path + \"/\" + %%04x level + ltx.FormatFilename(min, max): two writers of one generation share it")
924}
925
926// TestFact_R8_3_EmptyBootstrap: a created incarnation's first wake has
927// neither a database file nor a replica (R8-3). The pinned encoder refuses
928// the files a position-0 build would carry, a snapshot [1,0] and an anchor
929// [0,0], so an empty generation has no LTX file at all: litestream opened
930// on an absent path against an empty replica syncs nothing, a cold restore
931// of the empty generation has nothing to restore, and the first
932// transaction is the first upload, which a cold restore then holds.
933func TestFact_R8_3_EmptyBootstrap(t *testing.T) {
934 for _, h := range []ltx.Header{
935 {Version: ltx.Version, Flags: ltx.HeaderFlagNoChecksum, PageSize: 4096, Commit: 1, MinTXID: 1, MaxTXID: 0, Timestamp: 1, WALOffset: litestream.WALHeaderSize},
936 {Version: ltx.Version, Flags: ltx.HeaderFlagNoChecksum, PageSize: 4096, Commit: 1, MinTXID: 0, MaxTXID: 0, Timestamp: 1},
937 } {
938 var buf bytes.Buffer
939 enc, err := ltx.NewEncoder(&buf)
940 if err != nil {
941 t.Fatal(err)
942 }
943 err = enc.EncodeHeader(h)
944 t.Logf("FACT R8-3a: encoding an LTX header [%d,%d]: %v", h.MinTXID, h.MaxTXID, err)
945 if err == nil {
946 t.Fatalf("the encoder accepted a zero TXID [%d,%d]; the spec's empty-bootstrap rule rests on it refusing", h.MinTXID, h.MaxTXID)
947 }
948 }
949 dir := t.TempDir()
950 gen := lsfile.NewReplicaClient(filepath.Join(dir, "gen"))
951 path := filepath.Join(dir, "new.db")
952 meta := filepath.Join(dir, "meta")
953 if _, err := os.Stat(path); !os.IsNotExist(err) {
954 t.Fatalf("the database path exists before the first wake: %v", err)
955 }
956 if err := restore(t, gen, filepath.Join(dir, "nothing.db")); err == nil {
957 t.Fatalf("a restore from an empty replica succeeded; the empty generation must have nothing to restore")
958 } else {
959 t.Logf("FACT R8-3b: a cold restore of an empty generation: %v", err)
960 }
961 db := openDB(t, path, meta, gen)
962 err := db.Sync(context.Background())
963 t.Logf("FACT R8-3c: litestream opened on an absent database against an empty replica; first sync: %v; level 0 %s, level 9 %s", err, ranges(files(t, gen, 0)), ranges(files(t, gen, 9)))
964 if err != nil {
965 t.Fatalf("the first sync of an absent database failed: %v", err)
966 }
967 mustRanges(t, gen, 0, "none")
968 mustRanges(t, gen, 9, "none")
969 write(t, path, 1) // CREATE TABLE, then one row: two SQLite transactions, one sync
970 sync(t, db)
971 l0 := ranges(files(t, gen, 0))
972 t.Logf("FACT R8-3d: after the first transactions and one sync the replica's level 0 is %s, level 9 %s (a TXID is one sync's WAL frames, not one SQLite transaction)", l0, ranges(files(t, gen, 9)))
973 if l0 != "[1,1]" {
974 t.Fatalf("expected the first upload to be level 0 [1,1], got %s", l0)
975 }
976 closeDB(t, db)
977 out := filepath.Join(dir, "out.db")
978 if err := restore(t, gen, out); err != nil {
979 t.Fatalf("restore after the first transactions: %v", err)
980 }
981 mustCount(t, out, 1)
982 t.Logf("FACT R8-3e: a cold restore after the first transactions holds the row; the first transaction was the first upload")
983}
984