Skip to content

[sql-49] account + session: Add determintistic sorting of maps during the KVDB to SQL migration. #1132

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 114 additions & 4 deletions accounts/sql_migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import (
"database/sql"
"errors"
"fmt"
"github.com/lightningnetwork/lnd/lntypes"
"math"
"reflect"
"sort"
"time"

"github.com/davecgh/go-spew/spew"
"github.com/lightninglabs/lightning-terminal/db/sqlc"
"github.com/lightningnetwork/lnd/kvdb"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/pmezard/go-difflib/difflib"
)

Expand All @@ -23,6 +26,110 @@ var (
"original account")
)

// deterministicPayment is a variant of a single account PaymentEntry, which
// can be inserted into an array to be compared deterministically.
type deterministicPayment struct {
paymentHash lntypes.Hash
paymentEntry *PaymentEntry
}

// deterministicAccount is a variant of the OffChainBalanceAccount struct
// without any struct methods, which represents the maps in the
// OffChainBalanceAccount as lists, so that they can be deterministically sorted
// for comparison during the kvdb to SQL migration.
type deterministicAccount struct {
// ID is the randomly generated account identifier.
ID AccountID

// Type is the account type.
Type AccountType

// InitialBalance stores the initial balance in millisatoshis and is
// never updated.
InitialBalance lnwire.MilliSatoshi

// CurrentBalance is the currently available balance of the account
// in millisatoshis that is updated every time an invoice is paid. This
// value can be negative (for example if the fees for a payment are
// larger than the estimate made when checking the balance and the
// account is close to zero value).
CurrentBalance int64

// LastUpdate keeps track of the last time the balance of the account
// was updated.
LastUpdate time.Time

// ExpirationDate is a specific date in the future after which the
// account is marked as expired. Can be set to zero for accounts that
// never expire.
ExpirationDate time.Time

// Invoices is a list of all invoices that are associated with the
// account.
Invoices []lntypes.Hash

// Payments is a list of all payments that are associated with the
// account and the last status we were aware of.
Payments []*deterministicPayment

// Label is an optional label that can be set for the account. If it is
// not empty then it must be unique.
Label string
}

// newDeterministicAccount creates a new deterministic account from the
// an OffChainBalanceAccount.
func newDeterministicAccount(
acct *OffChainBalanceAccount) *deterministicAccount {

invoices := make([]lntypes.Hash, len(acct.Invoices))
payments := make([]*deterministicPayment, len(acct.Payments))

// First let's populate the invoices and payments slices with the
// invoices and payments from the account.
i := 0
for hash := range acct.Invoices {
invoices[i] = hash
i++
}

i = 0
for hash, paymentEntry := range acct.Payments {
payments[i] = &deterministicPayment{
paymentHash: hash,
paymentEntry: paymentEntry,
}

i++
}
Comment on lines +90 to +104

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The loops for populating invoices and payments slices can be written more idiomatically in Go. Instead of manual index management, you could use append with slices initialized with a capacity. This would make the code more concise and less error-prone.

For example:

	invoices := make([]lntypes.Hash, 0, len(acct.Invoices))
	for hash := range acct.Invoices {
		invoices = append(invoices, hash)
	}

	payments := make([]*deterministicPayment, 0, len(acct.Payments))
	for hash, paymentEntry := range acct.Payments {
		payments = append(payments, &deterministicPayment{
			paymentHash:  hash,
			paymentEntry: paymentEntry,
		})
	}


// Next, let's sort the invoices and payments slices by their hashes to
// ensure deterministic ordering.
sort.Slice(invoices, func(i, j int) bool {
return bytes.Compare(
invoices[i][:], invoices[j][:],
) < 0
})

sort.Slice(payments, func(i, j int) bool {
return bytes.Compare(
payments[i].paymentHash[:], payments[j].paymentHash[:],
) < 0
})

return &deterministicAccount{
ID: acct.ID,
Type: acct.Type,
InitialBalance: acct.InitialBalance,
CurrentBalance: acct.CurrentBalance,
LastUpdate: acct.LastUpdate.UTC(),
Label: acct.Label,
Invoices: invoices,
Payments: payments,
ExpirationDate: acct.ExpirationDate.UTC(),
}
}

