Skip to content

Commit 54176e4

Browse files
supertokens-agent-runnerclaude
andcommitted
fix: guard last-active fold against deleted apps (in-memory)
The rollup fold re-inserted user_last_active rows from retained activity_log events for since-deleted apps, which violates the user_last_active -> apps FK. Add an EXISTS(apps) guard so the fold only projects still-existing apps, plus a test that a user_last_active event for an app absent from apps is skipped while a concurrent event for an existing app still folds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d596a65 commit 54176e4

3 files changed

Lines changed: 66 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1010
- `ActiveUsers.updateLastActive` no longer writes `user_last_active` directly; it only appends the throttled `user_last_active` activity-log event, and the `RollupUserLastActive` cron is now the sole writer of the projection (so counts reflect activity within a rollup interval).
1111
- Added Phase-1 parity tests proving the last-active rollup derives the same `countUsersActiveSince` answer (and per-user projection) as the direct write, including link/unlink cases (`ActivityLogRollupParityTest`).
1212
- Adds an observability-only shadow audit to the approximate-user-count background refresh; discrepancies are logged and emitted as telemetry, never served.
13+
- The last-active rollup fold now skips activity for apps no longer present in `apps`, so a deleted app's retained `activity_log` rows can never resurrect a `user_last_active` projection row (which would violate its `apps` foreign key).
1314

1415
## [12.2.0]
1516

