Skip to content

Commit 30538e0

Browse files
authored
UserSession migration to SQL (dual use in Postgres and Datastore) (dart-lang#9482)
1 parent 7f2d4a0 commit 30538e0

14 files changed

Lines changed: 1420 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ AppEngine version, listed here to ease deployment and troubleshooting.
66
* Upgraded stable Flutter analysis SDK to `3.44.5`.
77
* Upgraded dependencies (including: `sanitize_html`, `typed_sql`).
88
* Upgraded dartdoc to `9.0.8`.
9+
* Started to use `UserSession` rows in Postgresql (dual presence in Datastore).
910

1011
## `20260702t101200-all`
1112
* Bump runtimeVersion to `2026.07.02`.

app/lib/account/backend.dart

Lines changed: 185 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ import 'package:meta/meta.dart';
1313
// ignore: import_of_legacy_library_into_null_safe
1414
import 'package:neat_cache/neat_cache.dart';
1515
import 'package:pub_dev/admin/models.dart';
16+
import 'package:typed_sql/typed_sql.dart' hide AuthenticationException;
1617

1718
import '../audit/models.dart';
19+
import '../database/database.dart';
20+
import '../database/schema.dart';
1821
import '../frontend/request_context.dart';
1922
import '../service/openid/gcp_openid.dart';
2023
import '../service/openid/github_openid.dart';
@@ -440,14 +443,18 @@ class AccountBackend {
440443
tx.insert(session);
441444
return session;
442445
});
443-
if (rs != null) return rs;
446+
if (rs != null) {
447+
await writeUserSessionToSql(rs);
448+
return rs;
449+
}
444450
}
445451

446452
// in the absence of a valid existing session, create a new one
447453
final session = UserSession.init()
448454
..created = now
449455
..expires = now.add(_sessionDuration);
450456
await _db.commit(inserts: [session]);
457+
await writeUserSessionToSql(session);
451458
return session;
452459
}
453460