// MigrateAccountStoreToSQL runs the migration of all accounts and indices from
// the KV database to the SQL database. The migration is done in a single
// transaction to ensure that all accounts are migrated or none at all.
Expand Down Expand Up @@ -79,13 +186,16 @@ func migrateAccountsToSQL(ctx context.Context, kvStore kvdb.Backend,
overrideAccountTimeZone(kvAccount)
overrideAccountTimeZone(migratedAccount)

if !reflect.DeepEqual(kvAccount, migratedAccount) {
dKvAccount := newDeterministicAccount(kvAccount)
dMigratedAccount := newDeterministicAccount(migratedAccount)

if !reflect.DeepEqual(dKvAccount, dMigratedAccount) {
diff := difflib.UnifiedDiff{
A: difflib.SplitLines(
spew.Sdump(kvAccount),
spew.Sdump(dKvAccount),
),
B: difflib.SplitLines(
spew.Sdump(migratedAccount),
spew.Sdump(dMigratedAccount),
),
FromFile: "Expected",
FromDate: "",
Expand All @@ -96,7 +206,7 @@ func migrateAccountsToSQL(ctx context.Context, kvStore kvdb.Backend,
diffText, _ := difflib.GetUnifiedDiffString(diff)

return fmt.Errorf("%w: %v.\n%v", ErrMigrationMismatch,
kvAccount.ID, diffText)
dKvAccount.ID, diffText)
}
}

Expand Down
166 changes: 162 additions & 4 deletions session/sql_migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import (
"errors"
"fmt"
"reflect"
"sort"
"time"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/davecgh/go-spew/spew"
"github.com/lightninglabs/lightning-node-connect/mailbox"
"github.com/lightninglabs/lightning-terminal/accounts"
"github.com/lightninglabs/lightning-terminal/db/sqlc"
"github.com/lightningnetwork/lnd/fn"
"github.com/lightningnetwork/lnd/sqldb"
"github.com/pmezard/go-difflib/difflib"
"go.etcd.io/bbolt"
Expand All @@ -24,6 +28,105 @@ var (
"original session")
)

// featureConfigEntry is a variant of a single session feature config, which
// can be inserted into an array to be compared deterministically.
type featureConfigEntry struct {
featureName string
config []byte
}

// deterministicSession is a variant of the Session struct without any struct
// methods, which represents the map in the Session as a list, so that it can be
// deterministically sorted for comparison during the kvdb to SQL migration.
type deterministicSession struct {
ID ID
Label string
State State
Type Type
Expiry time.Time
CreatedAt time.Time
RevokedAt time.Time
ServerAddr string
DevServer bool
MacaroonRootKey uint64
MacaroonRecipe *MacaroonRecipe
PairingSecret [mailbox.NumPassphraseEntropyBytes]byte
LocalPrivateKey *btcec.PrivateKey
LocalPublicKey *btcec.PublicKey
RemotePublicKey *btcec.PublicKey
FeatureConfig []*featureConfigEntry
WithPrivacyMapper bool
PrivacyFlags PrivacyFlags

// GroupID is the Session ID of the very first Session in the linked
// group of sessions. If this is the very first session in the group
// then this will be the same as ID.
GroupID ID

// AccountID is an optional account that the session has been linked to.
AccountID fn.Option[accounts.AccountID]
}

// newDeterministicSession creates a deterministicSession from a Session struct.
// This is used to compare the session in a deterministic way during the
// migration from the KV database to the SQL database.
func newDeterministicSession(sess *Session) *deterministicSession {
var featuresConfig []*featureConfigEntry

// If a session has a feature config set, we'll convert it to an array
// so that we can sort it and compare it deterministically.
if sess.FeatureConfig != nil {
sessFC := *sess.FeatureConfig
featuresConfig = make([]*featureConfigEntry, len(sessFC))

i := 0
for featureName, config := range sessFC {
featuresConfig[i] = &featureConfigEntry{
featureName: featureName,
config: config,
}

i++
}

// Sort the feature config entries by their feature name, and
// by their config bytes if the feature names are the same.
sort.Slice(featuresConfig, func(i, j int) bool {
iC := featuresConfig[i]
jC := featuresConfig[j]

if iC.featureName == jC.featureName {
return bytes.Compare(iC.config, jC.config) < 0
}

return iC.featureName < jC.featureName
})
}

return &deterministicSession{
ID: sess.ID,
Label: sess.Label,
State: sess.State,
Type: sess.Type,
Expiry: sess.Expiry,
CreatedAt: sess.CreatedAt,
RevokedAt: sess.RevokedAt,
ServerAddr: sess.ServerAddr,
DevServer: sess.DevServer,
MacaroonRootKey: sess.MacaroonRootKey,
PairingSecret: sess.PairingSecret,
LocalPrivateKey: sess.LocalPrivateKey,
LocalPublicKey: sess.LocalPublicKey,
RemotePublicKey: sess.RemotePublicKey,
GroupID: sess.GroupID,
AccountID: sess.AccountID,
PrivacyFlags: sess.PrivacyFlags,
MacaroonRecipe: sess.MacaroonRecipe,
WithPrivacyMapper: sess.WithPrivacyMapper,
FeatureConfig: featuresConfig,
}
}

// MigrateSessionStoreToSQL runs the migration of all sessions from the KV
// database to the SQL database. The migration is done in a single transaction
// to ensure that all sessions are migrated or none at all.
Expand Down Expand Up @@ -148,13 +251,16 @@ func migrateSessionsToSQLAndValidate(ctx context.Context,
overrideSessionTimeZone(migratedSession)
overrideMacaroonRecipe(kvSession, migratedSession)

if !reflect.DeepEqual(kvSession, migratedSession) {
dKvSession := newDeterministicSession(kvSession)
dMigratedSession := newDeterministicSession(migratedSession)

if !reflect.DeepEqual(dKvSession, dMigratedSession) {
diff := difflib.UnifiedDiff{
A: difflib.SplitLines(
spew.Sdump(kvSession),
spew.Sdump(dKvSession),
),
B: difflib.SplitLines(
spew.Sdump(migratedSession),
spew.Sdump(dMigratedSession),
),
FromFile: "Expected",
FromDate: "",
Expand All @@ -165,7 +271,7 @@ func migrateSessionsToSQLAndValidate(ctx context.Context,
diffText, _ := difflib.GetUnifiedDiffString(diff)

return fmt.Errorf("%w: %v.\n%v", ErrMigrationMismatch,
kvSession.ID, diffText)
dKvSession.ID, diffText)
}
}

Expand Down Expand Up @@ -380,17 +486,69 @@ func overrideSessionTimeZone(session *Session) {
// as nil in the bbolt store. Therefore, we also override the permissions
// or caveats to nil for the migrated session in that scenario, so that the
// deep equals check does not fail in this scenario either.
//
// Additionally, we sort the caveats & permissions of both the kv and sql
// sessions by their ID, so that they are always comparable in a deterministic
// way with deep equals.
func overrideMacaroonRecipe(kvSession *Session, migratedSession *Session) {
if kvSession.MacaroonRecipe != nil {
kvPerms := kvSession.MacaroonRecipe.Permissions
kvCaveats := kvSession.MacaroonRecipe.Caveats

// If the kvSession has a MacaroonRecipe with nil set for any
// of the fields, we need to override the migratedSession
// MacaroonRecipe to match that.
if kvPerms == nil && kvCaveats == nil {
migratedSession.MacaroonRecipe = &MacaroonRecipe{}
} else if kvPerms == nil {
migratedSession.MacaroonRecipe.Permissions = nil
} else if kvCaveats == nil {
migratedSession.MacaroonRecipe.Caveats = nil
}

sqlCaveats := migratedSession.MacaroonRecipe.Caveats
sqlPerms := migratedSession.MacaroonRecipe.Permissions

// If there have been caveats set for the MacaroonRecipe,
// the order of the postgres db caveats will in very rare cases
// differ from the kv store caveats. Therefore, we sort
// both the kv and sql caveats by their ID, so that we can
// compare them in a deterministic way.
if kvCaveats != nil {
sort.Slice(kvCaveats, func(i, j int) bool {
return bytes.Compare(
kvCaveats[i].Id, kvCaveats[j].Id,
) < 0
})

sort.Slice(sqlCaveats, func(i, j int) bool {
return bytes.Compare(
sqlCaveats[i].Id, sqlCaveats[j].Id,
) < 0
})
}

// Similarly, we sort the macaroon permissions for both the kv
// and sql sessions, so that we can compare them in a
// deterministic way.
if kvPerms != nil {
sort.Slice(kvPerms, func(i, j int) bool {
if kvPerms[i].Entity == kvPerms[j].Entity {
return kvPerms[i].Action <
kvPerms[j].Action
}

return kvPerms[i].Entity < kvPerms[j].Entity
})

sort.Slice(sqlPerms, func(i, j int) bool {
if sqlPerms[i].Entity == sqlPerms[j].Entity {
return sqlPerms[i].Action <
sqlPerms[j].Action
}

return sqlPerms[i].Entity < sqlPerms[j].Entity
})
}
Comment on lines +534 to +552

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The sorting logic for macaroon permissions is duplicated for kvPerms and sqlPerms. You can extract this logic into a helper function to avoid repetition and improve readability. The same applies to sorting caveats (lines 497-509).

}
}
Loading