src/main/java/io/supertokens/inmemorydb/queries/ActiveUsersQueries.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,17 @@ public static void rollupLastActiveFromActivityLog_Transaction(Start start, Conn
166166
throws StorageQueryException, SQLException {
167167
String userLastActiveTable = Config.getConfig(start).getUserLastActiveTable();
168168
String activityLogTable = Config.getConfig(start).getActivityLogTable();
169+
String appsTable = Config.getConfig(start).getAppsTable();
169170

170171
// SQLite's two-argument max() is the scalar GREATEST, so the upsert stays monotonic.
172+
// The apps guard skips activity for apps deleted within the window: activity_log rows are
173+
// intentionally retained after an app is deleted (no app_id cascade), but user_last_active
174+
// cascades on app delete, so folding a since-deleted app's rows would violate the
175+
// user_last_active -> apps foreign key. EXISTS keeps the fold set to still-existing apps only.
171176
String FOLD_QUERY = "INSERT INTO " + userLastActiveTable + " (app_id, user_id, last_active_time)"
172-
+ " SELECT app_id, primary_or_recipe_user_id, MAX(created_at) FROM " + activityLogTable
177+
+ " SELECT app_id, primary_or_recipe_user_id, MAX(created_at) FROM " + activityLogTable + " al"
173178
+ " WHERE event_type = 'user_last_active' AND created_at >= ?"
179+
+ " AND EXISTS (SELECT 1 FROM " + appsTable + " a WHERE a.app_id = al.app_id)"
174180
+ " GROUP BY app_id, primary_or_recipe_user_id"
175181
+ " ON CONFLICT (app_id, user_id) DO UPDATE"
176182
+ " SET last_active_time = MAX(" + userLastActiveTable + ".last_active_time,"

src/test/java/io/supertokens/test/ActivityLogRollupTest.java

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,39 @@ public void reconcileRemovesRecipeUserLinkedAwayInWindow() throws Exception {
149149
stopProcess(process);
150150
}
151151

152+
/**
153+
* The fold must never resurrect a projection row for an app that no longer exists. {@code activity_log}
154+
* rows are intentionally retained after an app is deleted (no app_id cascade), while {@code
155+
* user_last_active} cascades on app delete; folding a since-deleted app's activity would re-insert a row
156+
* that violates the {@code user_last_active -> apps} foreign key (this is what surfaced on PostgreSQL
157+
* once the test DB stopped being reset). The {@code EXISTS (apps)} guard confines the fold to still-
158+
* existing apps: a user_last_active event whose app_id is absent from {@code apps} is skipped, while a
159+
* concurrent event for an existing app in the same window still folds normally.
160+
*/
161+
@Test
162+
public void foldSkipsActivityForAppMissingFromApps() throws Exception {
163+
TestingProcessManager.TestingProcess process = startInMemoryProcess();
164+
Start storage = (Start) StorageLayer.getStorage(process.getProcess());
165+
166+
long base = System.currentTimeMillis();
167+
String existingAppUser = "rollup-app-guard-existing";
168+
String deletedAppUser = "rollup-app-guard-deleted";
169+
// "public" is present in the apps table; this id never is, standing in for a deleted app whose
170+
// activity_log rows still linger.
171+
String missingAppId = "app-that-was-deleted";
172+
173+
insertUserLastActiveEventForApp(storage, APP_ID, existingAppUser, base + 1000);
174+
insertUserLastActiveEventForApp(storage, missingAppId, deletedAppUser, base + 2000);
175+
176+
runRollup(storage, base - 10000);
177+
178+
// The existing app's user is folded; the missing app's user is skipped (no resurrected row).
179+
assertEquals(Long.valueOf(base + 1000), getLastActiveForApp(storage, APP_ID, existingAppUser));
180+
assertNull(getLastActiveForApp(storage, missingAppId, deletedAppUser));
181+
182+
stopProcess(process);
183+
}
184+
152185
/**
153186
* A transactional audit write plus a mutation on one connection, with a failure injected after the
154187
* write, must roll back together — neither the audit row nor the mutation survives.
@@ -251,25 +284,46 @@ private void seedUserLastActive(Start storage, String userId, long lastActiveTim
251284
});
252285
}
253286

287+
private Long getLastActiveForApp(Start storage, String appId, String userId) throws Exception {
288+
String query = "SELECT last_active_time FROM " + USER_LAST_ACTIVE + " WHERE app_id = ? AND user_id = ?";
289+
return storage.startTransaction(con -> {
290+
Connection sqlCon = (Connection) con.getConnection();
291+
try (PreparedStatement pst = sqlCon.prepareStatement(query)) {
292+
pst.setString(1, appId);
293+
pst.setString(2, userId);
294+
try (ResultSet rs = pst.executeQuery()) {
295+
return rs.next() ? Long.valueOf(rs.getLong(1)) : null;
296+
}
297+
} catch (Exception e) {
298+
throw new RuntimeException(e);
299+
}
300+
});
301+
}
302+
254303
private void insertUserLastActiveEvent(Start storage, String userId, long createdAt) throws Exception {
255304
// For a user_last_active event the user is its own primary_or_recipe_user_id.
256-
insertActivityLogRow(storage, userId, userId, "user_last_active", createdAt);
305+
insertActivityLogRow(storage, APP_ID, userId, userId, "user_last_active", createdAt);
306+
}
307+
308+
private void insertUserLastActiveEventForApp(Start storage, String appId, String userId, long createdAt)
309+
throws Exception {
310+
insertActivityLogRow(storage, appId, userId, userId, "user_last_active", createdAt);
257311
}
258312

259313
private void insertAccountLinkingEvent(Start storage, String recipeUserId, String primaryUserId, long createdAt)
260314
throws Exception {
261-
insertActivityLogRow(storage, recipeUserId, primaryUserId, "account_linking", createdAt);
315+
insertActivityLogRow(storage, APP_ID, recipeUserId, primaryUserId, "account_linking", createdAt);
262316
}
263317

264-
private void insertActivityLogRow(Start storage, String recipeUserId, String primaryOrRecipeUserId,
318+
private void insertActivityLogRow(Start storage, String appId, String recipeUserId, String primaryOrRecipeUserId,
265319
String eventType, long createdAt) throws Exception {
266320
String query = "INSERT INTO " + ACTIVITY_LOG
267321
+ " (app_id, tenant_id, recipe_user_id, primary_or_recipe_user_id, event_type, status, created_at)"
268322
+ " VALUES (?, 'public', ?, ?, ?, 'success', ?)";
269323
storage.startTransaction(con -> {
270324
Connection sqlCon = (Connection) con.getConnection();
271325
try (PreparedStatement pst = sqlCon.prepareStatement(query)) {
272-
pst.setString(1, APP_ID);
326+
pst.setString(1, appId);
273327
pst.setString(2, recipeUserId);
274328
pst.setString(3, primaryOrRecipeUserId);
275329
pst.setString(4, eventType);

0 commit comments

Comments
 (0)