Skip to content

Commit 41563f4

Browse files
corylanouclaude
andcommitted
fix(directory-watcher): ensure unique metadata paths per discovered database
- Add meta path expansion to support home directory tilde notation (~) - Derive unique metadata directories for each discovered database - Prevent databases from clobbering each other's replication state - Add tests for meta path expansion and directory-specific paths 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 4ce0b64 commit 41563f4

File tree

2 files changed

+145
-1
lines changed

2 files changed

+145
-1
lines changed

cmd/litestream/main.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,12 @@ func NewDBFromConfig(dbc *DBConfig) (*litestream.DB, error) {
527527

528528
// Override default database settings if specified in configuration.
529529
if dbc.MetaPath != nil {
530-
db.SetMetaPath(*dbc.MetaPath)
530+
expandedMetaPath, err := expand(*dbc.MetaPath)
531+
if err != nil {
532+
return nil, fmt.Errorf("failed to expand meta path: %w", err)
533+
}
534+
dbc.MetaPath = &expandedMetaPath
535+
db.SetMetaPath(expandedMetaPath)
531536
}
532537
if dbc.MonitorInterval != nil {
533538
db.MonitorInterval = *dbc.MonitorInterval
@@ -627,6 +632,18 @@ func newDBFromDirectoryEntry(dbc *DBConfig, dirPath, dbPath string) (*litestream
627632
dbConfigCopy.Recursive = false // Clear recursive flag
628633
dbConfigCopy.Watch = false // Individual DBs do not watch directories
629634

635+
// Ensure every database discovered beneath a directory receives a unique
636+
// metadata path. Without this, all databases share the same meta-path and
637+
// clobber each other's replication state.
638+
if dbc.MetaPath != nil {
639+
baseMetaPath, err := expand(*dbc.MetaPath)
640+
if err != nil {
641+
return nil, fmt.Errorf("failed to expand meta path for %s: %w", dbPath, err)
642+
}
643+
metaPathCopy := deriveMetaPathForDirectoryEntry(baseMetaPath, relPath)
644+
dbConfigCopy.MetaPath = &metaPathCopy
645+
}
646+
630647
// Deep copy replica config and make path unique per database.
631648
// This prevents all databases from writing to the same replica path.
632649
if dbc.Replica != nil {
@@ -697,6 +714,24 @@ func cloneReplicaConfigWithRelativePath(base *ReplicaConfig, relPath string) (*R
697714
return &replicaCopy, nil
698715
}
699716

717+
// deriveMetaPathForDirectoryEntry returns a unique metadata directory for a
718+
// database discovered through directory replication by appending the database's
719+
// relative path and the standard Litestream suffix to the configured base path.
720+
func deriveMetaPathForDirectoryEntry(basePath, relPath string) string {
721+
relPath = filepath.Clean(relPath)
722+
if relPath == "." || relPath == "" {
723+
return basePath
724+
}
725+
726+
relDir, relFile := filepath.Split(relPath)
727+
if relFile == "" || relFile == "." {
728+
return filepath.Join(basePath, relPath)
729+
}
730+
731+
metaDirName := "." + relFile + litestream.MetaDirSuffix
732+
return filepath.Join(basePath, relDir, metaDirName)
733+
}
734+
700735
// appendRelativePathToURL appends relPath to the URL's path component, ensuring
701736
// the result remains rooted and uses forward slashes.
702737
func appendRelativePathToURL(u *url.URL, relPath string) {

cmd/litestream/main_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"errors"
77
"os"
8+
"os/user"
89
"path/filepath"
910
"strings"
1011
"testing"
@@ -137,6 +138,46 @@ dbs:
137138
})
138139
}
139140

141+
func TestNewDBFromConfig_MetaPathExpansion(t *testing.T) {
142+
u, err := user.Current()
143+
if err != nil {
144+
t.Skipf("user.Current failed: %v", err)
145+
}
146+
if u.HomeDir == "" {
147+
t.Skip("no home directory available for expansion test")
148+
}
149+
150+
tmpDir := t.TempDir()
151+
dbPath := filepath.Join(tmpDir, "db.sqlite")
152+
replicaPath := filepath.Join(tmpDir, "replica")
153+
if err := os.MkdirAll(replicaPath, 0o755); err != nil {
154+
t.Fatalf("failed to create replica directory: %v", err)
155+
}
156+
157+
metaPath := filepath.Join("~", "litestream-meta")
158+
config := &main.DBConfig{
159+
Path: dbPath,
160+
MetaPath: &metaPath,
161+
Replica: &main.ReplicaConfig{
162+
Type: "file",
163+
Path: replicaPath,
164+
},
165+
}
166+
167+
db, err := main.NewDBFromConfig(config)
168+
if err != nil {
169+
t.Fatalf("NewDBFromConfig failed: %v", err)
170+
}
171+
172+
expectedMetaPath := filepath.Join(u.HomeDir, "litestream-meta")
173+
if got := db.MetaPath(); got != expectedMetaPath {
174+
t.Fatalf("MetaPath not expanded: got %s, want %s", got, expectedMetaPath)
175+
}
176+
if config.MetaPath == nil || *config.MetaPath != expectedMetaPath {
177+
t.Fatalf("config MetaPath not updated: got %v, want %s", config.MetaPath, expectedMetaPath)
178+
}
179+
}
180+
140181
func TestNewFileReplicaFromConfig(t *testing.T) {
141182
r, err := main.NewReplicaFromConfig(&main.ReplicaConfig{Path: "/foo"}, nil)
142183
if err != nil {
@@ -1614,6 +1655,74 @@ func TestNewDBsFromDirectoryConfig_UniquePaths(t *testing.T) {
16141655
}
16151656
}
16161657

1658+
// TestNewDBsFromDirectoryConfig_MetaPathPerDatabase ensures that each database
1659+
// discovered via a directory config receives a unique metadata directory when a
1660+
// base meta-path is provided.
1661+
func TestNewDBsFromDirectoryConfig_MetaPathPerDatabase(t *testing.T) {
1662+
tmpDir := t.TempDir()
1663+
1664+
rootDB := filepath.Join(tmpDir, "primary.db")
1665+
createSQLiteDB(t, rootDB)
1666+
1667+
nestedDir := filepath.Join(tmpDir, "team", "nested")
1668+
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
1669+
t.Fatalf("failed to create nested directory: %v", err)
1670+
}
1671+
nestedDB := filepath.Join(nestedDir, "analytics.db")
1672+
createSQLiteDB(t, nestedDB)
1673+
1674+
u, err := user.Current()
1675+
if err != nil {
1676+
t.Skipf("user.Current failed: %v", err)
1677+
}
1678+
if u.HomeDir == "" {
1679+
t.Skip("no home directory available for expansion test")
1680+
}
1681+
1682+
metaRoot := filepath.Join("~", "meta-root")
1683+
expandedMetaRoot := filepath.Join(u.HomeDir, "meta-root")
1684+
replicaDir := filepath.Join(t.TempDir(), "replica")
1685+
config := &main.DBConfig{
1686+
Dir: tmpDir,
1687+
Pattern: "*.db",
1688+
Recursive: true,
1689+
MetaPath: &metaRoot,
1690+
Replica: &main.ReplicaConfig{
1691+
Type: "file",
1692+
Path: replicaDir,
1693+
},
1694+
}
1695+
1696+
dbs, err := main.NewDBsFromDirectoryConfig(config)
1697+
if err != nil {
1698+
t.Fatalf("NewDBsFromDirectoryConfig failed: %v", err)
1699+
}
1700+
if len(dbs) != 2 {
1701+
t.Fatalf("expected 2 databases, got %d", len(dbs))
1702+
}
1703+
1704+
expectedMetaPaths := map[string]string{
1705+
rootDB: filepath.Join(expandedMetaRoot, ".primary.db"+litestream.MetaDirSuffix),
1706+
nestedDB: filepath.Join(expandedMetaRoot, "team", "nested", ".analytics.db"+litestream.MetaDirSuffix),
1707+
}
1708+
1709+
metaSeen := make(map[string]struct{})
1710+
for _, db := range dbs {
1711+
metaPath := db.MetaPath()
1712+
want, ok := expectedMetaPaths[db.Path()]
1713+
if !ok {
1714+
t.Fatalf("unexpected database path returned: %s", db.Path())
1715+
}
1716+
if metaPath != want {
1717+
t.Fatalf("database %s meta path mismatch: got %s, want %s", db.Path(), metaPath, want)
1718+
}
1719+
if _, dup := metaSeen[metaPath]; dup {
1720+
t.Fatalf("duplicate meta path detected: %s", metaPath)
1721+
}
1722+
metaSeen[metaPath] = struct{}{}
1723+
}
1724+
}
1725+
16171726
// TestNewDBsFromDirectoryConfig_SubdirectoryPaths verifies that the relative
16181727
// directory structure is preserved in replica paths when using recursive scanning.
16191728
func TestNewDBsFromDirectoryConfig_SubdirectoryPaths(t *testing.T) {

0 commit comments

Comments
 (0)