carlos / platform Public

litestream: replica keys from the full path, not the basename

Tree discovery turns a latent bug into a certainty. The replica URL was
derived from filepath.Base, and basenames are not unique: every SQLite-backed
tenant here names its database registry.db or data.db, and the box's own
control DB is /data/registry.db. Three such databases all resolved to

  s3://bucket/prefix/registry.db

and two litestream replicators writing one prefix interleave their WAL into a
replica that restores as neither database. Before treeDBs the collision needed
an unusual layout; after it, every tenant tree contains a registry.db.

Keys are now the full path with the leading separator dropped —
data/registry.db, var/lib/carlos-app/home.kass.training/registry.db — which is
unique by construction and still legible to whoever is reading the bucket
during a restore, at speed, trying to work out which database a key holds.

This is a one-time freedom: no box has ever run a config generated by this verb
(the flagship's is hand-written, tito's comes from the legacy `titogo ops
litestream`), so there is no live lineage to strand. There will be after the
first cutover, and the comment says so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell 61368e3f012c7c3d8f4358dbaedef6f94a2ea204 parent 6a634fc
2 files changed, +65 −1
  • internal/ops/litestream.go +27 −1
  • internal/ops/ops_test.go +38 −0
diff --git a/internal/ops/litestream.go b/internal/ops/litestream.go
index ae4a60c78..e246f3e1f 100644
--- a/internal/ops/litestream.go
+++ b/internal/ops/litestream.go
@@ -137,7 +137,7 @@ func LitestreamConfig(dbPaths []string, bucket, prefix, region string) string {
for _, p := range sorted {
fmt.Fprintf(&b, " - path: %s\n", p)
b.WriteString(" replicas:\n")
- fmt.Fprintf(&b, " - url: s3://%s/%s/%s\n", bucket, prefix, filepath.Base(p))
+ fmt.Fprintf(&b, " - url: s3://%s/%s/%s\n", bucket, prefix, replicaKey(p))
if region != "" {
fmt.Fprintf(&b, " region: %s\n", region)
}
@@ -145,6 +145,32 @@ func LitestreamConfig(dbPaths []string, bucket, prefix, region string) string {
return b.String()
}
+// replicaKey is a database's place in the replica bucket: its full path with
+// the leading separator dropped, so /data/registry.db becomes
+// data/registry.db.
+//
+// It is the full path and not the basename because the basename is NOT
+// unique, and the consequence of pretending otherwise is severe. Every
+// SQLite-backed tenant on this fleet calls its database registry.db or
+// data.db, and the box's own control DB is /data/registry.db; a basename key
+// collapses all of them onto one S3 prefix, where two litestream replicators
+// interleave their WAL into a replica that restores as neither database.
+// Tree discovery (treeDBs) turns that from a curiosity into the normal case —
+// every tenant tree contains a registry.db.
+//
+// Keeping the path readable rather than hashing it is deliberate: the person
+// reading this bucket is doing a restore, probably at speed, and needs to see
+// which database each key is.
+//
+// Changing this scheme moves every database to a new replica lineage, so it
+// is a one-time freedom: no box has ever run a config generated by this verb
+// (the flagship's is hand-written, tito's comes from the legacy `titogo ops
+// litestream`), so there is no live lineage to break. There will be after the
+// first cutover — treat it as fixed then.
+func replicaKey(dbPath string) string {
+ return strings.TrimPrefix(filepath.Clean(dbPath), "/")
+}
+
// ParseLitestreamDBs recovers the database paths from a generated config.
// It reads only the exact shape LitestreamConfig writes (the round-trip is
// pinned by TestConfigRoundTrip) — it is not a YAML parser.
diff --git a/internal/ops/ops_test.go b/internal/ops/ops_test.go
index 7aa68fc33..97cea384b 100644
--- a/internal/ops/ops_test.go
+++ b/internal/ops/ops_test.go
@@ -668,3 +668,41 @@ func TestCoverageShortfall(t *testing.T) {
t.Fatalf("a fresh box reported a shortfall: %v", missing)
}
}
+
+// Two databases with the same basename must not replicate to the same place.
+//
+// Every SQLite-backed tenant on the fleet names its database registry.db or
+// data.db; the box's own registry is /data/registry.db. Deriving the replica
+// URL from filepath.Base collapses all of them onto one S3 key, and two
+// litestream replicators writing one prefix interleave their WAL into a
+// replica that restores as neither database. Tree discovery makes the
+// collision the normal case rather than a curiosity: every tenant tree has a
+// registry.db in it.
+func TestReplicaURLsAreUniquePerPath(t *testing.T) {
+ cfg := LitestreamConfig([]string{
+ "/data/registry.db",
+ "/var/lib/carlos-app/home.kass.training/registry.db",
+ "/var/lib/carlos-app/other.example.com/registry.db",
+ }, "bucket-x", "fleet", "eu-west-1")
+
+ var urls []string
+ for _, line := range strings.Split(cfg, "\n") {
+ if u, ok := strings.CutPrefix(strings.TrimSpace(line), "- url: "); ok {
+ urls = append(urls, u)
+ }
+ }
+ if len(urls) != 3 {
+ t.Fatalf("expected 3 replica urls, got %d:\n%s", len(urls), cfg)
+ }
+ seen := map[string]bool{}
+ for _, u := range urls {
+ if seen[u] {
+ t.Fatalf("two databases share the replica url %q — their WALs would interleave:\n%s", u, cfg)
+ }
+ seen[u] = true
+ }
+ // And the key should still be legible to whoever is doing the restore.
+ if !strings.Contains(cfg, "home.kass.training") {
+ t.Fatalf("replica keys lost the path that identifies them:\n%s", cfg)
+ }
+}