Skip to content

Commit 2b2fd96

Browse files
committed
feat: add monitored cache wrapper and use
create an internal cache package with using adapter pattern to wrap LRU or TTL caches. use it for refactoring existing TTL pool cache and JWT/S3 creds LRU caches. use it also for tenant JWKs and configs as LRUs. add metrics and a few panels to show fix double destroy call in pool TTL cache fix caching jwt only by token for rotation Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent b0cde79 commit 2b2fd96

24 files changed

Lines changed: 2853 additions & 102 deletions

monitoring/grafana/dashboards/storage-otel.json

Lines changed: 509 additions & 5 deletions
Large diffs are not rendered by default.

src/internal/auth/jwks/manager.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { decrypt, encrypt, generateHS512JWK } from '@internal/auth'
2+
import {
3+
createLruCache,
4+
DEFAULT_CACHE_PURGE_STALE_INTERVAL_MS,
5+
TENANT_JWKS_CACHE_NAME,
6+
} from '@internal/cache'
27
import { createMutexByKey } from '@internal/concurrency'
38
import { PubSubAdapter } from '@internal/pubsub'
49
import { Knex } from 'knex'
10+
import objectSizeOf from 'object-sizeof'
511
import { JwksConfig, JwksConfigKeyOCT } from '../../../config'
612
import { JWKSManagerStore } from './store'
713

@@ -10,7 +16,23 @@ const JWK_KIND_STORAGE_URL_SIGNING = 'storage-url-signing-key'
1016
const JWK_KID_SEPARATOR = '_'
1117

1218
const tenantJwksMutex = createMutexByKey<JwksConfig>()
13-
const tenantJwksConfigCache = new Map<string, JwksConfig>()
19+
export const TENANT_JWKS_CACHE_MAX_ITEMS = 16384
20+
export const TENANT_JWKS_CACHE_MAX_SIZE_BYTES = 1024 * 1024 * 50 // 50 MiB
21+
export const TENANT_JWKS_CACHE_TTL_MS = 1000 * 60 * 60 // 1h
22+
23+
const tenantJwksConfigCache = createLruCache<string, JwksConfig>(TENANT_JWKS_CACHE_NAME, {
24+
max: TENANT_JWKS_CACHE_MAX_ITEMS,
25+
maxSize: TENANT_JWKS_CACHE_MAX_SIZE_BYTES,
26+
ttl: TENANT_JWKS_CACHE_TTL_MS,
27+
sizeCalculation: (value) => objectSizeOf(value),
28+
updateAgeOnGet: true,
29+
allowStale: false,
30+
purgeStaleIntervalMs: DEFAULT_CACHE_PURGE_STALE_INTERVAL_MS,
31+
})
32+
33+
export function deleteTenantJwksConfig(tenantId: string): void {
34+
tenantJwksConfigCache.delete(tenantId)
35+
}
1436

