From dfedbcebf210d6e349f7d2fefaf86e0b07b4f03d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 15 Aug 2026 13:17:56 +0530 Subject: [PATCH 1/2] feat(storage): add consistent database backup via VACUUM INTO - Store.Backup snapshots the live database without blocking writers, using SQLite's VACUUM INTO for a compact, standalone backup file - Atomic write (temp file + rename) with owner-only permissions - Backup is rejected inside transactions; mocks implement the new interface method - Add tests covering snapshot consistency, permissions, and tx rejection --- engine/engine_mock_storage_test.go | 3 +- storage/backup.go | 45 ++++++++++++++++++ storage/backup_test.go | 74 ++++++++++++++++++++++++++++++ storage/interface.go | 4 ++ storage/interface_test.go | 4 ++ storage/mock.go | 14 ++++++ storage/sqlite_tx.go | 8 +++- 7 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 storage/backup.go create mode 100644 storage/backup_test.go diff --git a/engine/engine_mock_storage_test.go b/engine/engine_mock_storage_test.go index 76fc939..29b9ac2 100644 --- a/engine/engine_mock_storage_test.go +++ b/engine/engine_mock_storage_test.go @@ -686,4 +686,5 @@ func (m *mockStorage) GetAllSignatures(ctx context.Context) (map[string]string, func (m *mockStorage) WithTx(ctx context.Context, fn func(storage.Storage) error) error { return fn(m) } -func (m *mockStorage) Close() error { return nil } +func (m *mockStorage) Backup(ctx context.Context, backupPath string) error { return nil } +func (m *mockStorage) Close() error { return nil } diff --git a/storage/backup.go b/storage/backup.go new file mode 100644 index 0000000..c79a386 --- /dev/null +++ b/storage/backup.go @@ -0,0 +1,45 @@ +package storage + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" +) + +// Backup creates a consistent snapshot of the database at backupPath using +// SQLite's VACUUM INTO, which reads the live database without blocking +// writers for more than a moment and produces a compact, standalone file. +// The backup is written atomically (temp file + rename) and its permissions +// are restricted to owner-only, matching the main database file. +func (s *Store) Backup(ctx context.Context, backupPath string) error { + if backupPath == "" { + return fmt.Errorf("backup path must not be empty") + } + + dir := filepath.Dir(backupPath) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create backup directory: %w", err) + } + + tq, cancel := s.withTimeout(ctx) + defer cancel() + + // VACUUM INTO cannot target an existing file, so write to a temp file + // in the same directory and rename it into place. + tmp := backupPath + fmt.Sprintf(".%d.tmp", time.Now().UnixNano()) + if _, err := s.q().ExecContext(tq, `VACUUM INTO ?`, tmp); err != nil { + _ = os.Remove(tmp) // best-effort cleanup of any partial file + return fmt.Errorf("backup database: %w", err) + } + if err := os.Chmod(tmp, 0o600); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("restrict backup file permissions: %w", err) + } + if err := os.Rename(tmp, backupPath); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("finalize backup: %w", err) + } + return nil +} diff --git a/storage/backup_test.go b/storage/backup_test.go new file mode 100644 index 0000000..3952cfd --- /dev/null +++ b/storage/backup_test.go @@ -0,0 +1,74 @@ +package storage + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestBackup(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + ctx := context.Background() + + // Seed some data so the backup is non-trivial. + if err := s.CreateNode(ctx, &Node{ + ID: "backup-node", Type: "convention", Content: "backup me", + ContentHash: "h-backup", Scope: "project", Project: "test", + }); err != nil { + t.Fatal(err) + } + + backupPath := filepath.Join(t.TempDir(), "sub", "backup.db") + if err := s.Backup(ctx, backupPath); err != nil { + t.Fatalf("Backup: %v", err) + } + + fi, err := os.Stat(backupPath) + if err != nil { + t.Fatalf("backup file not created: %v", err) + } + if fi.Size() == 0 { + t.Error("backup file is empty") + } + // Owner-only permissions (mask umask for strictness). + if perm := fi.Mode().Perm() & 0o777; perm != 0o600 { + t.Errorf("backup permissions = %o, want 600", perm) + } + + // The backup must be a standalone, consistent database: opening it and + // reading the seeded node should work. + restore, err := NewStore(backupPath) + if err != nil { + t.Fatalf("open backup: %v", err) + } + defer restore.Close() + n, err := restore.GetNode(ctx, "backup-node") + if err != nil { + t.Fatalf("GetNode from backup: %v", err) + } + if n.Content != "backup me" { + t.Errorf("backup content = %q, want %q", n.Content, "backup me") + } +} + +func TestBackupEmptyPath(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + if err := s.Backup(context.Background(), ""); err == nil { + t.Error("expected error for empty backup path") + } +} + +func TestBackupInsideTx(t *testing.T) { + s, cleanup := setupStore(t) + defer cleanup() + ctx := context.Background() + err := s.WithTx(ctx, func(tx Storage) error { + return tx.Backup(ctx, filepath.Join(t.TempDir(), "nope.db")) + }) + if err == nil { + t.Error("expected error backing up inside a transaction") + } +} diff --git a/storage/interface.go b/storage/interface.go index 2a784da..7f08925 100644 --- a/storage/interface.go +++ b/storage/interface.go @@ -103,5 +103,9 @@ type Storage interface { // Transactions WithTx(ctx context.Context, fn func(Storage) error) error + // Backup writes a consistent, compact snapshot of the database to + // backupPath (atomic temp-file + rename, owner-only permissions). + Backup(ctx context.Context, backupPath string) error + Close() error } diff --git a/storage/interface_test.go b/storage/interface_test.go index e2cf48f..a23c69e 100644 --- a/storage/interface_test.go +++ b/storage/interface_test.go @@ -361,6 +361,10 @@ func (m *mockStorage) WithTx(ctx context.Context, fn func(Storage) error) error return fn(m) } +func (m *mockStorage) Backup(ctx context.Context, backupPath string) error { + return nil +} + func (m *mockStorage) Close() error { return nil } diff --git a/storage/mock.go b/storage/mock.go index 76e9a0a..7dfb605 100644 --- a/storage/mock.go +++ b/storage/mock.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "os" "sort" "strings" "sync" @@ -952,6 +953,19 @@ func (m *MockStorage) WithTx(_ context.Context, fn func(Storage) error) error { return fn(m) } +// ───────────────────── Backup ───────────────────── + +// Backup writes an empty file at backupPath to simulate a snapshot. +func (m *MockStorage) Backup(_ context.Context, backupPath string) error { + if err := m.err(); err != nil { + return err + } + if backupPath == "" { + return fmt.Errorf("backup path must not be empty") + } + return os.WriteFile(backupPath, nil, 0o600) +} + // ───────────────────── Close ───────────────────── func (m *MockStorage) Close() error { diff --git a/storage/sqlite_tx.go b/storage/sqlite_tx.go index b587ca7..4ce3e58 100644 --- a/storage/sqlite_tx.go +++ b/storage/sqlite_tx.go @@ -227,7 +227,13 @@ func (t *txStore) GetAllSignatures(ctx context.Context) (map[string]string, erro func (t *txStore) FlushAccessLog(ctx context.Context) (int, error) { return flushAccessLogQ(ctx, t.tx) } func (t *txStore) WithTx(ctx context.Context, fn func(Storage) error) error { return fn(t) } -func (t *txStore) Close() error { return nil } + +// Backup cannot run inside a transaction: callers must back up through a +// non-transactional Store handle. +func (t *txStore) Backup(ctx context.Context, backupPath string) error { + return fmt.Errorf("backup is not supported inside a transaction") +} +func (t *txStore) Close() error { return nil } // RollbackToVersion restores a node's content to a specific version. // All operations run inside a single transaction to prevent concurrent From 7a303096592d0ee638cb2709b5839a9f9b5e2096 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 15 Aug 2026 13:28:04 +0530 Subject: [PATCH 2/2] ci: bump Go to 1.26.6 (fixes govulncheck stdlib CVEs GO-2026-{5026,5972,6089,6090,6091,6218}) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5197384..fafcd3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: "1.26.5" + GO_VERSION: "1.26.6" GOPRIVATE: "github.com/GrayCodeAI/*" GONOSUMDB: "github.com/GrayCodeAI/*" GONOSUMCHECK: "1"