Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94c577e22a |
@@ -765,9 +765,8 @@ them. We provide:
|
|||||||
|
|
||||||
* `script/bootstrap` — install all development dependencies (Go, Go
|
* `script/bootstrap` — install all development dependencies (Go, Go
|
||||||
module download). A host without Go gets the `go.mod` version through
|
module download). A host without Go gets the `go.mod` version through
|
||||||
`script/install-go`, in `.tool/go`, which `script/bootstrap` itself,
|
`script/install-go`, in `.tool/go`, which the `Makefile`, `script/fmt`,
|
||||||
the `Makefile`, `script/fmt`, `script/fmt-check`, `script/precommit`
|
`script/fmt-check` and `script/precommit` add to their `PATH`. It
|
||||||
and `script/release` add to their `PATH`. It
|
|
||||||
deliberately does not install `golangci-lint`; see `script/lint`
|
deliberately does not install `golangci-lint`; see `script/lint`
|
||||||
below.
|
below.
|
||||||
* `script/setup` — make a fresh clone ready for development: runs
|
* `script/setup` — make a fresh clone ready for development: runs
|
||||||
@@ -819,7 +818,8 @@ them. We provide:
|
|||||||
find out whether the tree is clean.
|
find out whether the tree is clean.
|
||||||
* `script/fmt` — format all code (writes)
|
* `script/fmt` — format all code (writes)
|
||||||
* `script/fmt-check` — check formatting (read-only). It runs `gofmt` on
|
* `script/fmt-check` — check formatting (read-only). It runs `gofmt` on
|
||||||
the host, over every Go file outside `.tool`.
|
the host, over the Go files git lists, and fails outside a git
|
||||||
|
checkout.
|
||||||
* `script/check` — run `script/test`, `script/lint`, and
|
* `script/check` — run `script/test`, `script/lint`, and
|
||||||
`script/fmt-check`.
|
`script/fmt-check`.
|
||||||
* `script/docker` — build the Docker image tagged via
|
* `script/docker` — build the Docker image tagged via
|
||||||
|
|||||||
@@ -31,15 +31,6 @@ the tag exists and is exercised; what is left is merging `next` to
|
|||||||
is v2.14.0, with its new findings fixed in the code, and the rules in
|
is v2.14.0, with its new findings fixed in the code, and the rules in
|
||||||
`CLAUDE.md` now live in `AGENTS.md`.
|
`CLAUDE.md` now live in `AGENTS.md`.
|
||||||
|
|
||||||
- 2026-10-06: Made the local index actually run in WAL mode with a busy
|
|
||||||
timeout ([issue #217](https://git.eeqj.de/sneak/vaultik/issues/217)).
|
|
||||||
The connection settings were written in a form the SQLite driver
|
|
||||||
ignores, so the index ran without either and `snapshot list` or `info`
|
|
||||||
during a backup could make the backup's next write fail with
|
|
||||||
`database is locked`. They are now `_pragma=` parameters, and the
|
|
||||||
metadata export copies the open index with `VACUUM INTO`, because a
|
|
||||||
copy of the file alone misses rows still in the `-wal` file.
|
|
||||||
|
|
||||||
- 2026-10-06: Made a backup record the real uid and gid of files,
|
- 2026-10-06: Made a backup record the real uid and gid of files,
|
||||||
directories and symlinks
|
directories and symlinks
|
||||||
([issue #216](https://git.eeqj.de/sneak/vaultik/issues/216)). The
|
([issue #216](https://git.eeqj.de/sneak/vaultik/issues/216)). The
|
||||||
|
|||||||
+1
-1
@@ -280,7 +280,7 @@ This ensures consistency, especially important for operations like:
|
|||||||
|
|
||||||
3. **Batch Operations**: Where possible, operations are batched within transactions
|
3. **Batch Operations**: Where possible, operations are batched within transactions
|
||||||
|
|
||||||
4. **Write-Ahead Logging**: The local index runs in SQLite WAL mode with a 10-second busy timeout, so a read-only command such as `snapshot list` can read it while a backup writes to it. Committed rows can sit in the `-wal` file beside the index until a checkpoint, so the metadata export copies the index through SQLite (`VACUUM INTO`), not as a file
|
4. **Write-Ahead Logging**: SQLite WAL mode is enabled for better concurrency
|
||||||
|
|
||||||
## Data Integrity
|
## Data Integrity
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ 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
|
||||||
|
|
||||||
// busyTimeoutMs is how long a connection to the index waits for another
|
|
||||||
// connection's lock before failing with "database is locked".
|
|
||||||
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.
|
||||||
// The database enables incremental backups by detecting changed files and
|
// The database enables incremental backups by detecting changed files and
|
||||||
@@ -98,27 +94,10 @@ func ParseMigrationVersion(filename string) (int, error) {
|
|||||||
return version, nil
|
return version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// indexDSN returns the driver DSN that opens the database at path. The
|
|
||||||
// driver runs each _pragma parameter on every connection it opens and drops
|
|
||||||
// any parameter it does not know without an error, so a setting written in
|
|
||||||
// another form silently does nothing. In WAL mode one connection can write
|
|
||||||
// while others read; the busy timeout makes a connection wait for a lock
|
|
||||||
// instead of failing at once.
|
|
||||||
func indexDSN(path string) string {
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"%s?_pragma=busy_timeout(%d)&_pragma=journal_mode(WAL)"+
|
|
||||||
"&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)",
|
|
||||||
path, busyTimeoutMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new database connection at the specified path.
|
// New creates a new database connection at the specified path.
|
||||||
// It creates the schema if needed. Every connection runs in WAL mode with
|
// It creates the schema if needed and configures SQLite with WAL mode for
|
||||||
// a busy timeout and foreign keys on (see indexDSN), so a read-only command
|
// better concurrency. SQLite handles crash recovery automatically when
|
||||||
// can read the index while a backup writes to it. Committed rows can sit in
|
// opening a database with journal/WAL files present.
|
||||||
// the -wal file beside the database until a checkpoint, so a copy of the
|
|
||||||
// database file alone may miss them.
|
|
||||||
// SQLite handles crash recovery automatically when opening a database with
|
|
||||||
// journal/WAL files present.
|
|
||||||
// The path parameter can be a file path for persistent storage or ":memory:"
|
// The path parameter can be a file path for persistent storage or ":memory:"
|
||||||
// for an in-memory database (useful for testing).
|
// for an in-memory database (useful for testing).
|
||||||
func New(ctx context.Context, path string) (*DB, error) {
|
func New(ctx context.Context, path string) (*DB, error) {
|
||||||
@@ -131,7 +110,11 @@ func New(ctx context.Context, path string) (*DB, error) {
|
|||||||
// First attempt with standard WAL mode
|
// First attempt with standard WAL mode
|
||||||
log.Debug("Attempting to open database with WAL mode", "path", path)
|
log.Debug("Attempting to open database with WAL mode", "path", path)
|
||||||
|
|
||||||
conn, err := sql.Open("sqlite", indexDSN(path))
|
conn, err := sql.Open(
|
||||||
|
"sqlite",
|
||||||
|
path+"?_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=10000"+
|
||||||
|
"&_locking_mode=NORMAL&_foreign_keys=ON",
|
||||||
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
configureConnPool(conn)
|
configureConnPool(conn)
|
||||||
|
|
||||||
@@ -151,8 +134,8 @@ func New(ctx context.Context, path string) (*DB, error) {
|
|||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the first attempt failed, try once more
|
// If first attempt failed, try with TRUNCATE mode to clear any locks
|
||||||
return retryOpen(ctx, path)
|
return openWithRecovery(ctx, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// configureConnPool serializes all database access through one connection.
|
// configureConnPool serializes all database access through one connection.
|
||||||
@@ -164,12 +147,18 @@ func configureConnPool(conn *sql.DB) {
|
|||||||
conn.SetMaxIdleConns(1)
|
conn.SetMaxIdleConns(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// finishOpen wraps the connection and applies any pending migrations. On
|
// finishOpen enables foreign keys, wraps the connection, and applies any
|
||||||
// migration failure the connection is closed.
|
// pending migrations. On migration failure the connection is closed.
|
||||||
func finishOpen(ctx context.Context, conn *sql.DB, path string) (*DB, error) {
|
func finishOpen(ctx context.Context, conn *sql.DB, path string) (*DB, error) {
|
||||||
|
// Enable foreign keys explicitly
|
||||||
|
_, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = ON")
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Failed to enable foreign keys", "path", path, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
db := &DB{conn: conn, path: path}
|
db := &DB{conn: conn, path: path}
|
||||||
|
|
||||||
err := applyMigrations(ctx, conn)
|
err = applyMigrations(ctx, conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
|
|
||||||
@@ -179,15 +168,21 @@ func finishOpen(ctx context.Context, conn *sql.DB, path string) (*DB, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// retryOpen makes a second attempt to open the database, with the same
|
// openWithRecovery retries opening the database in TRUNCATE journal mode to
|
||||||
// settings, after the first attempt failed, for example because another
|
// clear stale locks, then switches back to WAL mode.
|
||||||
// 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(
|
||||||
log.Info("Database appears locked, retrying open", "path", path)
|
"Database appears locked, attempting recovery with TRUNCATE mode",
|
||||||
|
"path", path,
|
||||||
|
)
|
||||||
|
|
||||||
conn, err := sql.Open("sqlite", indexDSN(path))
|
conn, err := sql.Open(
|
||||||
|
"sqlite",
|
||||||
|
path+"?_journal_mode=TRUNCATE&_synchronous=NORMAL&_busy_timeout=10000"+
|
||||||
|
"&_foreign_keys=ON",
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opening database on retry: %w", err)
|
return nil, fmt.Errorf("opening database in recovery mode: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
configureConnPool(conn)
|
configureConnPool(conn)
|
||||||
@@ -195,18 +190,28 @@ func retryOpen(ctx context.Context, path string) (*DB, error) {
|
|||||||
err = conn.PingContext(ctx)
|
err = conn.PingContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debug(
|
log.Debug(
|
||||||
"Failed to ping database on retry, closing",
|
"Failed to ping database in recovery mode, closing",
|
||||||
"path", path, "error", err,
|
"path", path, "error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"database still locked on retry: %w",
|
"database still locked after recovery attempt: %w",
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Debug("Database opened in TRUNCATE mode", "path", path)
|
||||||
|
|
||||||
|
// Switch back to WAL mode
|
||||||
|
log.Debug("Switching database back to WAL mode", "path", path)
|
||||||
|
|
||||||
|
_, err = conn.ExecContext(ctx, "PRAGMA journal_mode=WAL")
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Failed to switch back to WAL mode", "path", path, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
db, err := finishOpen(ctx, conn, path)
|
db, err := finishOpen(ctx, conn, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -120,89 +120,6 @@ func TestDatabaseConcurrentAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewSetsJournalModeAndBusyTimeout checks that the connection settings
|
|
||||||
// New passes reach SQLite. The driver drops a setting it does not recognise
|
|
||||||
// without an error, so only reading the value back shows it took effect.
|
|
||||||
func TestNewSetsJournalModeAndBusyTimeout(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
db, err := New(ctx, filepath.Join(t.TempDir(), "index.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = db.Close() }()
|
|
||||||
|
|
||||||
var journalMode string
|
|
||||||
|
|
||||||
err = db.conn.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journalMode)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading journal_mode: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if journalMode != "wal" {
|
|
||||||
t.Errorf("journal_mode = %q, want %q", journalMode, "wal")
|
|
||||||
}
|
|
||||||
|
|
||||||
var busyTimeout int
|
|
||||||
|
|
||||||
err = db.conn.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busyTimeout)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading busy_timeout: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if busyTimeout != busyTimeoutMs {
|
|
||||||
t.Errorf("busy_timeout = %d, want %d", busyTimeout, busyTimeoutMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewWriteSucceedsWhileAnotherHandleReads opens the same index twice,
|
|
||||||
// as a read-only command does while a backup runs, and checks that a write
|
|
||||||
// on one handle commits while the other is in the middle of a read.
|
|
||||||
func TestNewWriteSucceedsWhileAnotherHandleReads(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
dbPath := filepath.Join(t.TempDir(), "index.db")
|
|
||||||
|
|
||||||
reader, err := New(ctx, dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to open reading handle: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = reader.Close() }()
|
|
||||||
|
|
||||||
writer, err := New(ctx, dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to open writing handle: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = writer.Close() }()
|
|
||||||
|
|
||||||
// The read lock taken by the SELECT is held until the transaction ends.
|
|
||||||
readTx, err := reader.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("beginning read transaction: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = readTx.Rollback() }()
|
|
||||||
|
|
||||||
var count int
|
|
||||||
|
|
||||||
err = readTx.QueryRowContext(ctx, "SELECT COUNT(*) FROM chunks").Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading chunks: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = writer.ExecWithLog(ctx,
|
|
||||||
"INSERT INTO chunks (chunk_hash, size) VALUES (?, ?)", "hash", 1024)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("write while another handle reads: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseMigrationVersion(t *testing.T) {
|
func TestParseMigrationVersion(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +1,40 @@
|
|||||||
//nolint:testpackage // exercises the unexported copyDatabase helper
|
//nolint:testpackage // exercises the unexported copyFile helper
|
||||||
package snapshot
|
package snapshot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestCopyDatabaseExportCopyMode verifies that the exported snapshot
|
// TestCopyFileExportCopyMode verifies that the exported snapshot database
|
||||||
// database copy is created owner-only (0600), even under a lenient 022
|
// copy is created owner-only (0600), even under a lenient 022 umask that
|
||||||
// umask that would otherwise leave a fresh file world-readable.
|
// would otherwise leave a fresh file world-readable.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash
|
//nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash
|
||||||
func TestCopyDatabaseExportCopyMode(t *testing.T) {
|
func TestCopyFileExportCopyMode(t *testing.T) {
|
||||||
restore := syscall.Umask(0o022)
|
restore := syscall.Umask(0o022)
|
||||||
defer syscall.Umask(restore)
|
defer syscall.Umask(restore)
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
src := filepath.Join(dir, "index.sqlite")
|
src := filepath.Join(dir, "index.sqlite")
|
||||||
|
|
||||||
db, err := database.New(ctx, src)
|
err := os.WriteFile(src, []byte("index data"), 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("creating source index: %v", err)
|
t.Fatalf("creating source index: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = db.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("closing source index: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dst := filepath.Join(dir, "snapshot.db")
|
dst := filepath.Join(dir, "snapshot.db")
|
||||||
|
|
||||||
sm := &SnapshotManager{fs: afero.NewOsFs()}
|
sm := &SnapshotManager{fs: afero.NewOsFs()}
|
||||||
|
|
||||||
err = sm.copyDatabase(ctx, src, dst)
|
err = sm.copyFile(src, dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("copyDatabase: %v", err)
|
t.Fatalf("copyFile: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := os.Stat(dst)
|
info, err := os.Stat(dst)
|
||||||
|
|||||||
@@ -386,12 +386,12 @@ func (sm *SnapshotManager) prepareExportDB(
|
|||||||
ctx context.Context, dbPath, snapshotID, tempDir string,
|
ctx context.Context, dbPath, snapshotID, tempDir string,
|
||||||
) ([]byte, string, error) {
|
) ([]byte, string, error) {
|
||||||
// Step 1: Copy database to temp file
|
// Step 1: Copy database to temp file
|
||||||
// The main database is still open here, so it is copied through SQLite
|
// The main database should be closed at this point
|
||||||
tempDBPath := filepath.Join(tempDir, "snapshot.db")
|
tempDBPath := filepath.Join(tempDir, "snapshot.db")
|
||||||
log.Debug("Copying database to temporary location",
|
log.Debug("Copying database to temporary location",
|
||||||
"source", dbPath, "destination", tempDBPath)
|
"source", dbPath, "destination", tempDBPath)
|
||||||
|
|
||||||
err := sm.copyDatabase(ctx, dbPath, tempDBPath)
|
err := sm.copyFile(dbPath, tempDBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("copying database: %w", err)
|
return nil, "", fmt.Errorf("copying database: %w", err)
|
||||||
}
|
}
|
||||||
@@ -648,10 +648,9 @@ func (sm *SnapshotManager) collectCleanupStats(
|
|||||||
//
|
//
|
||||||
// VACUUM runs through the modernc.org/sqlite driver, on a freshly opened
|
// VACUUM runs through the modernc.org/sqlite driver, on a freshly opened
|
||||||
// connection with no transaction in flight (VACUUM cannot run inside one).
|
// connection with no transaction in flight (VACUUM cannot run inside one).
|
||||||
// database.New opens the file in WAL mode, so VACUUM's rewrite lands in the
|
// The database opens in WAL mode, so VACUUM's rewrite lands in the WAL; the
|
||||||
// -wal file. This is the only connection to the file, so closing it
|
// checkpoint on Close flushes it into the main file, which is the file we
|
||||||
// checkpoints the rewrite into the main file and removes the -wal file; the
|
// then compress and upload.
|
||||||
// main file is the one compressFile then compresses and uploads.
|
|
||||||
func (sm *SnapshotManager) vacuumDatabase(ctx context.Context, dbPath string) error {
|
func (sm *SnapshotManager) vacuumDatabase(ctx context.Context, dbPath string) error {
|
||||||
log.Debug("Running VACUUM on database", "path", dbPath)
|
log.Debug("Running VACUUM on database", "path", dbPath)
|
||||||
|
|
||||||
@@ -745,15 +744,26 @@ func (sm *SnapshotManager) compressFile(inputPath, outputPath string) error {
|
|||||||
// user; it holds the same private index data as the local index file.
|
// user; it holds the same private index data as the local index file.
|
||||||
const exportCopyPerm = 0o600
|
const exportCopyPerm = 0o600
|
||||||
|
|
||||||
// copyDatabase copies the database at src to dst with VACUUM INTO. It reads
|
// copyFile copies a file from src to dst. The destination is the exported
|
||||||
// through SQLite, so the copy holds rows committed to src that are still in
|
// snapshot database, so it is created owner-only rather than with the
|
||||||
// its -wal file, which a copy of the file alone would miss. The destination
|
// umask-dependent default.
|
||||||
// is the exported snapshot database, so it is created empty and owner-only
|
func (sm *SnapshotManager) copyFile(src, dst string) error {
|
||||||
// first rather than with the umask-dependent default: VACUUM INTO writes
|
log.Debug("Opening source file for copy", "path", src)
|
||||||
// into an existing empty file and keeps its mode.
|
|
||||||
func (sm *SnapshotManager) copyDatabase(
|
sourceFile, err := sm.fs.Open(src)
|
||||||
ctx context.Context, src, dst string,
|
if err != nil {
|
||||||
) error {
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
log.Debug("Closing source file", "path", src)
|
||||||
|
|
||||||
|
err := sourceFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Debug("Failed to close source file", "path", src, "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
log.Debug("Creating destination file", "path", dst)
|
log.Debug("Creating destination file", "path", dst)
|
||||||
|
|
||||||
destFile, err := sm.fs.OpenFile(
|
destFile, err := sm.fs.OpenFile(
|
||||||
@@ -763,28 +773,23 @@ func (sm *SnapshotManager) copyDatabase(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = destFile.Close()
|
defer func() {
|
||||||
|
log.Debug("Closing destination file", "path", dst)
|
||||||
|
|
||||||
|
err := destFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Debug("Failed to close destination file", "path", dst, "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Debug("Copying file data")
|
||||||
|
|
||||||
|
n, err := io.Copy(destFile, sourceFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := database.New(ctx, src)
|
log.Debug("File copy complete", "bytes_copied", n)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("opening database to copy: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
cerr := db.Close()
|
|
||||||
if cerr != nil {
|
|
||||||
log.Debug("Failed to close database after copy",
|
|
||||||
"path", src, "error", cerr)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
_, err = db.ExecWithLog(ctx, "VACUUM INTO ?", dst)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("running VACUUM INTO: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,77 +188,6 @@ func TestVacuumDatabaseRemovesDeletedData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPrepareExportDBKeepsRowsCommittedToOpenIndex exports from an index
|
|
||||||
// that is still open, as a backup does. A row committed there can still be
|
|
||||||
// in the index's -wal file, and the export must hold it all the same.
|
|
||||||
func TestPrepareExportDBKeepsRowsCommittedToOpenIndex(t *testing.T) {
|
|
||||||
log.Initialize(log.Config{})
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
|
|
||||||
dbPath := filepath.Join(t.TempDir(), "index.sqlite")
|
|
||||||
|
|
||||||
db, err := database.New(ctx, dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = db.Close() }()
|
|
||||||
|
|
||||||
repos := database.NewRepositories(db)
|
|
||||||
|
|
||||||
snapshot := &database.Snapshot{ID: "open-index-snapshot", Hostname: "test-host"}
|
|
||||||
|
|
||||||
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
||||||
return repos.Snapshots.Create(ctx, tx, snapshot)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create snapshot: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := &SnapshotManager{
|
|
||||||
config: &config.Config{
|
|
||||||
CompressionLevel: 3,
|
|
||||||
AgeRecipients: []string{testAgeRecipient},
|
|
||||||
},
|
|
||||||
fs: fs,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, tempDBPath, err := sm.prepareExportDB(
|
|
||||||
ctx, dbPath, snapshot.ID.String(), t.TempDir())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("prepareExportDB failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the main database file is compressed and uploaded, so open a
|
|
||||||
// copy of that file alone.
|
|
||||||
uploadedPath := filepath.Join(t.TempDir(), "uploaded.db")
|
|
||||||
|
|
||||||
err = copyFile(fs, tempDBPath, uploadedPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to copy exported database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
exported, err := database.OpenReadOnly(ctx, uploadedPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to open exported database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = exported.Close() }()
|
|
||||||
|
|
||||||
got, err := database.NewRepositories(exported).Snapshots.GetByID(
|
|
||||||
ctx, snapshot.ID.String())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read snapshot from export: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got == nil {
|
|
||||||
t.Fatal("exported database is missing the snapshot row")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCleanSnapshotDBEmptySnapshot(t *testing.T) {
|
func TestCleanSnapshotDBEmptySnapshot(t *testing.T) {
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
log.Initialize(log.Config{})
|
log.Initialize(log.Config{})
|
||||||
|
|||||||
+2
-2
@@ -106,8 +106,8 @@ main() {
|
|||||||
|
|
||||||
# Go toolchain: the host's own, or else the version go.mod names,
|
# Go toolchain: the host's own, or else the version go.mod names,
|
||||||
# hash-verified, in .tool/go. That directory is not on the caller's
|
# hash-verified, in .tool/go. That directory is not on the caller's
|
||||||
# PATH; this script, the Makefile, script/fmt, script/fmt-check,
|
# PATH; the Makefile, script/fmt, script/fmt-check and
|
||||||
# script/precommit and script/release add it to theirs.
|
# script/precommit add it to theirs.
|
||||||
if missing go; then
|
if missing go; then
|
||||||
"$ROOT/script/install-go"
|
"$ROOT/script/install-go"
|
||||||
PATH="$PATH:$ROOT/.tool/go/bin"
|
PATH="$PATH:$ROOT/.tool/go/bin"
|
||||||
|
|||||||
+17
-4
@@ -9,12 +9,25 @@ main() {
|
|||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
# Where script/bootstrap installs Go when the host has none.
|
# Where script/bootstrap installs Go when the host has none.
|
||||||
PATH="$PATH:$ROOT/.tool/go/bin"
|
PATH="$PATH:$ROOT/.tool/go/bin"
|
||||||
# .tool/go holds that toolchain's own sources, which are not ours.
|
# The Go files git lists, which leaves out the sources of that
|
||||||
if ! unformatted="$(find . -path ./.tool -prune -o -name '*.go' -type f \
|
# toolchain in the ignored .tool/go. git still lists a tracked file
|
||||||
-exec gofmt -l {} +)"; then
|
# whose deletion is not staged yet, and gofmt fails on a missing file.
|
||||||
echo "fmt-check: gofmt failed" >&2
|
listed="$(git ls-files --cached --others --exclude-standard '*.go')" || {
|
||||||
|
echo "fmt-check: git ls-files failed; run it in a git checkout" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
}
|
||||||
|
files=""
|
||||||
|
for f in $listed; do
|
||||||
|
if [ -e "$f" ]; then
|
||||||
|
files="$files $f"
|
||||||
fi
|
fi
|
||||||
|
done
|
||||||
|
# gofmt with no file arguments reads standard input.
|
||||||
|
if [ -z "$files" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC2086 # one argument per file name
|
||||||
|
unformatted="$(gofmt -l $files)"
|
||||||
if [ -n "$unformatted" ]; then
|
if [ -n "$unformatted" ]; then
|
||||||
echo "Files not formatted:" >&2
|
echo "Files not formatted:" >&2
|
||||||
echo "$unformatted" >&2
|
echo "$unformatted" >&2
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ resolve_goreleaser() {
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
# Where script/bootstrap installs Go when the host has none.
|
|
||||||
PATH="$PATH:$ROOT/.tool/go/bin"
|
|
||||||
|
|
||||||
if ! bin="$(resolve_goreleaser)"; then
|
if ! bin="$(resolve_goreleaser)"; then
|
||||||
cat >&2 <<EOF
|
cat >&2 <<EOF
|
||||||
|
|||||||
Reference in New Issue
Block a user