carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package ops
2
3// Is the replica CURRENT, not merely restorable?
4//
5// The drill's original question — "does this replica restore clean?" — has a
6// blind spot that only shows up in the failure it is meant to catch. A
7// replicator that dies leaves behind a replica that is valid SQLite, restores
8// perfectly, and passes `integrity_check` forever, while the database it
9// mirrors moves on without it. The drill stays green; the backup quietly
10// becomes a snapshot of the day the replicator died.
11//
12// This is not hypothetical. On 2026-08-11 the flagship's litestream exited
13// with "permission denied" writing its shadow WAL into a DynamicUser tenant's
14// state directory (a root drop-in fixed it the same minute). Had nobody been
15// watching the journal, nothing in the platform would have said a word: the
16// next morning's drill would have restored four frozen replicas and reported
17// them clean.
18//
19// The signal used here is SQLite's own: the file-change counter in the
20// database header, which advances on every write transaction that reaches the
21// main database file. Compare the source's counter with the restored copy's,
22// and a replica that has stopped tracking its source shows up as a number that
23// stopped moving. No S3 layout knowledge, no litestream version dependency,
24// no clock skew between machines — the comparison is between two files this
25// process just read.
26
27import (
28 "fmt"
29 "os"
30 "time"
31
32 "github.com/carlosframework/platform/internal/lease"
33)
34
35// staleGrace is how long a source must have been QUIET before a replica that
36// trails it is called stale. It exists to make false alarms impossible in the
37// ordinary case: a database written seconds before the drill ran is expected
38// to be a transaction or two ahead of its replica, and reporting that as a
39// fault would put the drill permanently red on the busiest — most valuable —
40// databases on the box, which is precisely how a check stops being read.
41//
42// 15 minutes is far longer than any replication lag this fleet has (litestream
43// syncs on change, seconds at most) and far shorter than the gap that matters
44// (a replicator that died).
45const staleGrace = 15 * time.Minute
46
47type freshness int
48
49const (
50 // freshnessOK: the replica is level with its source, or trailing a source
51 // that was written too recently to judge.
52 freshnessOK freshness = iota
53 // freshnessStale: the source has been quiet for longer than staleGrace and
54 // the replica is STILL behind it. Replication is not happening.
55 freshnessStale
56 // freshnessNoSource: the config names a database that is not on disk. The
57 // replica cannot ever advance again, and nothing is watching the gap.
58 freshnessNoSource
59 // freshnessUnknown: one of the two headers could not be read. Say so;
60 // never let "could not tell" read as "fine".
61 freshnessUnknown
62)
63
64func (f freshness) String() string {
65 switch f {
66 case freshnessOK:
67 return "ok"
68 case freshnessStale:
69 return "STALE"
70 case freshnessNoSource:
71 return "no-source"
72 default:
73 return "unknown"
74 }
75}
76
77// ChangeCounter is lease.ChangeCounter — one definition of the freshness
78// signal, shared by both drills. Kept exported here because this package's
79// tests and callers are its documented door.
80func ChangeCounter(path string) (uint32, error) { return lease.ChangeCounter(path) }
81
82// replicaFreshness compares a restored replica against the live database it
83// mirrors. srcModTime is the source's last-modified time, passed in so the
84// caller stats once and tests can drive the grace window.
85func replicaFreshness(srcPath, restoredPath string, srcModTime time.Time) (freshness, string) {
86 restoredCounter, err := ChangeCounter(restoredPath)
87 if err != nil {
88 return freshnessUnknown, fmt.Sprintf("restored header unreadable: %v", err)
89 }
90 return replicaFreshnessCounter(srcPath, restoredCounter, srcModTime)
91}
92
93// replicaFreshnessCounter is replicaFreshness for a caller that already holds
94// the restored copy's counter and no longer has the file — which is the
95// hibernation drill, where lease.VerifyReplica reads the counter and then
96// sweeps its throwaway copy in the same call.
97func replicaFreshnessCounter(srcPath string, restoredCounter uint32, srcModTime time.Time) (freshness, string) {
98 srcCounter, err := ChangeCounter(srcPath)
99 if os.IsNotExist(err) {
100 return freshnessNoSource, "source database is not on disk — this replica can never advance again"
101 }
102 if err != nil {
103 return freshnessUnknown, fmt.Sprintf("source header unreadable: %v", err)
104 }
105 if restoredCounter >= srcCounter {
106 return freshnessOK, ""
107 }
108 if quiet := time.Since(srcModTime); quiet <= staleGrace {
109 // Behind, but the source was written within the grace window — this is
110 // ordinary lag, and the next pass will judge it properly.
111 return freshnessOK, ""
112 }
113 return freshnessStale, fmt.Sprintf(
114 "replica is at change %d, the live database at %d, and the database has been quiet since %s — replication has stopped",
115 restoredCounter, srcCounter, srcModTime.UTC().Format(time.RFC3339))
116}
117
118// sourceModTime is the source database's mtime, or the zero time when it
119// cannot be stated (which replicaFreshness will treat through its own paths).
120func sourceModTime(path string) time.Time {
121 fi, err := os.Stat(path)
122 if err != nil {
123 return time.Time{}
124 }
125 return fi.ModTime()
126}
127