1537
function createJwkKid({ kind, id }: { id: string; kind: string }): string {
1638
return kind + JWK_KID_SEPARATOR + id
@@ -72,14 +94,14 @@ export class JWKSManager {
7294
async getJwksTenantConfig(tenantId: string): Promise<JwksConfig> {
7395
const cachedJwks = tenantJwksConfigCache.get(tenantId)
7496

75-
if (cachedJwks) {
97+
if (cachedJwks !== undefined) {
7698
return cachedJwks
7799
}
78100

79101
return tenantJwksMutex(tenantId, async () => {
80-
const cachedJwks = tenantJwksConfigCache.get(tenantId)
102+
const cachedJwks = tenantJwksConfigCache.get(tenantId, { recordMetrics: false })
81103

82-
if (cachedJwks) {
104+
if (cachedJwks !== undefined) {
83105
return cachedJwks
84106
}
85107

src/internal/auth/jwt.ts

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import { createHash } from 'node:crypto'
2+
import {
3+
createLruCache,
4+
DEFAULT_CACHE_PURGE_STALE_INTERVAL_MS,
5+
JWT_CACHE_NAME,
6+
} from '@internal/cache'
17
import { ERRORS } from '@internal/errors'
28
import {
39
exportJWK,
@@ -9,7 +15,6 @@ import {
915
jwtVerify,
1016
SignJWT,
1117
} from 'jose'
12-
import { LRUCache } from 'lru-cache'
1318
import objectSizeOf from 'object-sizeof'
1419
import { getConfig, JwksConfig, JwksConfigKey, JwksConfigKeyOCT } from '../../config'
1520

@@ -33,6 +38,8 @@ export type SignedUploadToken = {
3338
exp: number
3439
}
3540

41+
const jwtJwksFingerprintCache = new WeakMap<object, string>()
42+
3643
async function findJWKFromHeader(
3744
header: JWTHeaderParameters,
3845
secret: string,
@@ -113,12 +120,46 @@ function getJWTAlgorithms(jwks: JwksConfig | null) {
113120
return algorithms
114121
}
115122

116-
const jwtCache = new LRUCache<string, { token: string; payload: JWTPayload }>({
117-
maxSize: 1024 * 1024 * 50, // 50MB
118-
sizeCalculation: (value) => {
119-
return objectSizeOf(value)
120-
},
121-
ttlResolution: 5000, // 5 seconds
123+
function getJWTJwksFingerprint(jwks?: { keys: JwksConfigKey[] } | null): string {
124+
if (!jwks) {
125+
return 'null'
126+
}
127+
128+
const cachedFingerprint = jwtJwksFingerprintCache.get(jwks)
129+
if (cachedFingerprint) {
130+
return cachedFingerprint
131+
}
132+
133+
const fingerprint = createHash('sha256')
134+
.update(JSON.stringify(jwks.keys ?? null))
135+
.digest('base64url')
136+
jwtJwksFingerprintCache.set(jwks, fingerprint)
137+
return fingerprint
138+
}
139+
140+
function getJWTCacheKey(token: string, secret: string, jwks?: { keys: JwksConfigKey[] } | null) {
141+
const hash = createHash('sha256')
142+
.update(token)
143+
.update('\0')
144+
.update(secret)
145+
.update('\0')
146+
.update(getJWTJwksFingerprint(jwks))
147+
148+
return hash.digest('base64url')
149+
}
150+
151+
// JWT payloads are comparatively small and high-churn, so keep a higher
152+
// cardinality guardrail than the longer-lived config-style caches.
153+
export const JWT_CACHE_MAX_ITEMS = 65536
154+
export const JWT_CACHE_MAX_SIZE_BYTES = 1024 * 1024 * 50 // 50 MiB
155+
export const JWT_CACHE_TTL_RESOLUTION_MS = 5000 // 5 seconds
156+
157+
const jwtCache = createLruCache<string, JWTPayload>(JWT_CACHE_NAME, {
158+
max: JWT_CACHE_MAX_ITEMS,
159+
maxSize: JWT_CACHE_MAX_SIZE_BYTES,
160+
sizeCalculation: (value) => objectSizeOf(value),
161+
ttlResolution: JWT_CACHE_TTL_RESOLUTION_MS,
162+
purgeStaleIntervalMs: DEFAULT_CACHE_PURGE_STALE_INTERVAL_MS,
122163
})
123164

124165
/**
@@ -133,13 +174,10 @@ export async function verifyJWTWithCache(
133174
secret: string,
134175
jwks?: { keys: JwksConfigKey[] } | null
135176
) {
136-
const cachedVerification = jwtCache.get(token)
137-
if (
138-
cachedVerification &&
139-
cachedVerification.payload.exp &&
140-
cachedVerification.payload.exp * 1000 > Date.now()
141-
) {
142-
return Promise.resolve(cachedVerification.payload)
177+
const cacheKey = getJWTCacheKey(token, secret, jwks)
178+
const cachedPayload = jwtCache.get(cacheKey)
179+
if (cachedPayload && cachedPayload.exp && cachedPayload.exp * 1000 > Date.now()) {
180+
return Promise.resolve(cachedPayload)
143181
}
144182

145183
try {
@@ -148,13 +186,10 @@ export async function verifyJWTWithCache(
148186
return payload
149187
}
150188

151-
jwtCache.set(
152-
token,
153-
{ token, payload },
154-
{
155-
ttl: payload.exp * 1000 - Date.now(),
156-
}
157-
)
189+
const ttl = payload.exp * 1000 - Date.now()
190+
if (ttl > 0) {
191+
jwtCache.set(cacheKey, payload, { ttl })
192+
}
158193
return payload
159194
} catch (e) {
160195
throw e

src/internal/cache/adapter.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export type CacheLookupOptions = {
2+
recordMetrics?: boolean
3+
}
4+
5+
export type CacheLookupOutcome = 'hit' | 'miss' | 'stale'
6+
7+
export type CacheLookupResult<V> = {
8+
value: V | undefined
9+
outcome: CacheLookupOutcome
10+
}
11+
12+
export type CacheStats = {
13+
entries: number
14+
sizeBytes: number
15+
}
16+
17+
export interface Cache<K, V, SetOptions = undefined> {
18+
get(key: K, options?: CacheLookupOptions): V | undefined
19+
set(key: K, value: V, options?: SetOptions): void
20+
delete(key: K): boolean
21+
}
22+
23+
export interface InspectableCache<K, V, SetOptions = undefined> extends Cache<K, V, SetOptions> {
24+
getStats(): CacheStats
25+
}
26+
27+
export interface OutcomeAwareCache<K, V, SetOptions = undefined>
28+
extends InspectableCache<K, V, SetOptions> {
29+
getWithOutcome(key: K): CacheLookupResult<V>
30+
}
31+
32+
export interface Disposable {
33+
dispose(): void
34+
}
35+
36+
export interface DisposableCache<K, V, SetOptions = undefined>
37+
extends OutcomeAwareCache<K, V, SetOptions>,
38+
Disposable {}

src/internal/cache/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './adapter'
2+
export * from './lru'
3+
export * from './names'
4+
export * from './ttl'

src/internal/cache/lru.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { LRUCache as BaseLruCache } from 'lru-cache'
2+
import { CacheLookupOptions, CacheLookupOutcome, DisposableCache } from './adapter'
3+
import { monitorCache, withCacheEvictionMetrics } from './monitoring'
4+
import { CacheName } from './names'
5+
6+
export type LruCacheSetOptions<K extends {}, V extends {}> = BaseLruCache.SetOptions<K, V, unknown>
7+
8+
export type LruCacheOptions<K extends {}, V extends {}> = BaseLruCache.Options<K, V, unknown> & {
9+
purgeStaleIntervalMs?: number
10+
}
11+
12+
export const DEFAULT_CACHE_PURGE_STALE_INTERVAL_MS = 1000 * 60 // 1 minute
13+
14+
export class LruCache<K extends {}, V extends {}>
15+
implements DisposableCache<K, V, LruCacheSetOptions<K, V>>
16+
{
17+
private readonly cache: BaseLruCache<K, V>
18+
private readonly purgeStaleTimer?: ReturnType<typeof setInterval>
19+
20+
constructor(options: LruCacheOptions<K, V>) {
21+
const { purgeStaleIntervalMs, ...cacheOptions } = options
22+
23+
this.cache = new BaseLruCache<K, V>({
24+
...cacheOptions,
25+
})
26+
27+
if (purgeStaleIntervalMs) {
28+
this.purgeStaleTimer = setInterval(() => {
29+
this.cache.purgeStale()
30+
}, purgeStaleIntervalMs)
31+
this.purgeStaleTimer.unref?.()
32+
}
33+
}
34+
35+
get(key: K, options?: CacheLookupOptions): V | undefined {
36+
return this.getWithOutcome(key).value
37+
}
38+
39+
getWithOutcome(key: K) {
40+
const status: BaseLruCache.Status<V> = {}
41+
const value = this.cache.get(key, { status })
42+
const outcome = (status.get || (value === undefined ? 'miss' : 'hit')) as CacheLookupOutcome
43+
44+
return { value, outcome }
45+
}
46+
47+
set(key: K, value: V, options?: LruCacheSetOptions<K, V>): void {
48+
this.cache.set(key, value, options)
49+
}
50+
51+
delete(key: K): boolean {
52+
return this.cache.delete(key)
53+
}
54+
55+
getStats() {
56+
return {
57+
entries: this.cache.size,
58+
sizeBytes: this.cache.calculatedSize,
59+
}
60+
}
61+
62+
purgeStale(): boolean {
63+
return this.cache.purgeStale()
64+
}
65+
66+
dispose(): void {
67+
if (this.purgeStaleTimer) {
68+
clearInterval(this.purgeStaleTimer)
69+
}
70+
}
71+
}
72+
73+
export function createLruCache<K extends {}, V extends {}>(
74+
options: LruCacheOptions<K, V>
75+
): LruCache<K, V>
76+
export function createLruCache<K extends {}, V extends {}>(
77+
name: CacheName,
78+
options: LruCacheOptions<K, V>
79+
): DisposableCache<K, V, LruCacheSetOptions<K, V>>
80+
export function createLruCache<K extends {}, V extends {}>(
81+
nameOrOptions: CacheName | LruCacheOptions<K, V>,
82+
maybeOptions?: LruCacheOptions<K, V>
83+
) {
84+
if (typeof nameOrOptions !== 'string') {
85+
return new LruCache(nameOrOptions)
86+
}
87+
88+
const cacheName = nameOrOptions
89+
const options = maybeOptions as LruCacheOptions<K, V>
90+
const cache = new LruCache<K, V>({
91+
...options,
92+
disposeAfter: withCacheEvictionMetrics(cacheName, options.disposeAfter),
93+
})
94+
95+
return monitorCache(cacheName, cache, {
96+
purgeStale: () => {
97+
cache.purgeStale()
98+
},
99+
})
100+
}

0 commit comments

Comments
 (0)