Skip to content

Commit

Permalink
tapdb: backup SQLite DB before running database migration
Browse files Browse the repository at this point in the history
This commit adds functionality to back up any existing SQLite
database file before attempting a database migration.

A backup file is only created if the database driver reports a different
current and max database versions.

This commit also adds a command line flag called `SkipMigrationDbBackup`
which can be set to skip database backup creation before migration.
  • Loading branch information
ffranr committed Jun 27, 2024
1 parent 202c050 commit 09daf8a
Show file tree
Hide file tree
Showing 2 changed files with 93 additions and 1 deletion.
3 changes: 3 additions & 0 deletions sample-tapd.conf
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@
; Skip applying migrations on startup
; sqlite.skipmigrations=false

; Skip database backup before schema migration
; sqlite.skipmigrationdbbackup=false

; The full path to the database
; sqlite.dbfile=~/.tapd/data/testnet/tapd.db

Expand Down
91 changes: 90 additions & 1 deletion tapdb/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ package tapdb

import (
"database/sql"
"errors"
"fmt"
"net/url"
"path/filepath"
"testing"
"time"

"github.com/golang-migrate/migrate/v4"
sqlite_migrate "github.com/golang-migrate/migrate/v4/database/sqlite"
"github.com/lightninglabs/taproot-assets/fn"
"github.com/lightninglabs/taproot-assets/tapdb/sqlc"
"github.com/stretchr/testify/require"
_ "modernc.org/sqlite" // Register relevant drivers.
Expand Down Expand Up @@ -53,6 +56,11 @@ type SqliteConfig struct {
// up if they don't already exist.
SkipMigrations bool `long:"skipmigrations" description:"Skip applying migrations on startup."`

// SkipMigrationDbBackup if true, then a backup of the database will not
// be created before applying migrations.
// nolint: lll
SkipMigrationDbBackup bool `long:"skipmigrationdbbackup" description:"Skip creating a backup of the database before applying migrations."`

// DatabaseFileName is the full file path where the database file can be
// found.
DatabaseFileName string `long:"dbfile" description:"The full path to the database."`
Expand Down Expand Up @@ -140,7 +148,7 @@ func NewSqliteStore(cfg *SqliteConfig) (*SqliteStore, error) {
// Now that the database is open, populate the database with our set of
// schemas based on our embedded in-memory file system.
if !cfg.SkipMigrations {
if err := s.ExecuteMigrations(TargetLatest); err != nil {
if err := s.ExecuteMigrations(s.backupAndMigrate); err != nil {
return nil, fmt.Errorf("error executing migrations: "+
"%w", err)
}
Expand All @@ -149,6 +157,87 @@ func NewSqliteStore(cfg *SqliteConfig) (*SqliteStore, error) {
return s, nil
}

// backupSqliteDatabase creates a backup of the given SQLite database.
func backupSqliteDatabase(srcDB *sql.DB, dbFullFilePath string) error {
if srcDB == nil {
return fmt.Errorf("backup source database is nil")
}

// Create a database backup file full path from the given source
// database full file path.
//
// Get the current time and format it as a Unix timestamp in
// nanoseconds.
timestamp := time.Now().UnixNano()

// Add the timestamp to the backup name.
backupFullFilePath := fmt.Sprintf(
"%s.%d.backup", dbFullFilePath, timestamp,
)

log.Infof("Creating backup of database file: %v -> %v",
dbFullFilePath, backupFullFilePath)

// Backup the database
vacuumIntoQuery := "VACUUM INTO ?;"
stmt, err := srcDB.Prepare(vacuumIntoQuery)
if err != nil {
return err
}
defer stmt.Close()

_, err = stmt.Exec(backupFullFilePath)
if err != nil {
return err
}

return nil
}

// backupAndMigrate is a helper function that creates a database backup before
// initiating the migration, and then migrates the database to the latest
// version.
func (s *SqliteStore) backupAndMigrate(mig *migrate.Migrate,
currentDbVersion fn.Option[uint],
maxMigrationVersion fn.Option[uint]) error {

// Determine if a database migration is necessary given the current
// database version and the maximum migration version known to the
// driver.
versionUpgradePending := false
currentDbVersion.WhenSome(func(currentV uint) {
maxMigrationVersion.WhenSome(func(maxV uint) {
versionUpgradePending = currentV < maxV
})
})

// If a database migration version upgrade is pending, create a backup
// of the database before starting the migration.
if versionUpgradePending && !s.cfg.SkipMigrationDbBackup {
log.Infof("Creating backup of database before applying " +
"migrations")
err := backupSqliteDatabase(s.DB, s.cfg.DatabaseFileName)
if err != nil {
return err
}
} else {
log.Infof("Skipping database backup before applying migrations")
}

// Migration to the target version.
err := mig.Up()
switch {
case errors.Is(err, migrate.ErrNoChange):
// If there were no changes we can return without error.
return nil

case err != nil:
return fmt.Errorf("error applying migrations: %w", err)
}

return nil
}

// ExecuteMigrations runs migrations for the sqlite database, depending on the
// target given, either all migrations or up to a given version.
func (s *SqliteStore) ExecuteMigrations(target MigrationTarget,
Expand Down

0 comments on commit 09daf8a

Please sign in to comment.