Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Emits `user_creation` and `tenant_disassociation` lifecycle events atomically with the mutation from the interactive user-creation and tenant-removal paths.
- Bulk import now emits lifecycle events atomically with the import: one `user_import` per imported user (counted toward user totals like `user_creation`, but under its own type so the last-active rollup can exclude imports) plus a `tenant_association` for each remaining tenant the user lands in.
- 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).
- Replaced the synthetic `user_last_active` event with semantic activity events (`sign_in`, `token_refresh`, `session_create`, `sign_out`, `oauth_token_exchange`, `oauth_authorize`); the last-active fold now reads these plus the `user_creation` and `account_linking` lifecycle events.

## [12.2.0]

Expand Down
150 changes: 56 additions & 94 deletions src/main/java/io/supertokens/ActiveUsers.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package io.supertokens;

import io.supertokens.auditlog.AuditLog;
import io.supertokens.auditlog.lifecycle.ActivityEventType;
import io.supertokens.cronjobs.rollupUserLastActive.RollupDirtySignal;
import io.supertokens.pluginInterface.ActiveUsersSQLStorage;
import io.supertokens.pluginInterface.Storage;
import io.supertokens.pluginInterface.StorageUtils;
import io.supertokens.pluginInterface.auditlog.ActivityLogSQLStorage;
import io.supertokens.pluginInterface.auditlog.AuditLogEvent;
import io.supertokens.pluginInterface.auditlog.AuditedResult;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException;
import io.supertokens.pluginInterface.multitenancy.AppIdentifier;
Expand All @@ -14,113 +16,80 @@
import io.supertokens.storageLayer.StorageLayer;
import org.jetbrains.annotations.TestOnly;

import java.util.concurrent.ConcurrentHashMap;
import io.supertokens.auditlog.UnauditedTransaction;

