carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package lease
2
3import (
4 "context"
5 "database/sql"
6 "encoding/binary"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "path/filepath"
12
13 "github.com/benbjohnson/litestream"
14 "github.com/superfly/ltx"
15 _ "modernc.org/sqlite"
16)
17
18// restoreForVerify is Restore behind a package var so the drill's three
19// outcomes (clean, damaged, never-replicated) are testable without S3.
20var restoreForVerify = Restore
21
22// ErrNoReplica reports that a prefix holds no backup at all — a brand-new
23// instance that has never replicated, not a damaged one. Verification callers
24// must keep the two apart: "nothing to restore yet" is not a failure, and
25// reporting it as one would train operators to ignore the alarm.
26var ErrNoReplica = errors.New("no replica in prefix")
27
28// CheckDB opens path read-only and proves it is a sane SQLite database:
29// integrity_check == ok and at least one table present. It is the one check a
30// disaster recovery leans on, so it lives here — beside Restore — where both
31// the wake path (internal/activator) and the verification pass (internal/ops)
32// can reach it without either importing the other.
33//
34// The schema check is not pedantry: a restore that yields a valid but empty
35// database passes integrity_check and would be useless in the recovery it was
36// meant for.
37func CheckDB(path string) error {
38 return CheckDBContext(context.Background(), path)
39}
40
41// CheckDBContext is CheckDB that stops when ctx does: the integrity scan
42// reads every page, and a caller that has been asked to stop (carlos db,
43// on Ctrl-C) should not finish a full scan first.
44func CheckDBContext(ctx context.Context, path string) error {
45 db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
46 if err != nil {
47 return err
48 }
49 defer db.Close()
50 db.SetMaxOpenConns(1)
51 // busy_timeout first, always (Global Constraints) — and on a garbage file
52 // this first statement is what fails loudly instead of a later query.
53 if _, err := db.ExecContext(ctx, "PRAGMA busy_timeout=5000"); err != nil {
54 return fmt.Errorf("open: %w", err)
55 }
56 var res string
57 if err := db.QueryRowContext(ctx, "PRAGMA integrity_check").Scan(&res); err != nil {
58 return fmt.Errorf("integrity_check: %w", err)
59 }
60 if res != "ok" {
61 return fmt.Errorf("integrity_check: %s", res)
62 }
63 var tables int
64 if err := db.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&tables); err != nil {
65 return fmt.Errorf("schema count: %w", err)
66 }
67 if tables == 0 {
68 return errors.New("restored database has no tables")
69 }
70 return nil
71}
72
73// VerifyReplica restores s3://bucket/prefix into a throwaway file under dir
74// and proves it opens clean, then deletes it. It returns ErrNoReplica when
75// the prefix has never been replicated to, and a describing error when the
76// restore or the check fails.
77//
78// IT DELIBERATELY DOES NOT TAKE THE LEASE, and that is safe in a way Restore
79// itself is not. Restore's contract requires the lease because it materializes
80// the LIVE database: an unleased restore there can resurrect a stale copy that
81// later replicates over the true holder's newer state. Here the output path is
82// a temp file that is never replicated from and is removed before return, so
83// there is nothing that could shadow anything. What the lease forbids is a
84// second WRITER; a reader costs the holder nothing — the box-wide
85// `carlos ops restore-verify` has always restored databases that its own
86// replicator was actively streaming, and litestream restores a committed
87// position rather than a live tail.
88func VerifyReplica(ctx context.Context, bucket, prefix, region, dir string) (uint32, error) {
89 out := filepath.Join(dir, "verify-"+filepath.Base(prefix)+".db")
90 // A retry must not trip over a previous attempt's file: litestream refuses
91 // to restore over an existing output path.
92 _ = os.Remove(out)
93 // Sweep on EVERY path, including a failed check. The sidecars are swept
94 // too: CheckDB's "mode=ro" does not stop the driver attaching a WAL, and a
95 // drill that left a copy of every tenant's database on the box would be
96 // both a disk bug and a data-exposure one.
97 defer func() {
98 _ = os.Remove(out)
99 _ = os.Remove(out + "-wal")
100 _ = os.Remove(out + "-shm")
101 }()
102
103 restored, err := restoreForVerify(ctx, out, bucket, prefix, region)
104 if err != nil {
105 return 0, fmt.Errorf("restore: %w", err)
106 }
107 if !restored {
108 return 0, ErrNoReplica
109 }
110 if err := CheckDB(out); err != nil {
111 return 0, err
112 }
113 // The counter is read BEFORE the deferred sweep removes the copy: it is
114 // what lets the caller ask the second question, whether this replica is
115 // still tracking its source (see ops.replicaFreshness).
116 return ChangeCounter(out)
117}
118
119// ChangeCounter reads the SQLite file-change counter: bytes 24-27 of the
120// database header, big-endian. SQLite increments it on every write transaction
121// that modifies the main database file, which makes it the cheapest honest
122// answer to "is this replica still tracking its source?" — compare the number
123// on the live database with the number on a restored copy.
124//
125// It lives here, beside CheckDB and Restore, because both drills need it: the
126// box-wide one (internal/ops) and the hibernation one, which restores through
127// VerifyReplica.
128//
129// In WAL mode the counter in the main file only advances at a checkpoint, so a
130// source with a hot WAL reads LOWER than reality. That skew is deliberately
131// harmless: it can only make a stale replica look current (a missed alarm),
132// never a current replica look stale (a false one).
133func ChangeCounter(path string) (uint32, error) {
134 f, err := os.Open(path)
135 if err != nil {
136 return 0, err
137 }
138 defer f.Close()
139
140 var header [28]byte
141 if _, err := io.ReadFull(f, header[:]); err != nil {
142 return 0, fmt.Errorf("read header: %w", err)
143 }
144 if string(header[:15]) != "SQLite format 3" {
145 return 0, fmt.Errorf("not a SQLite database")
146 }
147 return binary.BigEndian.Uint32(header[24:28]), nil
148}
149
150// HasReplica reports whether s3://bucket/prefix holds any LTX file at any
151// level: the seed fallback's "has this host a replica of its own" (seeded
152// instances spec §3.4 step 1). A listing error is returned, never read as
153// "no replica".
154func HasReplica(ctx context.Context, bucket, prefix, region string) (bool, error) {
155 region, err := resolveRegion(ctx, region)
156 if err != nil {
157 return false, err
158 }
159 client, err := newReplicaClient(bucket, prefix, region)
160 if err != nil {
161 return false, err
162 }
163 return hasReplicaIn(ctx, client)
164}
165
166// ltxLister is the one replica-client method HasReplica needs.
167type ltxLister interface {
168 LTXFiles(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error)
169}
170
171func hasReplicaIn(ctx context.Context, client ltxLister) (bool, error) {
172 for level := 0; level <= litestream.SnapshotLevel; level++ {
173 itr, err := client.LTXFiles(ctx, level, 0, false)
174 if err != nil {
175 return false, err
176 }
177 found := itr.Next()
178 ierr := itr.Err()
179 cerr := itr.Close()
180 if ierr != nil {
181 return false, ierr
182 }
183 if cerr != nil {
184 return false, cerr
185 }
186 if found {
187 return true, nil
188 }
189 }
190 return false, nil
191}
192