-
Notifications
You must be signed in to change notification settings - Fork 851
Expand file tree
/
Copy pathActiveUsers.java
More file actions
194 lines (174 loc) · 10.8 KB
/
Copy pathActiveUsers.java
File metadata and controls
194 lines (174 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
package io.supertokens;
import io.supertokens.auditlog.AuditLog;
import io.supertokens.auditlog.lifecycle.ActivityEventType;
import io.supertokens.config.Config;
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.AuditLogEvent;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException;
import io.supertokens.pluginInterface.multitenancy.AppIdentifier;
import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.storageLayer.StorageLayer;
import org.jetbrains.annotations.TestOnly;
import java.util.concurrent.ConcurrentHashMap;
import io.supertokens.auditlog.UnauditedTransaction;
public class ActiveUsers {
// Skip appending a throttled activity event if we already wrote one for this (app, userId) within
// this window. The activity log feeds daily/monthly active-user counts (via the fold), so a few
// minutes of staleness is invisible — but at refresh-token rates an unthrottled insert dominates
// commit waits on the database. Unthrottled activity classes (sign_in, sign_out) bypass this.
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());
}
/**
* 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.
*/
public static void markRecentlyActive(AppIdentifier appIdentifier, String userId) {
recordActiveAt(cacheKey(appIdentifier, userId), System.currentTimeMillis());
}
/**
* 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. When the {@code activity_log_throttle_enabled} config is on (the
* default), throttled activity classes ({@link ActivityEventType#isThrottled()}) skip the append when
* this (app, user) was seen within the throttle window and unthrottled classes always append — either way
* the recency cache is refreshed. When the config is off, the throttle and its cache are bypassed and
* every activity is recorded as its own row (a complete audit trail). The projection updates
* asynchronously (within a rollup interval).
*
* <p>The append is best-effort: {@link AuditLog#emit} swallows its own write failures, so a failed
* activity write never fails the caller's request. That matters because these events are emitted after an
* already-committed (and, for OAuth, externally non-reversible) auth operation — a transient activity-log
* error must not turn a succeeded sign-in / refresh / session-create / sign-out / oauth call into a 500.
* Dropped rows self-heal for active-user counting: the next event for the user re-credits them, and the
* reliable recency anchors are the transactional {@code user_creation} / {@code account_linking} lifecycle
* events.
*/
public static void updateLastActive(TenantIdentifier tenantIdentifier, Main main, String userId,
ActivityEventType eventType)
throws TenantOrAppNotFoundException {
AppIdentifier appIdentifier = tenantIdentifier.toAppIdentifier();
long now = System.currentTimeMillis();
String key = cacheKey(appIdentifier, userId);
boolean throttleEnabled = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main)
.getActivityLogThrottleEnabled();
if (throttleEnabled && !Main.isTesting) {
if (eventType.isThrottled() && isRecentlyActive(key, now)) {
return;
}
// Refresh the recency cache so a subsequent throttled event (and wasRecentlyActive) sees this
// activity. Only meaningful while throttling is on; when off we never touch the cache, so
// wasRecentlyActive stays false and every activity is recorded.
recordActiveAt(key, now);
}
// The activity log and its projection live on the app's public-tenant storage — as before, 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);
emitActivityAuditLog(main, storage, tenantIdentifier, userId, eventType, now);
}
/**
* 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.
*/
public static void updateLastActive(AppIdentifier appIdentifier, Main main, String userId,
ActivityEventType eventType)
throws TenantOrAppNotFoundException {
updateLastActive(appIdentifier.getAsPublicTenantIdentifier(), main, userId, eventType);
}
/**
* Appends an activity event to the activity_log so the last-active fold captures the user's activity.
* Best-effort: {@link AuditLog#emit} swallows its own failures, so a failed audit write never affects the
* request. {@code tenant_id} carries the request's tenant; {@code event_type} is {@code eventType}'s value.
*/
private static void emitActivityAuditLog(Main main, Storage storage, TenantIdentifier tenantIdentifier,
String userId, ActivityEventType eventType, long now) {
AuditLog.emit(main, storage, tenantIdentifier, new AuditLogEvent(
tenantIdentifier.getAppId(), tenantIdentifier.getTenantId(),
userId, userId,
eventType.getValue(), "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());
}
@TestOnly
public static void updateLastActive(Main main, String userId) {
try {
ActiveUsers.updateLastActive(ResourceDistributor.getAppForTesting().toAppIdentifier(),
main, userId, ActivityEventType.SIGN_IN);
} catch (TenantOrAppNotFoundException 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);
return StorageUtils.getActiveUsersStorage(storage).countUsersActiveSince(appIdentifier, time);
}
@UnauditedTransaction(justification = "Legacy unaudited transaction (PLAN-012 backlog); pending conversion to startAuditedTransaction or read-only exemption.")
public static void updateLastActiveAfterLinking(Main main, AppIdentifier appIdentifier, String primaryUserId,
String recipeUserId)
throws StorageQueryException, TenantOrAppNotFoundException, StorageTransactionLogicException {
ActiveUsersSQLStorage activeUsersStorage =
(ActiveUsersSQLStorage) StorageUtils.getActiveUsersStorage(
StorageLayer.getStorage(appIdentifier.getAsPublicTenantIdentifier(), main));
// 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. 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));
}
@TestOnly
public static int countUsersActiveSince(Main main, long time)
throws StorageQueryException, TenantOrAppNotFoundException {
return countUsersActiveSince(main, ResourceDistributor.getAppForTesting().toAppIdentifier(), time);
}
}