carlos / platform Public

Merge pull request #215 from carlosframework/fleet-claims-only

litestream: the glob proposes, the registry disposes
Paul Campbell d63a566c908006084104520f7cb2868c98cbd5aa parent c2466bfparent a16b28e
3 files changed, +102 −6
  • internal/ops/litestream.go +46 −5
  • internal/ops/ops.go +8 −1
  • internal/ops/ops_test.go +48 −0
diff --git a/internal/ops/litestream.go b/internal/ops/litestream.go
index e246f3e1f..d6f706e15 100644
--- a/internal/ops/litestream.go
+++ b/internal/ops/litestream.go
@@ -321,9 +321,17 @@ func consoleDBPath(dataDir string) string {
}
func FleetDBs(reg *registry.Registry, dataDir string) ([]string, error) {
+ dbs, _, err := FleetDBsWithSkipped(reg, dataDir)
+ return dbs, err
+}
+
+// FleetDBsWithSkipped is FleetDBs, plus the files under dataDir it declined to
+// claim — see the "glob proposes, registry disposes" note inside. The verb
+// prints them; nothing here is dropped without being named.
+func FleetDBsWithSkipped(reg *registry.Registry, dataDir string) (dbs []string, skipped []string, err error) {
routes, err := reg.List()
if err != nil {
- return nil, err
+ return nil, nil, err
}
hibernating := map[string]bool{}
unitPaths := map[string]bool{}
@@ -345,11 +353,44 @@ func FleetDBs(reg *registry.Registry, dataDir string) ([]string, error) {
matches, err := filepath.Glob(filepath.Join(dataDir, "*.db"))
if err != nil {
- return nil, err
+ return nil, nil, err
+ }
+ // THE GLOB PROPOSES, THE REGISTRY DISPOSES. A *.db under dataDir is
+ // replicated when a route claims it, or when it is the box's own registry
+ // or console. Anything else is somebody's leftover, and streaming leftovers
+ // off-box costs storage forever: the flagship has a 410M
+ // seapointish-purge-backup.db in /data, root-owned, matching this glob
+ // exactly, which today escapes replication only because that box's config
+ // is still hand-written.
+ //
+ // Skipped files are NAMED to the caller, never dropped in silence — a
+ // database that someone did want, quietly left out, is the precise failure
+ // this area keeps producing. The shrink guard (CoverageShortfall) is the
+ // second net: if a skipped file is one the live config replicates today,
+ // the write is refused outright.
+ claimed := map[string]bool{
+ filepath.Clean(filepath.Join(dataDir, "registry.db")): true,
+ filepath.Clean(consoleDBPath(dataDir)): true,
+ }
+ for _, r := range routes {
+ if r.DBPath != "" {
+ claimed[filepath.Clean(r.DBPath)] = true
+ }
}
+ var globbed []string
+ for _, p := range matches {
+ if claimed[filepath.Clean(p)] {
+ globbed = append(globbed, p)
+ continue
+ }
+ skipped = append(skipped, filepath.Clean(p))
+ }
+ matches = globbed
+
// Unit-tenant DBPaths are added on top of the glob: they typically live
// entirely outside dataDir, at their StateDirectory path. A DBPath naming
- // a DIRECTORY is a tree — see treeDBs.
+ // a DIRECTORY is a tree — see treeDBs. These are claimed by construction:
+ // a route named them.
for p := range unitPaths {
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
matches = append(matches, treeDBs(p)...)
@@ -367,7 +408,6 @@ func FleetDBs(reg *registry.Registry, dataDir string) ([]string, error) {
matches = append(matches, consoleDBPath(dataDir))
seen := map[string]bool{}
- var dbs []string
for _, p := range matches {
p = filepath.Clean(p)
if seen[p] {
@@ -383,7 +423,8 @@ func FleetDBs(reg *registry.Registry, dataDir string) ([]string, error) {
seen[p] = true
dbs = append(dbs, p)
}
- return dbs, nil
+ sort.Strings(skipped)
+ return dbs, skipped, nil
}
// restoreAndCheckFn is the per-database restore behind a package var, so the
diff --git a/internal/ops/ops.go b/internal/ops/ops.go
index 678679cd9..3e52cf61f 100644
--- a/internal/ops/ops.go
+++ b/internal/ops/ops.go
@@ -111,13 +111,20 @@ func runLitestream(args []string) {
if err != nil {
log.Fatalf("litestream: open registry %s: %v", rp, err)
}
- dbs, err := FleetDBs(reg, dir)
+ dbs, skipped, err := FleetDBsWithSkipped(reg, dir)
if err != nil {
log.Fatalf("litestream: %v", err)
}
if len(dbs) == 0 {
log.Fatalf("litestream: no non-empty *.db files under %s — wrong box?", dir)
}
+ if len(skipped) > 0 {
+ // Named, never silent: these are files under the data dir that no route
+ // claims. Usually leftovers (a purge backup, a restore scratch); if one
+ // is a database someone still wants, the fix is a route that says so.
+ fmt.Fprintf(os.Stderr, "litestream: %d file(s) under %s are claimed by no route and will NOT be replicated:\n %s\n",
+ len(skipped), dir, strings.Join(skipped, "\n "))
+ }
// Never quietly replicate less than the file being replaced — see
// CoverageShortfall. This is what makes a cutover from a hand-maintained
diff --git a/internal/ops/ops_test.go b/internal/ops/ops_test.go
index 97cea384b..28be7f860 100644
--- a/internal/ops/ops_test.go
+++ b/internal/ops/ops_test.go
@@ -706,3 +706,51 @@ func TestReplicaURLsAreUniquePerPath(t *testing.T) {
t.Fatalf("replica keys lost the path that identifies them:\n%s", cfg)
}
}
+
+// The dataDir glob takes every *.db it finds, which means the box-wide
+// replicator will happily stream anything that lands there — including things
+// that are not tenant databases at all. The flagship has a 410M
+// seapointish-purge-backup.db sitting in /data: a leftover from a purge, owned
+// by root, matching the glob exactly. Today it is not replicated only because
+// that box's config is hand-written; the moment the generator takes over it
+// would join the replication set and start costing storage forever.
+//
+// So the glob proposes and the REGISTRY disposes: a file under dataDir is
+// replicated when some route claims it (or it is the box's own registry or
+// console). Anything else is skipped and NAMED — never silently, because a
+// skipped database that someone did want is exactly the failure this whole
+// area keeps producing.
+func TestFleetDBsSkipsUnclaimedFilesAndSaysSo(t *testing.T) {
+ dir := t.TempDir()
+ reg, err := registry.Open(filepath.Join(dir, "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ claimed := filepath.Join(dir, "app.example.com.db")
+ stray := filepath.Join(dir, "seapointish-purge-backup.db")
+ for _, p := range []string{claimed, stray} {
+ if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := reg.Upsert(registry.Route{Host: "app.example.com", DBPath: claimed}); err != nil {
+ t.Fatal(err)
+ }
+
+ dbs, skipped, err := FleetDBsWithSkipped(reg, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Contains(dbs, claimed) {
+ t.Fatalf("a route's own database was skipped: %v", dbs)
+ }
+ if !slices.Contains(dbs, filepath.Join(dir, "registry.db")) {
+ t.Fatalf("the box registry was skipped: %v", dbs)
+ }
+ if slices.Contains(dbs, stray) {
+ t.Fatalf("an unclaimed file joined the replication set: %v", dbs)
+ }
+ if !slices.Contains(skipped, stray) {
+ t.Fatalf("the unclaimed file was dropped SILENTLY — skipped = %v", skipped)
+ }
+}