multi: deprecate kvdb stores after SQL migration

Mark the legacy kvdb stores as deprecated once the kvdb -> SQL
migration commits successfully. This prevents normal bbolt startup
from reopening accounts.db, session.db, or rules.db after their data
has already been migrated.

Add explicit deprecation checks to the three kvdb store open paths and
provide migration-only constructors that can still reopen deprecated
files when the SQL database is deleted or downgraded and the migration
must be rerun.

Use store-specific tombstones for the deprecation markers and add
tests that verify deprecated stores are rejected while migration
reruns continue to work.
This commit is contained in:
Viktor Torstensson 2026-04-02 00:53:48 +02:00
parent c16ff48da6
commit 000d28a4fb
No known key found for this signature in database
GPG key ID: 961CC8259AE675D4
11 changed files with 497 additions and 8 deletions

View file

@ -12,6 +12,7 @@ import (
"github.com/davecgh/go-spew/spew"
"github.com/lightninglabs/lightning-terminal/db/sqlcmig6"
"github.com/lightninglabs/lightning-terminal/db/tombstone"
"github.com/lightningnetwork/lnd/kvdb"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntypes"
@ -127,6 +128,15 @@ func getBBoltAccounts(db kvdb.Backend) ([]*OffChainBalanceAccount, error) {
return nil
}
// Also skip the kvdb deprecation marker key. We
// still want to allow rerunning the kvdb -> SQL
// migration after the SQL database has been deleted or
// downgraded, even though normal bbolt startup should
// reject the tombstoned kvdb files.
if tombstone.IsMigrationTombstoneKey(k) {
return nil
}
// There should be no sub-buckets.
if v == nil {
return fmt.Errorf("invalid bucket structure")

View file

@ -8,9 +8,11 @@ import (
"fmt"
"math"
"os"
"path/filepath"
"time"
"github.com/btcsuite/btcwallet/walletdb"
"github.com/lightninglabs/lightning-terminal/db/tombstone"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/fn"
"github.com/lightningnetwork/lnd/kvdb"
@ -66,6 +68,30 @@ type BoltStore struct {
// NewBoltStore creates a BoltStore instance and the corresponding bucket in the
// bolt DB if it does not exist yet.
func NewBoltStore(dir, fileName string, clock clock.Clock) (*BoltStore, error) {
return newBoltStore(dir, fileName, clock, false)
}
// NewBoltStoreForMigration opens the accounts kvdb store even if it was
// already marked as deprecated. This is only intended for rerunning the kvdb
// to SQL migration after the SQL database was removed or downgraded.
func NewBoltStoreForMigration(dir, fileName string,
clock clock.Clock) (*BoltStore, error) {
return newBoltStore(dir, fileName, clock, true)
}
// DeprecateKVDB marks the accounts kvdb file in the given db directory as
// deprecated after a successful SQL migration.
func DeprecateKVDB(dbDir string) error {
return tombstone.DeprecateKVDB(
filepath.Join(dbDir, DBFilename), DefaultAccountDBTimeout,
accountBucketName,
)
}
func newBoltStore(dir, fileName string, clock clock.Clock,
allowDeprecated bool) (*BoltStore, error) {
// Ensure that the path to the directory exists.
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, dbPathPermission); err != nil {
@ -73,6 +99,17 @@ func NewBoltStore(dir, fileName string, clock clock.Clock) (*BoltStore, error) {
}
}
if !allowDeprecated {
dbPath := filepath.Join(dir, fileName)
err := tombstone.CheckKVDBDeprecated(
dbPath, accountBucketName, DefaultAccountDBTimeout,
)
if err != nil {
return nil, err
}
}
// Open the database that we'll use to store the primary macaroon key,
// and all generated macaroons+caveats.
db, err := kvdb.GetBoltBackend(&kvdb.BoltBackendConfig{

View file

@ -0,0 +1,50 @@
package accounts
import (
"os"
"path/filepath"
"testing"
"github.com/lightninglabs/lightning-terminal/db/tombstone"
"github.com/lightningnetwork/lnd/clock"
"github.com/stretchr/testify/require"
)
// TestKVDBDeprecation verifies that a deprecated accounts kvdb file refuses to
// reopen.
func TestKVDBDeprecation(t *testing.T) {
t.Parallel()
dbDir := t.TempDir()
clk := clock.NewDefaultClock()
store, err := NewBoltStore(dbDir, DBFilename, clk)
require.NoError(t, err)
require.NoError(t, store.Close())
err = DeprecateKVDB(dbDir)
require.NoError(t, err)
_, err = NewBoltStore(dbDir, DBFilename, clk)
require.Error(t, err)
require.ErrorIs(t, err, tombstone.ErrKVDBDeprecated)
store, err = NewBoltStoreForMigration(dbDir, DBFilename, clk)
require.NoError(t, err)
require.NoError(t, store.Close())
}
// TestDeprecateKVDBMissingFile verifies that deprecating a missing accounts
// kvdb file is a no-op.
func TestDeprecateKVDBMissingFile(t *testing.T) {
t.Parallel()
dbDir := t.TempDir()
dbPath := filepath.Join(dbDir, DBFilename)
err := DeprecateKVDB(dbDir)
require.NoError(t, err)
_, err = os.Stat(dbPath)
require.ErrorIs(t, err, os.ErrNotExist)
}

View file

@ -6,6 +6,7 @@ import (
"context"
"database/sql"
"encoding/binary"
"errors"
"fmt"
"path/filepath"
"time"
@ -42,7 +43,7 @@ func MakePostStepCallbacksMig6(ctx context.Context,
// we use migrate.NewWithInstance() to create the migration
// instance from our already instantiated database backend that
// is also passed into this function.
return mig6executor.ExecTx(
err := mig6executor.ExecTx(
ctx, sqldb.WriteTxOpt(),
func(q6 *sqlcmig6.Queries) error {
log.Infof("Running post migration callback "+
@ -54,6 +55,29 @@ func MakePostStepCallbacksMig6(ctx context.Context,
)
}, sqldb.NoOpReset,
)
if err != nil {
return err
}
// Now deprecate the kvdb database files. Note that if the
// deprecation function errors, we do not return the error.
//
// At this point the kvdb -> SQL data migration is already
// committed successfully. Returning an error here would only
// cause the programmatic migration to rerun on the next startup
// and reprocess data that is already present in SQL.
//
// We still want the failure to be highly visible because the
// legacy bbolt files were not tombstoned and may therefore
// still be opened unexpectedly.
err = deprecateKVDBStores(filepath.Dir(macPath))
if err != nil {
log.Errorf("CRITICAL: kvdb -> SQL migration "+
"succeeded, but the legacy bbolt databases "+
"were not marked deprecated: %v", err)
}
return nil
}
return migrate.ProgrammaticMigrEntry{
@ -71,7 +95,7 @@ func kvdbToSqlMigrationCallback(ctx context.Context,
start := time.Now()
log.Infof("Starting KVDB to SQL migration for all stores")
accountStore, err := accounts.NewBoltStore(
accountStore, err := accounts.NewBoltStoreForMigration(
filepath.Dir(macPath), accounts.DBFilename, clock,
)
if err != nil {
@ -92,7 +116,7 @@ func kvdbToSqlMigrationCallback(ctx context.Context,
"SQL: %w", err)
}
sessionStore, err := session.NewDB(
sessionStore, err := session.NewDBForMigration(
filepath.Dir(macPath), session.DBFilename,
clock, accountStore,
)
@ -114,7 +138,7 @@ func kvdbToSqlMigrationCallback(ctx context.Context,
"SQL: %w", err)
}
firewallStore, err := firewalldb.NewBoltDB(
firewallStore, err := firewalldb.NewBoltDBForMigration(
filepath.Dir(macPath), firewalldb.DBFilename,
sessionStore, accountStore, clock,
)
@ -193,3 +217,29 @@ func kvdbToSqlMigrationCallback(ctx context.Context,
return nil
}
// deprecateKVDBStores marks the old kvdb stores as deprecated after the SQL
// migration committed successfully. We do this after the SQL transaction is
// committed so a failed SQL migration cannot strand the user with an unusable
// kvdb backend.
func deprecateKVDBStores(dbDir string) error {
accountsErr := accounts.DeprecateKVDB(dbDir)
if accountsErr != nil {
accountsErr = fmt.Errorf("error deprecating accounts kvdb: %w",
accountsErr)
}
sessionErr := session.DeprecateKVDB(dbDir)
if sessionErr != nil {
sessionErr = fmt.Errorf("error deprecating session kvdb: %w",
sessionErr)
}
firewallErr := firewalldb.DeprecateKVDB(dbDir)
if firewallErr != nil {
firewallErr = fmt.Errorf("error deprecating firewall kvdb: %w",
firewallErr)
}
return errors.Join(accountsErr, sessionErr, firewallErr)
}

130
db/tombstone/tombstone.go Normal file
View file

@ -0,0 +1,130 @@
package tombstone
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"go.etcd.io/bbolt"
)
var (
// MigrationTombstoneKey marks a legacy kvdb bucket as permanently
// closed after its contents were migrated to SQL.
MigrationTombstoneKey = []byte("data-migration-tombstone")
// migrationTombstoneValue is the static value written for a tombstone
// marker.
migrationTombstoneValue = []byte("1")
// ErrKVDBDeprecated signals that the legacy kvdb database was already
// migrated to SQL and should not be opened again for normal use.
ErrKVDBDeprecated = errors.New("kvdb database has been migrated to " +
"SQL and can no longer be used")
)
const (
// dbFilePermission is the default permission the legacy bbolt database
// file is created with if bbolt ends up creating it during open. The
// deprecation helper guards against that by checking for file existence
// first, so this mode mainly documents the intended permission.
dbFilePermission = 0600
)
// DeprecateKVDB marks the given legacy bbolt database as deprecated by
// writing the migration tombstone marker into the specified top-level bucket.
func DeprecateKVDB(path string, timeout time.Duration, bucketKey []byte) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil
}
db, err := bbolt.Open(path, dbFilePermission, &bbolt.Options{
Timeout: timeout,
})
if err != nil {
return err
}
defer db.Close()
return setBoltBucketTombstone(db, bucketKey)
}
// CheckKVDBDeprecated returns a clear error if the legacy bbolt database at
// the given path was marked as deprecated. Missing files are treated as not
// deprecated so callers can continue with first-time initialization.
func CheckKVDBDeprecated(path string, bucketKey []byte,
timeout time.Duration) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil
}
db, err := bbolt.Open(path, dbFilePermission, &bbolt.Options{
Timeout: timeout,
})
if err != nil {
return err
}
defer db.Close()
deprecated, err := isBoltBucketTombstoned(db, bucketKey)
if err != nil {
return err
}
if deprecated {
return fmt.Errorf("%w: %s", ErrKVDBDeprecated,
filepath.Base(path))
}
return nil
}
// IsMigrationTombstoneKey returns true if the given key is the kvdb migration
// tombstone marker.
func IsMigrationTombstoneKey(key []byte) bool {
return bytes.Equal(key, MigrationTombstoneKey)
}
// setBoltBucketTombstone writes the migration tombstone key into the given
// top-level bucket of a bbolt backend.
func setBoltBucketTombstone(db *bbolt.DB, bucketKey []byte) error {
return db.Update(func(tx *bbolt.Tx) error {
bucket, err := tx.CreateBucketIfNotExists(bucketKey)
if err != nil {
return err
}
return bucket.Put(
MigrationTombstoneKey, migrationTombstoneValue,
)
})
}
// isBoltBucketTombstoned reports whether the given top-level bucket of a
// bbolt backend contains the migration tombstone marker.
func isBoltBucketTombstoned(db *bbolt.DB, bucketKey []byte) (bool, error) {
var tombstoneExists bool
err := db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bucketKey)
if bucket == nil {
return nil
}
tombstone := bucket.Get(MigrationTombstoneKey)
tombstoneExists = tombstone != nil
return nil
})
if err != nil {
return false, err
}
return tombstoneExists, nil
}

View file

@ -8,6 +8,7 @@ import (
"path/filepath"
"time"
"github.com/lightninglabs/lightning-terminal/db/tombstone"
"github.com/lightningnetwork/lnd/clock"
"go.etcd.io/bbolt"
)
@ -49,6 +50,35 @@ type BoltDB struct {
func NewBoltDB(dir, fileName string, sessionIDIndex SessionDB,
accountsDB AccountsDB, clock clock.Clock) (*BoltDB, error) {
return newBoltDB(
dir, fileName, sessionIDIndex, accountsDB, clock, false,
)
}
// NewBoltDBForMigration opens the rules kvdb store even if it was already
// marked as deprecated. This is only intended for rerunning the kvdb to SQL
// migration after the SQL database was removed or downgraded.
func NewBoltDBForMigration(dir, fileName string, sessionIDIndex SessionDB,
accountsDB AccountsDB, clock clock.Clock) (*BoltDB, error) {
return newBoltDB(
dir, fileName, sessionIDIndex, accountsDB, clock, true,
)
}
// DeprecateKVDB marks the rules kvdb file in the given db directory as
// deprecated after a successful SQL migration.
func DeprecateKVDB(dbDir string) error {
return tombstone.DeprecateKVDB(
filepath.Join(dbDir, DBFilename), DefaultRulesDBTimeout,
rulesBucketKey,
)
}
func newBoltDB(dir, fileName string, sessionIDIndex SessionDB,
accountsDB AccountsDB, clock clock.Clock,
allowDeprecated bool) (*BoltDB, error) {
firstInit := false
path := filepath.Join(dir, fileName)
@ -60,6 +90,15 @@ func NewBoltDB(dir, fileName string, sessionIDIndex SessionDB,
firstInit = true
}
if !allowDeprecated {
err := tombstone.CheckKVDBDeprecated(
path, rulesBucketKey, DefaultRulesDBTimeout,
)
if err != nil {
return nil, err
}
}
db, err := initDB(path, firstInit)
if err != nil {
return nil, err
@ -67,8 +106,10 @@ func NewBoltDB(dir, fileName string, sessionIDIndex SessionDB,
// Attempt to sync the database's current version with the latest known
// version available.
if err := syncVersions(db); err != nil {
return nil, err
if !allowDeprecated {
if err := syncVersions(db); err != nil {
return nil, err
}
}
return &BoltDB{

View file

@ -0,0 +1,68 @@
package firewalldb
import (
"os"
"path/filepath"
"testing"
"github.com/lightninglabs/lightning-terminal/accounts"
"github.com/lightninglabs/lightning-terminal/db/tombstone"
"github.com/lightninglabs/lightning-terminal/session"
"github.com/lightningnetwork/lnd/clock"
"github.com/stretchr/testify/require"
)
// TestKVDBDeprecation verifies that a deprecated rules kvdb file refuses to
// reopen.
func TestKVDBDeprecation(t *testing.T) {
t.Parallel()
dbDir := t.TempDir()
clk := clock.NewDefaultClock()
accountStore, err := accounts.NewBoltStore(
dbDir, accounts.DBFilename, clk,
)
require.NoError(t, err)
defer accountStore.Close()
sessionStore, err := session.NewDB(
dbDir, session.DBFilename, clk, accountStore,
)
require.NoError(t, err)
defer sessionStore.Close()
store, err := NewBoltDB(
dbDir, DBFilename, sessionStore, accountStore, clk,
)
require.NoError(t, err)
require.NoError(t, store.Close())
err = DeprecateKVDB(dbDir)
require.NoError(t, err)
_, err = NewBoltDB(dbDir, DBFilename, sessionStore, accountStore, clk)
require.Error(t, err)
require.ErrorIs(t, err, tombstone.ErrKVDBDeprecated)
store, err = NewBoltDBForMigration(
dbDir, DBFilename, sessionStore, accountStore, clk,
)
require.NoError(t, err)
require.NoError(t, store.Close())
}
// TestDeprecateKVDBMissingFile verifies that deprecating a missing rules kvdb
// file is a no-op.
func TestDeprecateKVDBMissingFile(t *testing.T) {
t.Parallel()
dbDir := t.TempDir()
dbPath := filepath.Join(dbDir, DBFilename)
err := DeprecateKVDB(dbDir)
require.NoError(t, err)
_, err = os.Stat(dbPath)
require.ErrorIs(t, err, os.ErrNotExist)
}

View file

@ -14,6 +14,7 @@ import (
"github.com/davecgh/go-spew/spew"
"github.com/lightninglabs/lightning-terminal/accounts"
"github.com/lightninglabs/lightning-terminal/db/sqlcmig6"
"github.com/lightninglabs/lightning-terminal/db/tombstone"
"github.com/lightninglabs/lightning-terminal/session"
"github.com/lightningnetwork/lnd/fn"
"github.com/lightningnetwork/lnd/sqldb/v2"
@ -216,6 +217,10 @@ func collectAllPairs(sessMap map[[4]byte]sqlcmig6.Session,
// Loop over each rule-name bucket.
err = mainBucket.ForEach(func(rule, v []byte) error {
if tombstone.IsMigrationTombstoneKey(rule) {
return nil
}
if v != nil {
return errors.New("expected only " +
"buckets under main bucket")

View file

@ -13,6 +13,7 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/lightninglabs/lightning-terminal/accounts"
"github.com/lightninglabs/lightning-terminal/db/tombstone"
"github.com/lightningnetwork/lnd/clock"
"go.etcd.io/bbolt"
)
@ -96,6 +97,30 @@ var _ Store = (*BoltStore)(nil)
func NewDB(dir, fileName string, clock clock.Clock,
store accounts.Store) (*BoltStore, error) {
return newDB(dir, fileName, clock, store, false)
}
// NewDBForMigration opens the session kvdb store even if it was already marked
// as deprecated. This is only intended for rerunning the kvdb to SQL migration
// after the SQL database was removed or downgraded.
func NewDBForMigration(dir, fileName string, clock clock.Clock,
store accounts.Store) (*BoltStore, error) {
return newDB(dir, fileName, clock, store, true)
}
// DeprecateKVDB marks the session kvdb file in the given db directory as
// deprecated after a successful SQL migration.
func DeprecateKVDB(dbDir string) error {
return tombstone.DeprecateKVDB(
filepath.Join(dbDir, DBFilename), DefaultSessionDBTimeout,
sessionBucketKey,
)
}
func newDB(dir, fileName string, clock clock.Clock, store accounts.Store,
allowDeprecated bool) (*BoltStore, error) {
firstInit := false
path := filepath.Join(dir, fileName)
@ -107,6 +132,15 @@ func NewDB(dir, fileName string, clock clock.Clock,
firstInit = true
}
if !allowDeprecated {
err := tombstone.CheckKVDBDeprecated(
path, sessionBucketKey, DefaultSessionDBTimeout,
)
if err != nil {
return nil, err
}
}
db, err := initDB(path, firstInit)
if err != nil {
return nil, err
@ -114,8 +148,10 @@ func NewDB(dir, fileName string, clock clock.Clock,
// Attempt to sync the database's current version with the latest known
// version available.
if err := syncVersions(db); err != nil {
return nil, err
if !allowDeprecated {
if err := syncVersions(db); err != nil {
return nil, err
}
}
return &BoltStore{

View file

@ -0,0 +1,57 @@
package session
import (
"os"
"path/filepath"
"testing"
"github.com/lightninglabs/lightning-terminal/accounts"
"github.com/lightninglabs/lightning-terminal/db/tombstone"
"github.com/lightningnetwork/lnd/clock"
"github.com/stretchr/testify/require"
)
// TestKVDBDeprecation verifies that a deprecated session kvdb file refuses to
// reopen.
func TestKVDBDeprecation(t *testing.T) {
t.Parallel()
dbDir := t.TempDir()
clk := clock.NewDefaultClock()
accountStore, err := accounts.NewBoltStore(
dbDir, accounts.DBFilename, clk,
)
require.NoError(t, err)
defer accountStore.Close()
store, err := NewDB(dbDir, DBFilename, clk, accountStore)
require.NoError(t, err)
require.NoError(t, store.Close())
err = DeprecateKVDB(dbDir)
require.NoError(t, err)
_, err = NewDB(dbDir, DBFilename, clk, accountStore)
require.Error(t, err)
require.ErrorIs(t, err, tombstone.ErrKVDBDeprecated)
store, err = NewDBForMigration(dbDir, DBFilename, clk, accountStore)
require.NoError(t, err)
require.NoError(t, store.Close())
}
// TestDeprecateKVDBMissingFile verifies that deprecating a missing session
// kvdb file is a no-op.
func TestDeprecateKVDBMissingFile(t *testing.T) {
t.Parallel()
dbDir := t.TempDir()
dbPath := filepath.Join(dbDir, DBFilename)
err := DeprecateKVDB(dbDir)
require.NoError(t, err)
_, err = os.Stat(dbPath)
require.ErrorIs(t, err, os.ErrNotExist)
}

View file

@ -15,6 +15,7 @@ import (
"github.com/lightninglabs/lightning-node-connect/mailbox"
"github.com/lightninglabs/lightning-terminal/accounts"
s6 "github.com/lightninglabs/lightning-terminal/db/sqlcmig6"
"github.com/lightninglabs/lightning-terminal/db/tombstone"
"github.com/lightningnetwork/lnd/fn"
"github.com/lightningnetwork/lnd/sqldb/v2"
"github.com/pmezard/go-difflib/difflib"
@ -154,6 +155,10 @@ func getBBoltSessions(db *bbolt.DB) ([]*Session, error) {
return nil
}
if tombstone.IsMigrationTombstoneKey(k) {
return nil
}
session, err := DeserializeSession(bytes.NewReader(v))
if err != nil {
return err