Skip to content

Commit 2bc7e7f

Browse files
Merge pull request #1051 from ViktorTigerstrom/2025-04-migrate-sessions
[sql-33] session: add migration code from kvdb to SQL
2 parents d593098 + 2007d53 commit 2bc7e7f

File tree

6 files changed

+1199
-15
lines changed

6 files changed

+1199
-15
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)