@@ -466,7 +473,7 @@ class AccountBackend {
466473
if (user == null || user.isModerated || user.isDeleted) {
467474
throw AuthenticationException.failed();
468475
}
469-
final data = await withRetryTransaction(_db, (tx) async {
476+
final result = await withRetryTransaction(_db, (tx) async {
470477
final session = await tx.userSessions.lookupOrNull(sessionId);
471478
if (session == null || session.isExpired()) {
472479
throw AuthenticationException.failed('Session has been expired.');
@@ -491,7 +498,7 @@ class AccountBackend {
491498
..authenticatedAt = now
492499
..expires = now.add(_sessionDuration);
493500
tx.insert(newSession);
494-
return SessionData.fromModel(newSession);
501+
return (session: newSession, expiredSessionId: sessionId);
495502
} else {
496503
// only update the current one
497504
session
@@ -504,9 +511,16 @@ class AccountBackend {
504511
..authenticatedAt = now
505512
..expires = now.add(_sessionDuration);
506513
tx.insert(session);
507-
return SessionData.fromModel(session);
514+
return (session: session, expiredSessionId: null);
508515
}
509516
});
517+
518+
if (result.expiredSessionId != null) {
519+
await _deleteUserSessionFromSql(result.expiredSessionId!);
520+
}
521+
await writeUserSessionToSql(result.session);
522+
523+
final data = SessionData.fromModel(result.session);
510524
await cache.userSessionData(data.sessionId).set(data);
511525
return data;
512526
}
@@ -576,7 +590,12 @@ class AccountBackend {
576590
/// Deletes the session entry if it has already expired and
577591
/// clears the related cache too.
578592
Future<UserSession?> lookupValidUserSession(String sessionId) async {
579-
final session = await _db.userSessions.lookupOrNull(sessionId);
593+
// Read from SQL first, falling back to Datastore if the session is not present.
594+
var session = await _lookupUserSessionFromSql(sessionId);
595+
if (session == null) {
596+
final key = _db.emptyKey.append(UserSession, id: sessionId);
597+
session = await _db.lookupOrNull<UserSession>(key);
598+
}
580599
if (session == null) {
581600
return null;
582601
}
@@ -587,6 +606,121 @@ class AccountBackend {
587606
return session;
588607
}
589608

609+
Future<UserSession?> _lookupUserSessionFromSql(String sessionId) async {
610+
try {
611+
final row = await primaryDatabase.withRetry(
612+
(db) => db.userSessions.byKey(sessionId).fetch(),
613+
);
614+
if (row == null) return null;
615+
return _userSessionFromRow(_db, row);
616+
} catch (e, st) {
617+
_logger.warning('SQL user session lookup failed: $sessionId', e, st);
618+
return null;
619+
}
620+
}
621+
622+
/// Upserts [session] into the SQL database.
623+
Future<void> writeUserSessionToSql(UserSession session) async {
624+
await primaryDatabase.transactWithRetry((db) async {
625+
// TODO: consider supporting a generated `upsertValue()` in typed_sql
626+
await db.userSessions
627+
.insertValue(
628+
sessionId: session.sessionId,
629+
userId: session.userId,
630+
email: session.email,
631+
name: session.name,
632+
imageUrl: session.imageUrl,
633+
created: session.created!,
634+
expires: session.expires!,
635+
authenticatedAt: session.authenticatedAt,
636+
csrfToken: session.csrfToken,
637+
openidNonce: session.openidNonce,
638+
accessToken: session.accessToken,
639+
grantedScopes: session.grantedScopes,
640+
)
641+
.onConflict(.primaryKey)
642+
.update(
643+
(_, _, set) => set(
644+
userId: session.userId.asExpr,
645+
email: session.email.asExpr,
646+
name: session.name.asExpr,
647+
imageUrl: session.imageUrl.asExpr,
648+
created: session.created!.asExpr,
649+
expires: session.expires!.asExpr,
650+
authenticatedAt: session.authenticatedAt.asExpr,
651+
csrfToken: session.csrfToken.asExpr,
652+
openidNonce: session.openidNonce.asExpr,
653+
accessToken: session.accessToken.asExpr,
654+
grantedScopes: session.grantedScopes.asExpr,
655+
),
656+
)
657+
.execute();
658+
});
659+
}
660+
661+
Future<void> _deleteUserSessionFromSql(String sessionId) async {
662+
await primaryDatabase.withRetry(
663+
(db) => db.userSessions.delete(sessionId).execute(),
664+
);
665+
}
666+
667+
/// Copies Datastore [UserSession] entities into the SQL database.
668+
///
669+
/// When the `expires` field is newer on the SQL row, it will keep its current
670+
/// value, since it indicates it has been updated more recently.
671+
Future<({int scanned, int updated})>
672+
copyUserSessionsFromDatastoreToSql() async {
673+
var scanned = 0;
674+
var updated = 0;
675+
await for (final session in _db.query<UserSession>().run()) {
676+
scanned++;
677+
if (session.isExpired()) continue;
678+
679+
final row = await primaryDatabase.withRetry(
680+
(db) => db.userSessions.byKey(session.sessionId).fetch(),
681+
);
682+
683+
if (row == null || session.expires!.isAfter(row.expires)) {
684+
await writeUserSessionToSql(session);
685+
updated++;
686+
}
687+
}
688+
_logger.info(
689+
'Synced UserSessions Datastore -> SQL: scanned $scanned, updated $updated.',
690+
);
691+
return (scanned: scanned, updated: updated);
692+
}
693+
694+
/// Copies SQL [UserSessionRow] entries back into Datastore.
695+
///
696+
/// When the `expires` field is newer on the Datastore, it will keep its current
697+
/// value, since it indicates it has been updated more recently.
698+
Future<({int scanned, int updated})>
699+
copyUserSessionsFromSqlToDatastore() async {
700+
var scanned = 0;
701+
var updated = 0;
702+
703+
final rows = await primaryDatabase.withRetry(
704+
(db) => db.userSessions.stream().toList(),
705+
);
706+
707+
for (final row in rows) {
708+
scanned++;
709+
final key = _db.emptyKey.append(UserSession, id: row.sessionId);
710+
final existing = await _db.lookupOrNull<UserSession>(key);
711+
if (existing == null ||
712+
existing.expires == null ||
713+
row.expires.isAfter(existing.expires!)) {
714+
await _db.commit(inserts: [_userSessionFromRow(_db, row)]);
715+
updated++;
716+
}
717+
}
718+
_logger.info(
719+
'Synced UserSessions SQL -> Datastore: scanned $scanned, updated $updated.',
720+
);
721+
return (scanned: scanned, updated: updated);
722+
}
723+
590724
/// Deletes sessions associated with a [userId] or [sessionId].
591725
Future<void> deleteUserSessions({String? userId, String? sessionId}) async {
592726
if (sessionId != null) {
@@ -681,23 +815,34 @@ class _UserSessionDataAccess {
681815

682816
_UserSessionDataAccess(this._db);
683817

684-
Future<UserSession?> lookupOrNull(String sessionId) async {
685-
final key = _db.emptyKey.append(UserSession, id: sessionId);
686-
return await _db.lookupOrNull<UserSession>(key);
687-
}
688-
689-
/// Scans Datastore for all sessions the user has, and invalidates
690-
/// them all (by deleting the Datastore entry and purging the cache).
818+
/// Scans for all sessions the user has, and invalidates
819+
/// them all.
691820
Future<void> expireAllForUserId(String userId) async {
821+
final rows = await primaryDatabase.withRetry(
822+
(db) => db.userSessions
823+
.where((session) => session.userId.equalsValue(userId))
824+
.select((session) => (session.sessionId,))
825+
.fetch(),
826+
);
827+
for (final sessionId in rows) {
828+
await expire(sessionId);
829+
}
830+
692831
final query = _db.query<UserSession>()..filter('userId =', userId);
693832
final sessionsToDelete = await query.run().toList();
694833
for (final session in sessionsToDelete) {
695834
await expire(session.sessionId);
696835
}
697836
}
698837

699-
/// Removes the session data from the Datastore and from cache.
838+
/// Removes the session data from the SQL store, the Datastore and cache.
839+
///
840+
/// Deletes from SQL first, then from Datastore.
700841
Future<void> expire(String sessionId) async {
842+
await primaryDatabase.withRetry(
843+
(db) => db.userSessions.delete(sessionId).execute(),
844+
);
845+
701846
final key = _db.emptyKey.append(UserSession, id: sessionId);
702847
try {
703848
await _db.commit(deletes: [key]);
@@ -708,7 +853,17 @@ class _UserSessionDataAccess {
708853
}
709854

710855
/// Removes the session data that has expiry before [ts].
856+
///
857+
/// Deletes from SQL first, then from Datastore. Returns the number of
858+
/// deleted Datastore entities.
711859
Future<int> expireAllBeforeTimestamp(DateTime ts) async {
860+
await primaryDatabase.withRetry(
861+
(db) => db.userSessions
862+
.where((s) => s.expires.isBeforeValue(ts))
863+
.delete()
864+
.execute(),
865+
);
866+
712867
final query = _db.query<UserSession>()..filter('expires <', ts);
713868
final count = await _db.deleteWithQuery(query);
714869
return count.deleted;
@@ -725,3 +880,20 @@ class _UserSessionTransactionDataAcccess {
725880
return await _tx.lookupOrNull<UserSession>(key);
726881
}
727882
}
883+
884+
UserSession _userSessionFromRow(DatastoreDB db, UserSessionRow row) {
885+
return UserSession()
886+
..parentKey = db.emptyKey
887+
..id = row.sessionId
888+
..userId = row.userId
889+
..email = row.email
890+
..name = row.name
891+
..imageUrl = row.imageUrl
892+
..created = row.created
893+
..expires = row.expires
894+
..authenticatedAt = row.authenticatedAt
895+
..csrfToken = row.csrfToken
896+
..openidNonce = row.openidNonce
897+
..accessToken = row.accessToken
898+
..grantedScopes = row.grantedScopes;
899+
}

app/lib/admin/actions/actions.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import 'tool_execute.dart';
4343
import 'tool_list.dart';
4444
import 'uploader_count_report.dart';
4545
import 'user_info.dart';
46+
import 'user_session_migrate.dart';
4647

4748
export '../../shared/exceptions.dart';
4849

@@ -135,5 +136,6 @@ final class AdminAction {
135136
toolList,
136137
uploaderCountReport,
137138
userInfo,
139+
userSessionMigrate,
138140
];
139141
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import '../../account/backend.dart';
6+
7+
import 'actions.dart';
8+
9+
final userSessionMigrate = AdminAction(
10+
name: 'user-session-migrate',
11+
summary:
12+
'Copy UserSession entities between the Datastore and the SQL database.',
13+
description: '''
14+
Action that copies `UserSession` entities between the Datastore and the SQL database.
15+
16+
By default it copies from Datastore into the SQL database:
17+
--reverse=false (default)
18+
19+
Set `--reverse=true` to copy from the SQL database back into Datastore:
20+
--reverse=true
21+
22+
In both directions the entry with the newer `expires` timestamp wins.
23+
The action is safe to run repeatedly and in either order.
24+
''',
25+
options: {
26+
'reverse': 'true/false to copy SQL -> Datastore instead (default: false)',
27+
},
28+
invoke: (options) async {
29+
final reverseString = options['reverse'] ?? 'false';
30+
InvalidInputException.checkAnyOf(reverseString, 'reverse', [
31+
'true',
32+
'false',
33+
]);
34+
final reverse = reverseString == 'true';
35+
36+
final result = reverse
37+
? await accountBackend.copyUserSessionsFromSqlToDatastore()
38+
: await accountBackend.copyUserSessionsFromDatastoreToSql();
39+
40+
return {
41+
'reverse': reverse,
42+
'scanned': result.scanned,
43+
'updated': result.updated,
44+
};
45+
},
46+
);

app/lib/admin/tools/user_merger.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,18 @@ class UserMerger {
203203
await _processConcurrently(
204204
_db.query<UserSession>()..filter('userId =', fromUserId),
205205
(UserSession m) async {
206-
await withRetryTransaction(_db, (tx) async {
206+
final session = await withRetryTransaction(_db, (tx) async {
207207
final session = await tx.lookupValue<UserSession>(m.key);
208208
if (session.userId == fromUserId) {
209209
session.userId = toUserId;
210210
tx.insert(session);
211211
}
212+
return session;
212213
});
214+
// Mirror the reassigned session into SQL (session reads are SQL-first).
215+
// This also creates SQL rows for Datastore-only (pre-migration)
216+
// sessions that the bulk update would have missed.
217+
await accountBackend.writeUserSessionToSql(session);
213218
},
214219
);
215220

0 commit comments

Comments
 (0)