1 Commits
Author SHA1 Message Date
sneak 7a39442eac Run the local index in WAL mode with a busy timeout (closes #217)
check / check (pull_request) Successful in 5m4s
The connection settings were passed as `_journal_mode=`-style
parameters, which the SQLite driver drops without an error, so the
index ran in rollback-journal mode with no busy timeout. `snapshot
list` or `info` reading during a backup could make the backup's next
write fail with "database is locked". Both open paths now pass
`_pragma=` parameters; foreign keys moved there too.

With WAL on, rows committed to the open index can still be in the
-wal file, which a copy of the main file misses. The metadata export
now copies the index with VACUUM INTO, into an empty 0600 file.

The retry after a failed open no longer claims a TRUNCATE recovery; it
retries with the same settings.

Model: opus-5-5
2026-10-06 08:49:03 +00:00
2 changed files with 11 additions and 11 deletions
+9 -9
View File
@@ -42,9 +42,9 @@ var schemaFS embed.FS
// table itself. It is applied before the normal migration loop. // table itself. It is applied before the normal migration loop.
const bootstrapVersion = 0 const bootstrapVersion = 0
// busyTimeoutMsec is how long a connection to the index waits for another // busyTimeoutMs is how long a connection to the index waits for another
// connection's lock before failing with "database is locked". // connection's lock before failing with "database is locked".
const busyTimeoutMsec = 10000 const busyTimeoutMs = 10000
// DB represents the Vaultik local index database connection. // DB represents the Vaultik local index database connection.
// It uses SQLite to track file metadata, content-defined chunks, and blob associations. // It uses SQLite to track file metadata, content-defined chunks, and blob associations.
@@ -108,7 +108,7 @@ func indexDSN(path string) string {
return fmt.Sprintf( return fmt.Sprintf(
"%s?_pragma=busy_timeout(%d)&_pragma=journal_mode(WAL)"+ "%s?_pragma=busy_timeout(%d)&_pragma=journal_mode(WAL)"+
"&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)", "&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)",
path, busyTimeoutMsec) path, busyTimeoutMs)
} }
// New creates a new database connection at the specified path. // New creates a new database connection at the specified path.
@@ -152,7 +152,7 @@ func New(ctx context.Context, path string) (*DB, error) {
} }
// If the first attempt failed, try once more // If the first attempt failed, try once more
return openWithRecovery(ctx, path) return retryOpen(ctx, path)
} }
// configureConnPool serializes all database access through one connection. // configureConnPool serializes all database access through one connection.
@@ -179,10 +179,10 @@ func finishOpen(ctx context.Context, conn *sql.DB, path string) (*DB, error) {
return db, nil return db, nil
} }
// openWithRecovery makes a second attempt to open the database, with the // retryOpen makes a second attempt to open the database, with the same
// same settings, after the first attempt failed, for example because // settings, after the first attempt failed, for example because another
// another process held a lock for longer than the busy timeout. // process held a lock for longer than the busy timeout.
func openWithRecovery(ctx context.Context, path string) (*DB, error) { func retryOpen(ctx context.Context, path string) (*DB, error) {
log.Info("Database appears locked, retrying open", "path", path) log.Info("Database appears locked, retrying open", "path", path)
conn, err := sql.Open("sqlite", indexDSN(path)) conn, err := sql.Open("sqlite", indexDSN(path))
@@ -202,7 +202,7 @@ func openWithRecovery(ctx context.Context, path string) (*DB, error) {
_ = conn.Close() _ = conn.Close()
return nil, fmt.Errorf( return nil, fmt.Errorf(
"database still locked after recovery attempt: %w", "database still locked on retry: %w",
err, err,
) )
} }
+2 -2
View File
@@ -153,8 +153,8 @@ func TestNewSetsJournalModeAndBusyTimeout(t *testing.T) {
t.Fatalf("reading busy_timeout: %v", err) t.Fatalf("reading busy_timeout: %v", err)
} }
if busyTimeout != busyTimeoutMsec { if busyTimeout != busyTimeoutMs {
t.Errorf("busy_timeout = %d, want %d", busyTimeout, busyTimeoutMsec) t.Errorf("busy_timeout = %d, want %d", busyTimeout, busyTimeoutMs)
} }
} }