Skip to content

Commit 8c3ff91

Browse files
committed
tapdb: check and error on dirty database version
Add a check during startup to ensure that the database version is not dirty (i.e., the last migration did not complete successfully). If it is dirty, the application will exit with an error. This is done to resolve a bug that would occur when the **latest** migration failed. In that scenario, the database version would correctly be marked as dirty and exit with an error. However, under `sqlite` database backends, when `tapd` was restarted, the dirty database version would never be checked and `tapd` would start up normally, despite the database being in a dirty state. This is due to the check here: https://github.com/lightninglabs/taproot-assets/blob/0ddb08670f59e41e7edb2925a49df18d2124598e/tapdb/sqlite.go#L206-L213 In that scenario, since no migration is needed the migrate library is never called to perform any potential migration. And since the dirty check is normally done in the migrate library during a migration, it is never performed in that scenario.
1 parent 8ffd112 commit 8c3ff91

File tree

2 files changed

+87
-1
lines changed

2 files changed

+87
-1
lines changed

tapdb/migrations.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,12 +166,20 @@ func applyMigrations(fs fs.FS, driver database.Driver, path, dbName string,
166166
return err
167167
}
168168

169-
migrationVersion, _, err := sqlMigrate.Version()
169+
migrationVersion, dirty, err := sqlMigrate.Version()
170170
if err != nil && !errors.Is(err, migrate.ErrNilVersion) {
171171
return fmt.Errorf("unable to determine current migration "+
172172
"version: %w", err)
173173
}
174174

175+
// If the migration version is dirty, we should not proceed with further
176+
// migrations, as this indicates that a previous migration did not
177+
// complete successfully and requires manual intervention.
178+
if dirty {
179+
return fmt.Errorf("database is in a dirty state at version "+
180+
"%v, manual intervention required", migrationVersion)
181+
}
182+
175183
// As the down migrations may end up *dropping* data, we want to
176184
// prevent that without explicit accounting.
177185
latestVersion := opts.latestVersion.UnwrapOr(LatestMigrationVersion)

tapdb/migrations_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package tapdb
33
import (
44
"context"
55
"encoding/hex"
6+
"errors"
67
"fmt"
78
"os"
89
"path/filepath"
@@ -694,3 +695,80 @@ func TestMigration37(t *testing.T) {
694695

695696
require.Len(t, burns, 5)
696697
}
698+
699+
// TestDirtySqliteVersion tests that if a migration fails and leaves an Sqlite
700+
// database backend in a dirty state, any attempts of re-executing migrations on
701+
// the db (i.e. restart tapd), will fail with an error indicating that the
702+
// database is in a dirty state. This is regardless of whether the failing
703+
// migration is the latest migration or an intermediate migration.
704+
func TestDirtySqliteVersion(t *testing.T) {
705+
var (
706+
err error
707+
testError = errors.New("test error")
708+
709+
// testPostMigrationChecks1 is a map that will trigger a
710+
// migration callback for migration 2 which always returns an
711+
// error. This is used to simulate an intermediate migration
712+
// that fails and leaves the db in a dirty state.
713+
testPostMigrationChecks1 = map[uint]postMigrationCheck{
714+
2: func(ctx context.Context, q sqlc.Querier) error {
715+
return testError
716+
},
717+
}
718+
719+
// testPostMigrationChecks2 is a map that will trigger a
720+
// migration callback for the latest migration which always
721+
// returns an error. This is used to simulate that the latest
722+
// migration fails and leaves the db in a dirty state.
723+
testPostMigrationChecks2 = map[uint]postMigrationCheck{
724+
LatestMigrationVersion: func(ctx context.Context,
725+
q sqlc.Querier) error {
726+
727+
return testError
728+
},
729+
}
730+
)
731+
732+
// First, we'll test that intermediate migration that fails and leaves
733+
// the db in a dirty state is detected on subsequent migration attempts.
734+
// Note that this test only targets Sqlite database backends, and
735+
// therefore we create a new Sqlite test db for this.
736+
db1 := NewTestSqliteDBWithVersion(t, 1)
737+
738+
// As intend that the failing migration version should be an
739+
// intermediate migration, we use the testPostMigrationChecks1 when
740+
// executing the migration. Note that we use the `backupAndMigrate` func
741+
// as the MigrationTarget, to simulate what is used in production for
742+
// Sqlite database backends.
743+
err = db1.ExecuteMigrations(db1.backupAndMigrate, WithPostStepCallbacks(
744+
makePostStepCallbacks(db1, testPostMigrationChecks1),
745+
))
746+
require.ErrorIs(t, err, testError)
747+
748+
// If we now attempt to execute migrations again, it should fail with an
749+
// error indicating that the db is in a dirty state.
750+
err = db1.ExecuteMigrations(db1.backupAndMigrate, WithPostStepCallbacks(
751+
makePostStepCallbacks(db1, testPostMigrationChecks1),
752+
))
753+
require.ErrorContains(t, err, "database is in a dirty state")
754+
755+
// Next, we'll test that if the **latest** migration fails and leaves
756+
// the db in a dirty state, that this is also detected on subsequent
757+
// migration attempts.
758+
db2 := NewTestSqliteDBWithVersion(t, 1)
759+
760+
// As we intend that the failing migration version should be the latest
761+
// migration, we now use the testPostMigrationChecks2 when executing
762+
// the migration.
763+
err = db2.ExecuteMigrations(db2.backupAndMigrate, WithPostStepCallbacks(
764+
makePostStepCallbacks(db2, testPostMigrationChecks2),
765+
))
766+
require.ErrorIs(t, err, testError)
767+
768+
// If we now attempt to execute migrations again, it should fail with an
769+
// error indicating that the db is in a dirty state.
770+
err = db2.ExecuteMigrations(db2.backupAndMigrate, WithPostStepCallbacks(
771+
makePostStepCallbacks(db2, testPostMigrationChecks2),
772+
))
773+
require.ErrorContains(t, err, "database is in a dirty state")
774+
}

0 commit comments

Comments
 (0)