Skip to content

Commit 4da924d

Browse files
committed
perf(auth): cache auth session and user row lookups
1 parent 50a17b6 commit 4da924d

4 files changed

Lines changed: 246 additions & 16 deletions

File tree

fluxer_api/pkgs/cache/src/ICacheService.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ interface CacheMSetEntry<T> {
1010

1111
export type CacheLookupResult<T> = {hit: true; value: T} | {hit: false};
1212

13+
type CacheTtlSeconds<T> = number | ((value: T) => number);
14+
1315
export abstract class ICacheService {
1416
private readonly inflightValues = new Map<string, Promise<unknown>>();
1517

@@ -56,7 +58,7 @@ export abstract class ICacheService {
5658
return entry.hit ? entry.value : null;
5759
}
5860

59-
async getOrSet<T>(key: string, valueFactory: () => Promise<T>, ttlSeconds?: number): Promise<T> {
61+
async getOrSet<T>(key: string, valueFactory: () => Promise<T>, ttlSeconds?: CacheTtlSeconds<T>): Promise<T> {
6062
const existing = await this.getEntry<T>(key);
6163
if (existing.hit) {
6264
return existing.value;
@@ -75,9 +77,13 @@ export abstract class ICacheService {
7577
return await pending;
7678
}
7779

78-
private async produceAndStore<T>(key: string, valueFactory: () => Promise<T>, ttlSeconds?: number): Promise<T> {
80+
private async produceAndStore<T>(
81+
key: string,
82+
valueFactory: () => Promise<T>,
83+
ttlSeconds?: CacheTtlSeconds<T>,
84+
): Promise<T> {
7985
const value = await valueFactory();
80-
await this.set(key, value, ttlSeconds);
86+
await this.set(key, value, typeof ttlSeconds === 'function' ? ttlSeconds(value) : ttlSeconds);
8187
return value;
8288
}
8389
}

fluxer_api/pkgs/cache/src/__tests__/CacheGetOrSet.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,25 @@ import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
55
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
66
import {describe, expect, it, vi} from 'vitest';
77

8-
function createKVCacheProvider(): {provider: KVCacheProvider; store: Map<string, string>} {
8+
function createKVCacheProvider(): {
9+
provider: KVCacheProvider;
10+
store: Map<string, string>;
11+
ttls: Array<[string, number]>;
12+
} {
913
const store = new Map<string, string>();
14+
const ttls: Array<[string, number]> = [];
1015
const client = {
1116
get: async (key: string) => store.get(key) ?? null,
1217
set: async (key: string, value: string) => {
1318
store.set(key, value);
1419
return 'OK';
1520
},
16-
setex: async (key: string, _ttlSeconds: number, value: string) => {
21+
setex: async (key: string, ttlSeconds: number, value: string) => {
22+
ttls.push([key, ttlSeconds]);
1723
store.set(key, value);
1824
},
1925
} as unknown as IKVProvider;
20-
return {provider: new KVCacheProvider({client}), store};
26+
return {provider: new KVCacheProvider({client}), store, ttls};
2127
}
2228

2329
function delay(ms: number): Promise<void> {
@@ -76,6 +82,18 @@ describe('ICacheService.getOrSet', () => {
7682
expect(factory).toHaveBeenCalledTimes(1);
7783
});
7884

85+
it('resolves the ttl from the produced value', async () => {
86+
const {provider, store, ttls} = createKVCacheProvider();
87+
const resolver = (value: number | null) => (value === null ? 5 : 30);
88+
await expect(provider.getOrSet<number | null>('present', async () => 1, resolver)).resolves.toBe(1);
89+
await expect(provider.getOrSet<number | null>('absent', async () => null, resolver)).resolves.toBeNull();
90+
expect(ttls).toEqual([
91+
['present', 30],
92+
['absent', 5],
93+
]);
94+
expect(store.get('absent')).toBe('null');
95+
});
96+
7997
it('rejects every waiter and retries on the next call when the factory fails', async () => {
8098
const cache = new InMemoryProvider();
8199
const failing = vi.fn(async () => {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
import {createHash} from 'node:crypto';
4+
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
5+
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
6+
import type {ApiTestHarness} from '../../test/ApiTestHarness';
7+
import {createBuilder} from '../../test/TestRequestBuilder';
8+
import {
9+
createAuthHarness,
10+
createFakeAuthToken,
11+
createTestAccount,
12+
loginAccount,
13+
setUserACLs,
14+
type TestAccount,
15+
} from './AuthTestUtils';
16+
17+
function authSessionCacheKey(token: string): string {
18+
return `auth:session:${createHash('sha256').update(token).digest('base64url')}`;
19+
}
20+
21+
async function readCachedSession(harness: ApiTestHarness, token: string): Promise<string | null> {
22+
return harness.kvProvider.get(authSessionCacheKey(token));
23+
}
24+
25+
describe('Auth session cache invalidation', () => {
26+
let harness: ApiTestHarness;
27+
beforeAll(async () => {
28+
harness = await createAuthHarness();
29+
});
30+
beforeEach(async () => {
31+
await harness.reset();
32+
});
33+
afterAll(async () => {
34+
await harness?.shutdown();
35+
});
36+
it('caches the session on the token hash and never on the raw token', async () => {
37+
const account = await createTestAccount(harness);
38+
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
39+
const cached = await readCachedSession(harness, account.token);
40+
expect(cached).not.toBeNull();
41+
expect(cached).not.toContain(account.token);
42+
await expect(harness.kvProvider.get(`auth:session:${account.token}`)).resolves.toBeNull();
43+
});
44+
it('stops serving a session revoked by logout', async () => {
45+
const account = await createTestAccount(harness);
46+
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
47+
expect(await readCachedSession(harness, account.token)).not.toBeNull();
48+
await createBuilder(harness, account.token).post('/auth/logout').expect(204).execute();
49+
expect(await readCachedSession(harness, account.token)).toBeNull();
50+
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
51+
});
52+
it('stops serving a session revoked from another session list', async () => {
53+
const account = await createTestAccount(harness);
54+
const second = await loginAccount(harness, account);
55+
await createBuilder(harness, second.token).get('/users/@me').expect(200).execute();
56+
expect(await readCachedSession(harness, second.token)).not.toBeNull();
57+
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
58+
.get('/auth/sessions')
59+
.execute();
60+
const secondSessionIdHash = createHash('sha256').update(second.token).digest('base64url');
61+
expect(sessions.some((session) => session.id_hash === secondSessionIdHash)).toBe(true);
62+
await createBuilder(harness, account.token)
63+
.post('/auth/sessions/logout')
64+
.body({session_id_hashes: [secondSessionIdHash], password: account.password})
65+
.expect(204)
66+
.execute();
67+
expect(await readCachedSession(harness, second.token)).toBeNull();
68+
await createBuilder(harness, second.token).get('/users/@me').expect(401).execute();
69+
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
70+
});
71+
it('stops serving every session of a user after a password change', async () => {
72+
const account = await createTestAccount(harness);
73+
const second = await loginAccount(harness, account);
74+
const third = await loginAccount(harness, account);
75+
for (const token of [account.token, second.token, third.token]) {
76+
await createBuilder(harness, token).get('/users/@me').expect(200).execute();
77+
expect(await readCachedSession(harness, token)).not.toBeNull();
78+
}
79+
await createBuilder(harness, account.token)
80+
.patch('/users/@me')
81+
.body({password: account.password, new_password: `cache-rotation-${Date.now()}`})
82+
.execute();
83+
for (const token of [account.token, second.token, third.token]) {
84+
expect(await readCachedSession(harness, token)).toBeNull();
85+
await createBuilder(harness, token).get('/users/@me').expect(401).execute();
86+
}
87+
});
88+
it('stops serving every session of a user after an admin temp ban', async () => {
89+
const target = await createTestAccount(harness);
90+
const targetSecond = await loginAccount(harness, target);
91+
let admin: TestAccount = await createTestAccount(harness);
92+
admin = await setUserACLs(harness, admin, ['admin:authenticate', 'user:temp_ban']);
93+
for (const token of [target.token, targetSecond.token]) {
94+
await createBuilder(harness, token).get('/users/@me').expect(200).execute();
95+
expect(await readCachedSession(harness, token)).not.toBeNull();
96+
}
97+
await createBuilder(harness, admin.token)
98+
.post('/admin/users/temp-ban')
99+
.body({user_id: target.userId, duration_hours: 24, reason: 'cache invalidation coverage'})
100+
.execute();
101+
for (const token of [target.token, targetSecond.token]) {
102+
expect(await readCachedSession(harness, token)).toBeNull();
103+
await createBuilder(harness, token).get('/users/@me').expect(401).execute();
104+
}
105+
});
106+
it('stops serving sessions after the account disables itself', async () => {
107+
const account = await createTestAccount(harness);
108+
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
109+
expect(await readCachedSession(harness, account.token)).not.toBeNull();
110+
await createBuilder(harness, account.token)
111+
.post('/users/@me/disable')
112+
.body({password: account.password})
113+
.expect(204)
114+
.execute();
115+
expect(await readCachedSession(harness, account.token)).toBeNull();
116+
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
117+
});
118+
it('caches an unknown token hash without stranding sessions created afterwards', async () => {
119+
const unknownToken = createFakeAuthToken();
120+
await createBuilder(harness, unknownToken).get('/users/@me').expect(401).execute();
121+
await expect(readCachedSession(harness, unknownToken)).resolves.toBe('null');
122+
await createBuilder(harness, unknownToken).get('/users/@me').expect(401).execute();
123+
const account = await createTestAccount(harness);
124+
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
125+
const rotated = await loginAccount(harness, account);
126+
await createBuilder(harness, rotated.token).get('/users/@me').expect(200).execute();
127+
});
128+
});

fluxer_api/src/api/user/repositories/auth/AuthSessionRepository.ts

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,70 @@
11
// SPDX-License-Identifier: AGPL-3.0-or-later
22

3-
import type {UserID} from '../../../BrandedTypes';
3+
import {createUserID, type UserID} from '../../../BrandedTypes';
44
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '../../../database/CassandraQueryExecution';
55
import {Db} from '../../../database/CassandraTypes';
66
import type {AuthSessionRow, AuthSessionTombstoneRow, UserCountryHistoryRow} from '../../../database/types/AuthTypes';
77
import {Logger} from '../../../Logger';
8-
import {getPhoneFraudGraphService} from '../../../middleware/ServiceSingletons';
8+
import {getCacheService, getPhoneFraudGraphService} from '../../../middleware/ServiceSingletons';
99
import {AuthSession, AuthSessionTombstone} from '../../../models/AuthSession';
1010
import {AuthSessions, AuthSessionsByUserId, AuthSessionTombstones, UserCountryHistory} from '../../../Tables';
1111

12-
function invalidateAuthSessionCache(_sessionIdHash: Buffer): void {}
12+
const AUTH_SESSION_CACHE_TTL_SECONDS = 30;
13+
const AUTH_SESSION_MISS_CACHE_TTL_SECONDS = 5;
14+
15+
interface CachedAuthSession {
16+
user_id: string;
17+
session_id_hash: string;
18+
created_at: number;
19+
approx_last_used_at: number;
20+
client_ip: string;
21+
client_user_agent: string | null;
22+
client_os: string | null;
23+
client_country: string | null;
24+
version: number;
25+
}
26+
27+
function authSessionCacheKey(sessionIdHash: Buffer): string {
28+
return `auth:session:${sessionIdHash.toString('base64url')}`;
29+
}
30+
31+
function encodeCachedAuthSession(row: AuthSessionRow): CachedAuthSession {
32+
return {
33+
user_id: row.user_id.toString(),
34+
session_id_hash: row.session_id_hash.toString('base64url'),
35+
created_at: row.created_at.getTime(),
36+
approx_last_used_at: row.approx_last_used_at.getTime(),
37+
client_ip: row.client_ip,
38+
client_user_agent: row.client_user_agent,
39+
client_os: row.client_os,
40+
client_country: row.client_country,
41+
version: row.version,
42+
};
43+
}
44+
45+
function decodeCachedAuthSession(cached: CachedAuthSession): AuthSessionRow {
46+
return {
47+
user_id: createUserID(BigInt(cached.user_id)),
48+
session_id_hash: Buffer.from(cached.session_id_hash, 'base64url'),
49+
created_at: new Date(cached.created_at),
50+
approx_last_used_at: new Date(cached.approx_last_used_at),
51+
client_ip: cached.client_ip,
52+
client_user_agent: cached.client_user_agent,
53+
client_os: cached.client_os,
54+
client_country: cached.client_country,
55+
version: cached.version,
56+
};
57+
}
58+
59+
async function invalidateAuthSessionCache(sessionIdHashes: ReadonlyArray<Buffer>): Promise<void> {
60+
if (sessionIdHashes.length === 0) return;
61+
try {
62+
const cache = getCacheService();
63+
await Promise.all(sessionIdHashes.map((sessionIdHash) => cache.delete(authSessionCacheKey(sessionIdHash))));
64+
} catch (error) {
65+
Logger.error({error}, 'Failed to invalidate cached auth sessions; they expire with the cache ttl');
66+
}
67+
}
1368

1469
const FETCH_AUTH_SESSIONS_CQL = AuthSessions.selectCql({
1570
where: AuthSessions.where.in('session_id_hash', 'session_id_hashes'),
@@ -46,6 +101,7 @@ export class AuthSessionRepository {
46101
}),
47102
);
48103
await batch.execute();
104+
await invalidateAuthSessionCache([sessionData.session_id_hash]);
49105
try {
50106
await getPhoneFraudGraphService().recordSessionForCohortGraph(
51107
sessionData.user_id,
@@ -107,10 +163,27 @@ export class AuthSessionRepository {
107163
}
108164

109165
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
110-
const session = await fetchOne<AuthSessionRow>(FETCH_AUTH_SESSION_BY_TOKEN_CQL, {
166+
try {
167+
const cached = await getCacheService().getOrSet<CachedAuthSession | null>(
168+
authSessionCacheKey(sessionIdHash),
169+
async () => {
170+
const session = await this.fetchAuthSessionByToken(sessionIdHash);
171+
return session ? encodeCachedAuthSession(session) : null;
172+
},
173+
(value) => (value === null ? AUTH_SESSION_MISS_CACHE_TTL_SECONDS : AUTH_SESSION_CACHE_TTL_SECONDS),
174+
);
175+
return cached ? new AuthSession(decodeCachedAuthSession(cached)) : null;
176+
} catch (error) {
177+
Logger.warn({error}, 'Auth session cache lookup failed; falling back to the datastore');
178+
const session = await this.fetchAuthSessionByToken(sessionIdHash);
179+
return session ? new AuthSession(session) : null;
180+
}
181+
}
182+
183+
private async fetchAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSessionRow | null> {
184+
return fetchOne<AuthSessionRow>(FETCH_AUTH_SESSION_BY_TOKEN_CQL, {
111185
session_id_hash: sessionIdHash,
112186
});
113-
return session ? new AuthSession(session) : null;
114187
}
115188

116189
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
@@ -138,11 +211,12 @@ export class AuthSessionRepository {
138211
await upsertOne(
139212
AuthSessions.patchByPk({session_id_hash: sessionIdHash}, {approx_last_used_at: Db.set(approximateLastUsedAt)}),
140213
);
141-
invalidateAuthSessionCache(sessionIdHash);
214+
await invalidateAuthSessionCache([sessionIdHash]);
142215
}
143216

144217
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
145218
if (sessionIdHashes.length === 0) return;
219+
await invalidateAuthSessionCache(sessionIdHashes);
146220
let originals: Array<AuthSessionRow> = [];
147221
try {
148222
originals = await fetchMany<AuthSessionRow>(FETCH_AUTH_SESSIONS_CQL, {
@@ -165,6 +239,7 @@ export class AuthSessionRepository {
165239
batch.addPrepared(AuthSessionTombstones.insert(toTombstoneRow(original, deletedAt)));
166240
}
167241
await batch.execute();
242+
await invalidateAuthSessionCache(sessionIdHashes);
168243
}
169244

170245
async deleteAllAuthSessions(userId: UserID): Promise<void> {
@@ -174,10 +249,12 @@ export class AuthSessionRepository {
174249
user_id: userId,
175250
});
176251
if (sessionRefs.length === 0) return;
252+
const sessionIdHashes = sessionRefs.map((session) => session.session_id_hash);
253+
await invalidateAuthSessionCache(sessionIdHashes);
177254
let originals: Array<AuthSessionRow> = [];
178255
try {
179256
originals = await fetchMany<AuthSessionRow>(FETCH_AUTH_SESSIONS_CQL, {
180-
session_id_hashes: sessionRefs.map((s) => s.session_id_hash),
257+
session_id_hashes: sessionIdHashes,
181258
});
182259
} catch (error) {
183260
Logger.warn(
@@ -187,19 +264,20 @@ export class AuthSessionRepository {
187264
}
188265
const deletedAt = new Date();
189266
const batch = new BatchBuilder();
190-
for (const session of sessionRefs) {
191-
batch.addPrepared(AuthSessions.deleteByPk({session_id_hash: session.session_id_hash}));
267+
for (const sessionIdHash of sessionIdHashes) {
268+
batch.addPrepared(AuthSessions.deleteByPk({session_id_hash: sessionIdHash}));
192269
batch.addPrepared(
193270
AuthSessionsByUserId.deleteByPk({
194271
user_id: userId,
195-
session_id_hash: session.session_id_hash,
272+
session_id_hash: sessionIdHash,
196273
}),
197274
);
198275
}
199276
for (const original of originals) {
200277
batch.addPrepared(AuthSessionTombstones.insert(toTombstoneRow(original, deletedAt)));
201278
}
202279
await batch.execute();
280+
await invalidateAuthSessionCache(sessionIdHashes);
203281
}
204282
}
205283

0 commit comments

Comments
 (0)