|
1 | 1 | package io.supertokens; |
2 | 2 |
|
3 | 3 | import io.supertokens.auditlog.AuditLog; |
| 4 | +import io.supertokens.config.Config; |
4 | 5 | import io.supertokens.cronjobs.rollupUserLastActive.RollupDirtySignal; |
5 | 6 | import io.supertokens.pluginInterface.ActiveUsersSQLStorage; |
6 | 7 | import io.supertokens.pluginInterface.Storage; |
7 | 8 | import io.supertokens.pluginInterface.StorageUtils; |
| 9 | +import io.supertokens.pluginInterface.auditlog.ActivityEventType; |
8 | 10 | import io.supertokens.pluginInterface.auditlog.AuditLogEvent; |
9 | 11 | import io.supertokens.pluginInterface.exceptions.StorageQueryException; |
10 | 12 | import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException; |
|
14 | 16 | import io.supertokens.storageLayer.StorageLayer; |
15 | 17 | import org.jetbrains.annotations.TestOnly; |
16 | 18 |
|
| 19 | +import java.util.EnumSet; |
17 | 20 | import java.util.concurrent.ConcurrentHashMap; |
18 | 21 | import io.supertokens.auditlog.UnauditedTransaction; |
19 | 22 |
|
20 | 23 | public class ActiveUsers { |
21 | 24 |
|
22 | | - // Skip the user_last_active upsert if we already wrote one for this (app, userId) within |
23 | | - // this window. The table feeds daily/monthly active-user counts, so a few minutes of |
24 | | - // staleness is invisible — but at refresh-token rates the unthrottled upsert dominates |
25 | | - // commit waits on the database. |
| 25 | + // Skip appending a throttled activity event if we already wrote one for this (app, userId) within |
| 26 | + // this window. The activity log feeds daily/monthly active-user counts (via the fold), so a few |
| 27 | + // minutes of staleness is invisible — but at refresh-token rates an unthrottled insert dominates |
| 28 | + // commit waits on the database. Unthrottled activity classes (sign_in, sign_out) bypass this. |
26 | 29 | private static final long THROTTLE_MS = 5 * 60 * 1000L; |
27 | 30 |
|
| 31 | + // Throttle policy for the shared plugin-interface {@link ActivityEventType} vocabulary. The vocabulary |
| 32 | + // deliberately carries no throttle flag — its javadoc keeps throttling core-side — so which classes are |
| 33 | + // throttled is decided here: sign_in / sign_out are low-volume, user-initiated and audit-meaningful, so |
| 34 | + // they always emit; every other activity class is high-volume and shares the throttle. |
| 35 | + private static final EnumSet<ActivityEventType> UNTHROTTLED_EVENTS = |
| 36 | + EnumSet.of(ActivityEventType.SIGN_IN, ActivityEventType.SIGN_OUT); |
| 37 | + |
| 38 | + /** |
| 39 | + * @return whether emits of {@code eventType} are subject to the shared 5-minute per-{@code (app, user)} |
| 40 | + * throttle. {@code sign_in} / {@code sign_out} return {@code false} (always emitted); the rest return |
| 41 | + * {@code true}. Core-side policy over the plugin-interface {@link ActivityEventType} vocabulary. |
| 42 | + */ |
| 43 | + public static boolean isThrottled(ActivityEventType eventType) { |
| 44 | + return !UNTHROTTLED_EVENTS.contains(eventType); |
| 45 | + } |
| 46 | + |
28 | 47 | // Hard cap on cache size. Beyond this we sweep expired entries; if still over we clear. |
29 | 48 | // Extra upserts for a window are acceptable; unbounded memory growth is not. |
30 | 49 | private static final int MAX_CACHE_ENTRIES = 200_000; |
@@ -72,45 +91,106 @@ public static void markRecentlyActive(AppIdentifier appIdentifier, String userId |
72 | 91 | recordActiveAt(cacheKey(appIdentifier, userId), System.currentTimeMillis()); |
73 | 92 | } |
74 | 93 |
|
75 | | - public static void updateLastActive(AppIdentifier appIdentifier, Main main, String userId) |
| 94 | + /** |
| 95 | + * Records a unit of user activity of the given {@code eventType}, emitting it into the request's tenant. |
| 96 | + * The last-active rollup cron is the sole writer of the {@code user_last_active} projection (PLAN-011 |
| 97 | + * cutover); here we only append the activity-log event — the fold's source — and mark the storage dirty |
| 98 | + * so the next rollup pass folds it. When the {@code activity_log_throttle_enabled} config is on (the |
| 99 | + * default), throttled activity classes ({@link #isThrottled(ActivityEventType)}) skip the append when |
| 100 | + * this (app, user) was seen within the throttle window and unthrottled classes always append — either way |
| 101 | + * the recency cache is refreshed. When the config is off, the throttle and its cache are bypassed and |
| 102 | + * every activity is recorded as its own row (a complete audit trail). The projection updates |
| 103 | + * asynchronously (within a rollup interval). |
| 104 | + * |
| 105 | + * <p>The append is best-effort: {@link AuditLog#emit} swallows its own write failures, so a failed |
| 106 | + * activity write never fails the caller's request. That matters because these events are emitted after an |
| 107 | + * already-committed (and, for OAuth, externally non-reversible) auth operation — a transient activity-log |
| 108 | + * error must not turn a succeeded sign-in / refresh / session-create / sign-out / oauth call into a 500. |
| 109 | + * Dropped rows self-heal for active-user counting: the next event for the user re-credits them, and the |
| 110 | + * reliable recency anchors are the transactional {@code user_creation} / {@code account_linking} lifecycle |
| 111 | + * events. |
| 112 | + */ |
| 113 | + public static void updateLastActive(TenantIdentifier tenantIdentifier, Main main, String userId, |
| 114 | + ActivityEventType eventType) |
76 | 115 | throws TenantOrAppNotFoundException { |
| 116 | + AppIdentifier appIdentifier = tenantIdentifier.toAppIdentifier(); |
77 | 117 | long now = System.currentTimeMillis(); |
78 | 118 | String key = cacheKey(appIdentifier, userId); |
79 | | - if (!Main.isTesting && isRecentlyActive(key, now)) { |
80 | | - return; |
| 119 | + boolean throttleEnabled = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main) |
| 120 | + .getActivityLogThrottleEnabled(); |
| 121 | + if (throttleEnabled && !Main.isTesting) { |
| 122 | + if (isThrottled(eventType) && isRecentlyActive(key, now)) { |
| 123 | + return; |
| 124 | + } |
| 125 | + // Refresh the recency cache so a subsequent throttled event (and wasRecentlyActive) sees this |
| 126 | + // activity. Only meaningful while throttling is on; when off we never touch the cache, so |
| 127 | + // wasRecentlyActive stays false and every activity is recorded. |
| 128 | + recordActiveAt(key, now); |
81 | 129 | } |
| 130 | + // The activity log and its projection live on the app's public-tenant storage — as before, so the |
| 131 | + // fold (which groups by app_id) and the count read see the same rows. The request's tenant is written |
| 132 | + // into the tenant_id column for provenance only. |
82 | 133 | Storage storage = StorageLayer.getStorage(appIdentifier.getAsPublicTenantIdentifier(), main); |
83 | | - // The last-active rollup cron is the sole writer of user_last_active (PLAN-011 cutover). Here we only |
84 | | - // append the throttled user_last_active activity-log event — the fold's source — and mark the storage |
85 | | - // dirty so the next rollup pass folds it. The 5-minute throttle now caps activity-log insert volume |
86 | | - // instead of direct-upsert volume. The projection updates asynchronously (within a rollup interval). |
87 | | - recordActiveAt(key, now); |
88 | | - emitLastActiveAuditLog(main, storage, appIdentifier, userId, now); |
| 134 | + emitActivityAuditLog(main, storage, tenantIdentifier, userId, eventType, now); |
89 | 135 | } |
90 | 136 |
|
91 | 137 | /** |
92 | | - * Records a {@code user_last_active} entry in the activity_log table. Mirrors every successful |
93 | | - * user_last_active write so the audit log captures user activity. Best-effort: {@link AuditLog#emit} |
94 | | - * swallows its own failures, so a failed audit write never affects the active-users update. |
| 138 | + * Overload for callers that only have the app on hand (no request tenant): the event is emitted into the |
| 139 | + * app's public tenant — today's behavior for every activity emit before per-tenant provenance was added. |
95 | 140 | */ |
96 | | - private static void emitLastActiveAuditLog(Main main, Storage storage, AppIdentifier appIdentifier, |
97 | | - String userId, long now) { |
98 | | - TenantIdentifier tenantIdentifier = appIdentifier.getAsPublicTenantIdentifier(); |
| 141 | + public static void updateLastActive(AppIdentifier appIdentifier, Main main, String userId, |
| 142 | + ActivityEventType eventType) |
| 143 | + throws TenantOrAppNotFoundException { |
| 144 | + updateLastActive(appIdentifier.getAsPublicTenantIdentifier(), main, userId, eventType); |
| 145 | + } |
| 146 | + |
| 147 | + /** |
| 148 | + * Appends an activity event to the activity_log so the last-active fold captures the user's activity. |
| 149 | + * Best-effort: {@link AuditLog#emit} swallows its own failures, so a failed audit write never affects the |
| 150 | + * request. {@code tenant_id} carries the request's tenant; {@code event_type} is {@code eventType}'s value. |
| 151 | + */ |
| 152 | + private static void emitActivityAuditLog(Main main, Storage storage, TenantIdentifier tenantIdentifier, |
| 153 | + String userId, ActivityEventType eventType, long now) { |
99 | 154 | AuditLog.emit(main, storage, tenantIdentifier, new AuditLogEvent( |
100 | | - appIdentifier.getAppId(), tenantIdentifier.getTenantId(), |
| 155 | + tenantIdentifier.getAppId(), tenantIdentifier.getTenantId(), |
101 | 156 | userId, userId, |
102 | | - "user_last_active", "success", null, null, |
| 157 | + eventType.getValue(), "success", null, null, |
103 | 158 | now, null)); |
104 | 159 | // Signal the last-active rollup cron that this storage now has unfolded activity, so its next tick |
105 | 160 | // folds instead of skipping. |
106 | 161 | RollupDirtySignal.getInstance(main).markDirty(storage.getUserPoolId()); |
107 | 162 | } |
108 | 163 |
|
| 164 | + /** |
| 165 | + * Wakes the last-active rollup for a user whose fold credit comes from a transactional lifecycle event — |
| 166 | + * {@code user_creation} on sign-up, {@code account_linking} on link (the two lifecycle members of the |
| 167 | + * fold set, see {@code RollupEventTypes#FOLD_SET}) — rather than from {@link #updateLastActive}. Those events |
| 168 | + * are written on the mutation's own connection via {@code startAuditedTransaction}, which — unlike |
| 169 | + * {@code updateLastActive} / {@link #emitActivityAuditLog} — does not touch the rollup dirty signal. |
| 170 | + * |
| 171 | + * <p>Without this nudge a user who only signs up (or is only linked) and produces no other activity would |
| 172 | + * not be folded into {@code user_last_active} until the periodic backstop pass — up to a backstop |
| 173 | + * interval — a promptness regression versus the pre-semantic-event behaviour where sign-up went through |
| 174 | + * {@code updateLastActive → markDirty} and folded on the next rollup tick. |
| 175 | + * |
| 176 | + * <p>Call after the lifecycle event's transaction has committed. Marking dirty only signals <em>that</em> |
| 177 | + * there is something to fold, never the fold window, so it is idempotent and safe to over-signal; a lost |
| 178 | + * signal is corrected by the cron's periodic backstop. |
| 179 | + */ |
| 180 | + public static void markLastActiveRollupDirty(Main main, AppIdentifier appIdentifier) |
| 181 | + throws TenantOrAppNotFoundException { |
| 182 | + // The projection and its dirty flag are keyed by the app's public-tenant storage pool — the same |
| 183 | + // storage updateLastActive marks dirty — so a fold-relevant lifecycle event written on any tenant in |
| 184 | + // the pool wakes the one rollup pass that folds it. |
| 185 | + Storage storage = StorageLayer.getStorage(appIdentifier.getAsPublicTenantIdentifier(), main); |
| 186 | + RollupDirtySignal.getInstance(main).markDirty(storage.getUserPoolId()); |
| 187 | + } |
| 188 | + |
109 | 189 | @TestOnly |
110 | 190 | public static void updateLastActive(Main main, String userId) { |
111 | 191 | try { |
112 | 192 | ActiveUsers.updateLastActive(ResourceDistributor.getAppForTesting().toAppIdentifier(), |
113 | | - main, userId); |
| 193 | + main, userId, ActivityEventType.SIGN_IN); |
114 | 194 | } catch (TenantOrAppNotFoundException e) { |
115 | 195 | throw new IllegalStateException(e); |
116 | 196 | } |
@@ -138,20 +218,20 @@ public static void updateLastActiveAfterLinking(Main main, AppIdentifier appIden |
138 | 218 | // Latency optimization only: the rollup's reconcile — driven by the account_linking event that |
139 | 219 | // AuthRecipe.linkAccounts emits atomically with the mapping change — is the source of truth for |
140 | 220 | // dropping the recipe user's now-stale projection row. Deleting it here just makes the merge visible |
141 | | - // before the next rollup pass instead of after it. |
| 221 | + // before the next rollup pass instead of after it. The primary user's refreshed recency comes from |
| 222 | + // the same account_linking event (the fold credits primary_or_recipe_user_id), so no activity ping is |
| 223 | + // emitted here. |
142 | 224 | activeUsersStorage.startTransaction(con -> { |
143 | 225 | activeUsersStorage.deleteUserActive_Transaction(con, appIdentifier, recipeUserId); |
144 | 226 | return null; |
145 | 227 | }); |
146 | 228 | recentlyActiveCache.remove(cacheKey(appIdentifier, recipeUserId)); |
147 | 229 |
|
148 | | - // Bypass throttle: linking merges two users into primaryUserId, so its timestamp must |
149 | | - // be refreshed to "now" regardless of cache state — it now represents the merged |
150 | | - // activity and an undercounted timestamp would lose the recipeUser's recency. Emitted into the |
151 | | - // activity log (not written directly) so the rollup — the sole user_last_active writer — folds it. |
152 | | - long now = System.currentTimeMillis(); |
153 | | - recordActiveAt(cacheKey(appIdentifier, primaryUserId), now); |
154 | | - emitLastActiveAuditLog(main, activeUsersStorage, appIdentifier, primaryUserId, now); |
| 230 | + // The primary user's refreshed recency is credited by the account_linking lifecycle event |
| 231 | + // AuthRecipe.linkAccounts emitted transactionally — but that event, written via startAuditedTransaction, |
| 232 | + // does not mark the rollup dirty. Wake the rollup here so a link with no other activity folds on the |
| 233 | + // next tick rather than waiting for the periodic backstop. |
| 234 | + RollupDirtySignal.getInstance(main).markDirty(activeUsersStorage.getUserPoolId()); |
155 | 235 | } |
156 | 236 |
|
157 | 237 | @TestOnly |
|
0 commit comments