public class ActiveUsers {

// Skip the user_last_active upsert if we already wrote one for this (app, userId) within
// this window. The table feeds daily/monthly active-user counts, so a few minutes of
// staleness is invisible — but at refresh-token rates the unthrottled upsert dominates
// commit waits on the database.
private static final long THROTTLE_MS = 5 * 60 * 1000L;

// Hard cap on cache size. Beyond this we sweep expired entries; if still over we clear.
// Extra upserts for a window are acceptable; unbounded memory growth is not.
private static final int MAX_CACHE_ENTRIES = 200_000;

private static final ConcurrentHashMap<String, Long> recentlyActiveCache = new ConcurrentHashMap<>();

private static String cacheKey(AppIdentifier appIdentifier, String userId) {
return appIdentifier.getConnectionUriDomain() + "|" + appIdentifier.getAppId() + "|" + userId;
}

private static boolean isRecentlyActive(String key, long now) {
Long last = recentlyActiveCache.get(key);
return last != null && (now - last) < THROTTLE_MS;
}

private static void recordActiveAt(String key, long now) {
if (recentlyActiveCache.size() >= MAX_CACHE_ENTRIES) {
long cutoff = now - THROTTLE_MS;
recentlyActiveCache.entrySet().removeIf(e -> e.getValue() < cutoff);
if (recentlyActiveCache.size() >= MAX_CACHE_ENTRIES) {
recentlyActiveCache.clear();
}
}
recentlyActiveCache.put(key, now);
}

/**
* Returns true if updateLastActive has been called for this (app, userId) within the
* throttle window. Callers can use this to short-circuit work that exists only to feed
* updateLastActive (e.g. resolving a user-id mapping).
*/
public static boolean wasRecentlyActive(AppIdentifier appIdentifier, String userId) {
if (Main.isTesting) {
return false;
}
return isRecentlyActive(cacheKey(appIdentifier, userId), System.currentTimeMillis());
}
// Status written for a semantic activity row, mirroring the lifecycle events written to the same table.
private static final String STATUS_SUCCESS = "success";

/**
* Marks (app, userId) as recently active without performing a DB upsert. Used when the
* upsert was performed under an alias (e.g. supertokensUserId) and the caller wants future
* lookups by a different key (e.g. external userId) to short-circuit.
* Records a unit of user activity of the given {@code eventType}, emitting it into the request's tenant.
* The last-active rollup cron is the sole writer of the {@code user_last_active} projection (PLAN-011
* cutover); here we only append the activity-log event — the fold's source — and mark the storage dirty
* so the next rollup pass folds it. The projection updates asynchronously (within a rollup interval).
*
* <p>A semantic activity event is a true audit row, so it is written through {@code startAuditedTransaction}
* — the same mechanism {@link io.supertokens.auditlog.lifecycle.LifecycleAuditEvent lifecycle events} use:
* a real transaction that is fail-loud (a failed write fails the caller's request) and atomic, not a
* best-effort {@code AuditLog.emit}. There is no accompanying state mutation at the emit site to co-commit
* into (the interaction — a sign-in, a refresh, ... — is what the row records), so the audited transaction
* carries only the event. Every activity is written; there is no throttle (a true audit trail cannot skip
* rows).
*/
public static void markRecentlyActive(AppIdentifier appIdentifier, String userId) {
recordActiveAt(cacheKey(appIdentifier, userId), System.currentTimeMillis());
}

public static void updateLastActive(AppIdentifier appIdentifier, Main main, String userId)
throws TenantOrAppNotFoundException {
public static void updateLastActive(TenantIdentifier tenantIdentifier, Main main, String userId,
ActivityEventType eventType)
throws TenantOrAppNotFoundException, StorageQueryException {
AppIdentifier appIdentifier = tenantIdentifier.toAppIdentifier();
long now = System.currentTimeMillis();
String key = cacheKey(appIdentifier, userId);
if (!Main.isTesting && isRecentlyActive(key, now)) {
// The activity log and its projection live on the app's public-tenant storage, so the fold (which
// groups by app_id) and the count read see the same rows. The request's tenant is written into the
// tenant_id column for provenance only.
Storage storage = StorageLayer.getStorage(appIdentifier.getAsPublicTenantIdentifier(), main);
if (!(storage instanceof ActivityLogSQLStorage)) {
// No SQL activity-log storage to write to (e.g. a non-SQL storage): nothing to record.
return;
}
Storage storage = StorageLayer.getStorage(appIdentifier.getAsPublicTenantIdentifier(), main);
// The last-active rollup cron is the sole writer of user_last_active (PLAN-011 cutover). Here we only
// append the throttled user_last_active activity-log event — the fold's source — and mark the storage
// dirty so the next rollup pass folds it. The 5-minute throttle now caps activity-log insert volume
// instead of direct-upsert volume. The projection updates asynchronously (within a rollup interval).
recordActiveAt(key, now);
emitLastActiveAuditLog(main, storage, appIdentifier, userId, now);
ActivityLogSQLStorage auditStorage = (ActivityLogSQLStorage) storage;
try {
auditStorage.startAuditedTransaction(appIdentifier, con -> {
Comment thread
tamassoltesz marked this conversation as resolved.
Outdated
AuditLogEvent event = new AuditLogEvent(
tenantIdentifier.getAppId(), tenantIdentifier.getTenantId(),
userId, userId,
eventType.getValue(), STATUS_SUCCESS, null, null,
now, null);
return new AuditedResult<Void>(null, event);
});
} catch (StorageTransactionLogicException e) {
// The audited-transaction logic here only builds an event and never throws a logic exception, so
// this is unreachable; surface it as a storage error if the combinator's contract ever changes.
throw new StorageQueryException(e);
}
// Signal the last-active rollup cron that this storage now has unfolded activity, so its next tick
// folds instead of skipping.
RollupDirtySignal.getInstance(main).markDirty(storage.getUserPoolId());
}

/**
* Records a {@code user_last_active} entry in the activity_log table. Mirrors every successful
* user_last_active write so the audit log captures user activity. Best-effort: {@link AuditLog#emit}
* swallows its own failures, so a failed audit write never affects the active-users update.
* Overload for callers that only have the app on hand (no request tenant): the event is emitted into the
* app's public tenant — today's behavior for every activity emit before per-tenant provenance was added.
*/
private static void emitLastActiveAuditLog(Main main, Storage storage, AppIdentifier appIdentifier,
String userId, long now) {
TenantIdentifier tenantIdentifier = appIdentifier.getAsPublicTenantIdentifier();
AuditLog.emit(main, storage, tenantIdentifier, new AuditLogEvent(
appIdentifier.getAppId(), tenantIdentifier.getTenantId(),
userId, userId,
"user_last_active", "success", null, null,
now, null));
// Signal the last-active rollup cron that this storage now has unfolded activity, so its next tick
// folds instead of skipping.
RollupDirtySignal.getInstance(main).markDirty(storage.getUserPoolId());
public static void updateLastActive(AppIdentifier appIdentifier, Main main, String userId,
ActivityEventType eventType)
throws TenantOrAppNotFoundException, StorageQueryException {
updateLastActive(appIdentifier.getAsPublicTenantIdentifier(), main, userId, eventType);
}

@TestOnly
public static void updateLastActive(Main main, String userId) {
try {
ActiveUsers.updateLastActive(ResourceDistributor.getAppForTesting().toAppIdentifier(),
main, userId);
} catch (TenantOrAppNotFoundException e) {
main, userId, ActivityEventType.SIGN_IN);
} catch (TenantOrAppNotFoundException | StorageQueryException e) {
throw new IllegalStateException(e);
}
}

@TestOnly
public static void clearCacheForTesting() {
recentlyActiveCache.clear();
}

public static int countUsersActiveSince(Main main, AppIdentifier appIdentifier, long time)
throws StorageQueryException, TenantOrAppNotFoundException {
Storage storage = StorageLayer.getStorage(appIdentifier.getAsPublicTenantIdentifier(), main);
Expand All @@ -138,20 +107,13 @@ public static void updateLastActiveAfterLinking(Main main, AppIdentifier appIden
// Latency optimization only: the rollup's reconcile — driven by the account_linking event that
// AuthRecipe.linkAccounts emits atomically with the mapping change — is the source of truth for
// dropping the recipe user's now-stale projection row. Deleting it here just makes the merge visible
// before the next rollup pass instead of after it.
// before the next rollup pass instead of after it. The primary user's refreshed recency comes from
// the same account_linking event (the fold credits primary_or_recipe_user_id), so no activity ping is
// emitted here.
activeUsersStorage.startTransaction(con -> {
activeUsersStorage.deleteUserActive_Transaction(con, appIdentifier, recipeUserId);
return null;
});
recentlyActiveCache.remove(cacheKey(appIdentifier, recipeUserId));

// Bypass throttle: linking merges two users into primaryUserId, so its timestamp must
// be refreshed to "now" regardless of cache state — it now represents the merged
// activity and an undercounted timestamp would lose the recipeUser's recency. Emitted into the
// activity log (not written directly) so the rollup — the sole user_last_active writer — folds it.
long now = System.currentTimeMillis();
recordActiveAt(cacheKey(appIdentifier, primaryUserId), now);
emitLastActiveAuditLog(main, activeUsersStorage, appIdentifier, primaryUserId, now);
}

@TestOnly
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2026, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

package io.supertokens.auditlog.lifecycle;

/**
* The vocabulary of "activity" events written to the activity log — user-initiated interactions that mark a
* user as recently active. Like {@link LifecycleEventType lifecycle events}, an activity event is a true audit
* row: {@code ActiveUsers.updateLastActive} writes it through {@code startAuditedTransaction} (a real,
* fail-loud transaction on the same table), not a best-effort ping. Activity events feed the last-active
* rollup fold: {@code last_active(user) = MAX(created_at)} over these events (plus the activity-implying
* lifecycle events {@code user_creation} and {@code account_linking}).
*
* <p>The string {@link #getValue() value} is what lands in the {@code activity_log.event_type} column. These
* replaced the single synthetic {@code user_last_active} event, which is retired: the concrete interaction
* (a sign-in, a refresh, a session create, ...) is now recorded directly and the fold counts it.
*
* <p>Every activity is recorded; there is no throttle — a true audit trail cannot skip rows.
*/
public enum ActivityEventType {
Comment thread
tamassoltesz marked this conversation as resolved.
Outdated

/** An interactive sign-in of an existing user (emailpassword / webauthn / thirdparty / passwordless). */
SIGN_IN("sign_in"),

/** A session was explicitly revoked for a user. */
SIGN_OUT("sign_out"),

/** A session's tokens were refreshed. */
TOKEN_REFRESH("token_refresh"),

/** A new session was created for a user. */
SESSION_CREATE("session_create"),

/** An OAuth token exchange resolved to a session user. */
OAUTH_TOKEN_EXCHANGE("oauth_token_exchange"),

/** An OAuth authorization request resolved to a session user. */
OAUTH_AUTHORIZE("oauth_authorize");

private final String value;

ActivityEventType(String value) {
this.value = value;
}

/** The string stored in the {@code activity_log.event_type} column. */
public String getValue() {
return value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ private static void applyEvent(LifecycleEventPayload event, Map<String, Long> de
case USER_IMPORT:
// A bulk-imported user is counted toward totals exactly like an interactively created one:
// a +1 in the tenant it lands in. (The type distinction matters only to the last-active
// rollup, which reads user_last_active/account_linking rows and never enters this fold.)
// rollup, which folds user_creation but excludes user_import, and never enters this fold.)
add(deltas, event.tenantId, 1);
break;
case USER_GROUP_DELETION:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2026, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/

package io.supertokens.auditlog.lifecycle;

import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.stream.Collectors;

/**
* Single source of truth for the {@code activity_log.event_type} values that feed the last-active rollup
* fold, so the fold query and the {@code hasUnfoldedActivitySince} existence check cannot drift apart. The
* PostgreSQL plugin mirrors the same set in its own SQL (supertokens-postgresql-plugin#398); both halves must
* ship together.
*
* <p>The set is the six {@link ActivityEventType activity events} plus the two lifecycle events that imply
* activity: {@code user_creation} (an interactive creation counts as activity — the fold reads it in place of
* a sign-up ping) and {@code account_linking} (credits the primary user via {@code primary_or_recipe_user_id};
* the reconcile separately drops the linked-away recipe user's row). Everything else is excluded — notably
* {@code user_import} (imported != active) and the retired {@code user_last_active} (no writer remains).
*/
public final class LastActiveFoldEvents {

private LastActiveFoldEvents() {
}

/** The {@code event_type} values the fold credits toward a user's recency. Insertion order is preserved. */
public static final Set<String> FOLD_EVENT_TYPES;

static {
Set<String> types = new LinkedHashSet<>();
for (ActivityEventType type : ActivityEventType.values()) {
types.add(type.getValue());
}
types.add(LifecycleEventType.USER_CREATION.getValue());
types.add(LifecycleEventType.ACCOUNT_LINKING.getValue());
Comment thread
tamassoltesz marked this conversation as resolved.
Outdated
FOLD_EVENT_TYPES = Collections.unmodifiableSet(types);
}

/**
* @return the fold set as a SQL {@code IN}-list body, e.g. {@code 'sign_in', 'token_refresh', ...}. Safe to
* inline into a query string: every value is a compile-time enum constant, never user input.
*/
public static String sqlInList() {
return FOLD_EVENT_TYPES.stream().map(v -> "'" + v + "'").collect(Collectors.joining(", "));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
*/
public final class LifecycleAuditEvent {

// Mirrors the status written for the user_last_active activity rows.
// Mirrors the status written for the semantic activity rows.
private static final String STATUS_SUCCESS = "success";

private LifecycleAuditEvent() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public String getValue() {

/**
* @return the lifecycle event type with the given {@code event_type} value, or {@code null} if the
* value is not a lifecycle event (e.g. an activity event such as {@code user_last_active}).
* value is not a lifecycle event (e.g. an activity event such as {@code sign_in}).
*/
public static LifecycleEventType fromValue(String value) {
if (value != null) {
Expand Down
Loading
Loading