Skip to content

Commit 8f1aa99

Browse files
session: add migration code from kvdb to SQL
This commit introduces the migration logic for transitioning the sessions store from kvdb to SQL. Note that as of this commit, the migration is not yet triggered by any production code, i.e. only tests execute the migration logic.
1 parent 6c4c6a4 commit 8f1aa99

File tree

2 files changed

+777
-0
lines changed

2 files changed

+777
-0
lines changed

session/sql_migration.go

Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
package session
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"database/sql"
7+
"errors"
8+
"fmt"
9+
"reflect"
10+
"time"
11+
12+
"github.com/davecgh/go-spew/spew"
13+
"github.com/lightninglabs/lightning-terminal/accounts"
14+
"github.com/lightninglabs/lightning-terminal/db/sqlc"
15+
"github.com/lightningnetwork/lnd/sqldb"
16+
"github.com/pmezard/go-difflib/difflib"
17+
"go.etcd.io/bbolt"
18+
)
19+
20+
var (
21+
// ErrMigrationMismatch is returned when the migrated session does not
22+
// match the original session.
23+
ErrMigrationMismatch = fmt.Errorf("migrated session does not match " +
24+
"original session")
25+
)
26+
27+
// MigrateSessionStoreToSQL runs the migration of all sessions from the KV
28+
// database to the SQL database. The migration is done in a single transaction
29+
// to ensure that all sessions are migrated or none at all.
30+
//
31+
// NOTE: As sessions may contain linked accounts, the accounts sql migration
32+
// MUST be run prior to this migration.
33+
func MigrateSessionStoreToSQL(ctx context.Context, kvStore *bbolt.DB,
34+
tx SQLQueries) error {
35+
36+
log.Infof("Starting migration of the KV sessions store to SQL")
37+
38+
kvSessions, err := getBBoltSessions(kvStore)
39+
if err != nil {
40+
return err
41+
}
42+
43+
// If sessions are linked to a group, we must insert the initial session
44+
// of each group before the other sessions in that group. This ensures
45+
// we can retrieve the SQL group ID when inserting the remaining
46+
// sessions. Therefore, we first insert all initial group sessions,
47+
// allowing us to fetch the group IDs and insert the rest of the
48+
// sessions afterward.
49+
// We therefore filter out the initial sessions first, and then migrate
50+
// them prior to the rest of the sessions.
51+
var (
52+
initialGroupSessions []*Session
53+
linkedSessions []*Session
54+
)
55+
56+
for _, kvSession := range kvSessions {
57+
if kvSession.GroupID == kvSession.ID {
58+
initialGroupSessions = append(
59+
initialGroupSessions, kvSession,
60+
)
61+
} else {
62+
linkedSessions = append(linkedSessions, kvSession)
63+
}
64+
}
65+
66+
err = migrateSessionsToSQLAndValidate(ctx, tx, initialGroupSessions)
67+
if err != nil {
68+
return fmt.Errorf("migration of non-linked session failed: %w",
69+
err)
70+
}
71+
72+
err = migrateSessionsToSQLAndValidate(ctx, tx, linkedSessions)
73+
if err != nil {
74+
return fmt.Errorf("migration of linked session failed: %w", err)
75+
}
76+
77+
total := len(initialGroupSessions) + len(linkedSessions)
78+
log.Infof("All sessions migrated from KV to SQL. Total number of "+
79+
"sessions migrated: %d", total)
80+
81+
return nil
82+
}
83+
84+
// getBBoltSessions is a helper function that fetches all sessions from the
85+
// Bbolt store, by iterating directly over the buckets, without needing to
86+
// use any public functions of the BoltStore struct.
87+
func getBBoltSessions(db *bbolt.DB) ([]*Session, error) {
88+
var sessions []*Session
89+
90+
err := db.View(func(tx *bbolt.Tx) error {
91+
sessionBucket, err := getBucket(tx, sessionBucketKey)
92+
if err != nil {
93+
return err
94+
}
95+
96+
return sessionBucket.ForEach(func(k, v []byte) error {
97+
// We'll also get buckets here, skip those (identified
98+
// by nil value).
99+
if v == nil {
100+
return nil
101+
}
102+
103+
session, err := DeserializeSession(bytes.NewReader(v))
104+
if err != nil {
105+
return err
106+
}
107+
108+
sessions = append(sessions, session)
109+
110+
return nil
111+
})
112+
})
113+
114+
return sessions, err
115+
}
116+
117+
// migrateSessionsToSQLAndValidate runs the migration for the passed sessions
118+
// from the KV database to the SQL database, and validates that the migrated
119+
// sessions match the original sessions.
120+
func migrateSessionsToSQLAndValidate(ctx context.Context,
121+
tx SQLQueries, kvSessions []*Session) error {
122+
123+
for _, kvSession := range kvSessions {
124+
err := migrateSingleSessionToSQL(ctx, tx, kvSession)
125+
if err != nil {
126+
return fmt.Errorf("unable to migrate session(%v): %w",
127+
kvSession.ID, err)
128+
}
129+
130+
// Validate that the session was correctly migrated and matches
131+
// the original session in the kv store.
132+
sqlSess, err := tx.GetSessionByAlias(ctx, kvSession.ID[:])
133+
if err != nil {
134+
if errors.Is(err, sql.ErrNoRows) {
135+
err = ErrSessionNotFound
136+
}
137+
return fmt.Errorf("unable to get migrated session "+
138+
"from sql store: %w", err)
139+
}
140+
141+
migratedSession, err := unmarshalSession(ctx, tx, sqlSess)
142+
if err != nil {
143+
return fmt.Errorf("unable to unmarshal migrated "+
144+
"session: %w", err)
145+
}
146+
147+
overrideSessionTimeZone(kvSession)
148+
overrideSessionTimeZone(migratedSession)
149+
overrideMacaroonRecipe(kvSession, migratedSession)
150+
151+
if !reflect.DeepEqual(kvSession, migratedSession) {
152+
diff := difflib.UnifiedDiff{
153+
A: difflib.SplitLines(
154+
spew.Sdump(kvSession),
155+
),
156+
B: difflib.SplitLines(
157+
spew.Sdump(migratedSession),
158+
),
159+
FromFile: "Expected",
160+
FromDate: "",
161+
ToFile: "Actual",
162+
ToDate: "",
163+
Context: 3,
164+
}
165+
diffText, _ := difflib.GetUnifiedDiffString(diff)
166+
167+
return fmt.Errorf("%w: %v.\n%v", ErrMigrationMismatch,
168+
kvSession.ID, diffText)
169+
}
170+
}
171+
172+
return nil
173+
}
174+
175+
// migrateSingleSessionToSQL runs the migration for a single session from the
176+
// KV database to the SQL database. Note that if the session links to an
177+
// account, the linked accounts store MUST have been migrated before that
178+
// session is migrated.
179+
func migrateSingleSessionToSQL(ctx context.Context,
180+
tx SQLQueries, session *Session) error {
181+
182+
var (
183+
acctID sql.NullInt64
184+
err error
185+
remotePubKey []byte
186+
)
187+
188+
session.AccountID.WhenSome(func(alias accounts.AccountID) {
189+
// Fetch the SQL ID for the account from the SQL store.
190+
var acctAlias int64
191+
acctAlias, err = alias.ToInt64()
192+
if err != nil {
193+
return
194+
}
195+
196+
var acctDBID int64
197+
acctDBID, err = tx.GetAccountIDByAlias(ctx, acctAlias)
198+
if errors.Is(err, sql.ErrNoRows) {
199+
err = accounts.ErrAccNotFound
200+
return
201+
} else if err != nil {
202+
return
203+
}
204+
205+
acctID = sqldb.SQLInt64(acctDBID)
206+
})
207+
if err != nil {
208+
return err
209+
}
210+
211+
if session.RemotePublicKey != nil {
212+
remotePubKey = session.RemotePublicKey.SerializeCompressed()
213+
}
214+
215+
// Proceed to insert the session into the sql db.
216+
sqlId, err := tx.InsertSession(ctx, sqlc.InsertSessionParams{
217+
Alias: session.ID[:],
218+
Label: session.Label,
219+
State: int16(session.State),
220+
Type: int16(session.Type),
221+
Expiry: session.Expiry.UTC(),
222+
CreatedAt: session.CreatedAt.UTC(),
223+
ServerAddress: session.ServerAddr,
224+
DevServer: session.DevServer,
225+
MacaroonRootKey: int64(session.MacaroonRootKey),
226+
PairingSecret: session.PairingSecret[:],
227+
LocalPrivateKey: session.LocalPrivateKey.Serialize(),
228+
LocalPublicKey: session.LocalPublicKey.SerializeCompressed(),
229+
RemotePublicKey: remotePubKey,
230+
Privacy: session.WithPrivacyMapper,
231+
AccountID: acctID,
232+
})
233+
if err != nil {
234+
return err
235+
}
236+
237+
// Since the InsertSession query doesn't support that we set the revoked
238+
// field during the insert, we need to set the field after the session
239+
// has been created.
240+
if !session.RevokedAt.IsZero() {
241+
err = tx.SetSessionRevokedAt(
242+
ctx, sqlc.SetSessionRevokedAtParams{
243+
ID: sqlId,
244+
RevokedAt: sqldb.SQLTime(
245+
session.RevokedAt.UTC(),
246+
),
247+
},
248+
)
249+
if err != nil {
250+
return err
251+
}
252+
}
253+
254+
// After the session has been inserted, we need to update the session
255+
// with the group ID if it is linked to a group. We need to do this
256+
// after the session has been inserted, because the group ID can be the
257+
// session itself, and therefore the SQL id for the session won't exist
258+
// prior to inserting the session.
259+
groupID, err := tx.GetSessionIDByAlias(ctx, session.GroupID[:])
260+
if errors.Is(err, sql.ErrNoRows) {
261+
return ErrUnknownGroup
262+
} else if err != nil {
263+
return fmt.Errorf("unable to fetch group(%x): %w",
264+
session.GroupID[:], err)
265+
}
266+
267+
// Now lets set the group ID for the session.
268+
err = tx.SetSessionGroupID(ctx, sqlc.SetSessionGroupIDParams{
269+
ID: sqlId,
270+
GroupID: sqldb.SQLInt64(groupID),
271+
})
272+
if err != nil {
273+
return fmt.Errorf("unable to set group Alias: %w", err)
274+
}
275+
276+
// Once we have the sqlID for the session, we can proceed to insert rows
277+
// into the linked child tables.
278+
if session.MacaroonRecipe != nil {
279+
// We start by inserting the macaroon permissions.
280+
for _, sessionPerm := range session.MacaroonRecipe.Permissions {
281+
err = tx.InsertSessionMacaroonPermission(
282+
ctx, sqlc.InsertSessionMacaroonPermissionParams{
283+
SessionID: sqlId,
284+
Entity: sessionPerm.Entity,
285+
Action: sessionPerm.Action,
286+
},
287+
)
288+
if err != nil {
289+
return err
290+
}
291+
}
292+
293+
// Next we insert the macaroon caveats.
294+
for _, caveat := range session.MacaroonRecipe.Caveats {
295+
err = tx.InsertSessionMacaroonCaveat(
296+
ctx, sqlc.InsertSessionMacaroonCaveatParams{
297+
SessionID: sqlId,
298+
CaveatID: caveat.Id,
299+
VerificationID: caveat.VerificationId,
300+
Location: sqldb.SQLStr(
301+
caveat.Location,
302+
),
303+
},
304+
)
305+
if err != nil {
306+
return err
307+
}
308+
}
309+
}
310+
311+
// That's followed by the feature config.
312+
if session.FeatureConfig != nil {
313+
for featureName, config := range *session.FeatureConfig {
314+
err = tx.InsertSessionFeatureConfig(
315+
ctx, sqlc.InsertSessionFeatureConfigParams{
316+
SessionID: sqlId,
317+
FeatureName: featureName,
318+
Config: config,
319+
},
320+
)
321+
if err != nil {
322+
return err
323+
}
324+
}
325+
}
326+
327+
// Finally we insert the privacy flags.
328+
for _, privacyFlag := range session.PrivacyFlags {
329+
err = tx.InsertSessionPrivacyFlag(
330+
ctx, sqlc.InsertSessionPrivacyFlagParams{
331+
SessionID: sqlId,
332+
Flag: int32(privacyFlag),
333+
},
334+
)
335+
if err != nil {
336+
return err
337+
}
338+
}
339+
340+
return nil
341+
}
342+
343+
// overrideSessionTimeZone overrides the time zone of the session to the local
344+
// time zone and chops off the nanosecond part for comparison. This is needed
345+
// because KV database stores times as-is which as an unwanted side effect would
346+
// fail migration due to time comparison expecting both the original and
347+
// migrated sessions to be in the same local time zone and in microsecond
348+
// precision. Note that PostgresSQL stores times in microsecond precision while
349+
// SQLite can store times in nanosecond precision if using TEXT storage class.
350+
func overrideSessionTimeZone(session *Session) {
351+
fixTime := func(t time.Time) time.Time {
352+
return t.In(time.Local).Truncate(time.Microsecond)
353+
}
354+
355+
if !session.Expiry.IsZero() {
356+
session.Expiry = fixTime(session.Expiry)
357+
}
358+
359+
if !session.CreatedAt.IsZero() {
360+
session.CreatedAt = fixTime(session.CreatedAt)
361+
}
362+
363+
if !session.RevokedAt.IsZero() {
364+
session.RevokedAt = fixTime(session.RevokedAt)
365+
}
366+
}
367+
368+
// overrideMacaroonRecipe overrides the MacaroonRecipe for the SQL session in a
369+
// certain scenario:
370+
// In the bbolt store, a session can have a non-nil macaroon struct, despite
371+
// both the permissions and caveats being nil. There is no way to represent this
372+
// in the SQL store, as the macaroon permissions and caveats are separate
373+
// tables. Therefore, in the scenario where a MacaroonRecipe exists for the
374+
// bbolt version, but both the permissions and caveats are nil, we override the
375+
// MacaroonRecipe for the SQL version and set it to a MacaroonRecipe with
376+
// nil permissions and caveats. This is needed to ensure that the deep equals
377+
// check in the migration validation does not fail in this scenario.
378+
// Additionally, if either the permissions or caveats aren't set, for the
379+
// MacaroonRecipe, that is represented as empty array in the SQL store, but
380+
// as nil in the bbolt store. Therefore, we also override the permissions
381+
// or caveats to nil for the migrated session in that scenario, so that the
382+
// deep equals check does not fail in this scenario either.
383+
func overrideMacaroonRecipe(kvSession *Session, migratedSession *Session) {
384+
if kvSession.MacaroonRecipe != nil {
385+
kvPerms := kvSession.MacaroonRecipe.Permissions
386+
kvCaveats := kvSession.MacaroonRecipe.Caveats
387+
388+
if kvPerms == nil && kvCaveats == nil {
389+
migratedSession.MacaroonRecipe = &MacaroonRecipe{}
390+
} else if kvPerms == nil {
391+
migratedSession.MacaroonRecipe.Permissions = nil
392+
} else if kvCaveats == nil {
393+
migratedSession.MacaroonRecipe.Caveats = nil
394+
}
395+
}
396+
}

0 commit comments

Comments
 (0)