Skip to content

Commit 3cae924

Browse files
committed
fix: use adapter pattern
fix: bring ttl into adapter fix: more coverage fix: common file name pattern fix: drop adapter suffix Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent 51c17ea commit 3cae924

16 files changed

Lines changed: 791 additions & 220 deletions

File tree

src/internal/auth/jwks/manager.ts

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { decrypt, encrypt, generateHS512JWK } from '@internal/auth'
2-
import { createMutexByKey } from '@internal/concurrency'
32
import {
4-
createMonitoredCache,
5-
DEFAULT_MONITORED_CACHE_PURGE_STALE_INTERVAL_MS,
6-
getFromMonitoredCache,
3+
createLruCache,
4+
DEFAULT_CACHE_PURGE_STALE_INTERVAL_MS,
75
TENANT_JWKS_CACHE_NAME,
8-
} from '@internal/monitoring/cache'
6+
} from '@internal/cache'
7+
import { createMutexByKey } from '@internal/concurrency'
98
import { PubSubAdapter } from '@internal/pubsub'
109
import { Knex } from 'knex'
1110
import objectSizeOf from 'object-sizeof'
@@ -21,14 +20,14 @@ export const TENANT_JWKS_CACHE_MAX_ITEMS = 2048
2120
export const TENANT_JWKS_CACHE_MAX_SIZE_BYTES = 1024 * 1024 * 50 // 50 MiB
2221
export const TENANT_JWKS_CACHE_TTL_MS = 1000 * 60 * 60 // 1h
2322

24-
const tenantJwksConfigCache = createMonitoredCache<string, JwksConfig>(TENANT_JWKS_CACHE_NAME, {
23+
const tenantJwksConfigCache = createLruCache<string, JwksConfig>(TENANT_JWKS_CACHE_NAME, {
2524
max: TENANT_JWKS_CACHE_MAX_ITEMS,
2625
maxSize: TENANT_JWKS_CACHE_MAX_SIZE_BYTES,
2726
ttl: TENANT_JWKS_CACHE_TTL_MS,
2827
sizeCalculation: (value) => objectSizeOf(value),
2928
updateAgeOnGet: true,
3029
allowStale: false,
31-
purgeStaleIntervalMs: DEFAULT_MONITORED_CACHE_PURGE_STALE_INTERVAL_MS,
30+
purgeStaleIntervalMs: DEFAULT_CACHE_PURGE_STALE_INTERVAL_MS,
3231
})
3332

3433
function createJwkKid({ kind, id }: { id: string; kind: string }): string {
@@ -89,23 +88,14 @@ export class JWKSManager {
8988
* @param tenantId
9089
*/
9190
async getJwksTenantConfig(tenantId: string): Promise<JwksConfig> {
92-
const cachedJwks = getFromMonitoredCache(
93-
TENANT_JWKS_CACHE_NAME,
94-
tenantJwksConfigCache,
95-
tenantId
96-
)
91+
const cachedJwks = tenantJwksConfigCache.get(tenantId)
9792

9893
if (cachedJwks !== undefined) {
9994
return cachedJwks
10095
}
10196

10297
return tenantJwksMutex(tenantId, async () => {
103-
const cachedJwks = getFromMonitoredCache(
104-
TENANT_JWKS_CACHE_NAME,
105-
tenantJwksConfigCache,
106-
tenantId,
107-
{ recordMetrics: false }
108-
)
98+
const cachedJwks = tenantJwksConfigCache.get(tenantId, { recordMetrics: false })
10999

110100
if (cachedJwks !== undefined) {
111101
return cachedJwks

src/internal/auth/jwt.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { ERRORS } from '@internal/errors'
21
import {
3-
createMonitoredCache,
4-
DEFAULT_MONITORED_CACHE_PURGE_STALE_INTERVAL_MS,
5-
getFromMonitoredCache,
2+
createLruCache,
3+
DEFAULT_CACHE_PURGE_STALE_INTERVAL_MS,
64
JWT_CACHE_NAME,
7-
} from '@internal/monitoring/cache'
5+
} from '@internal/cache'
6+
import { ERRORS } from '@internal/errors'
87
import {
98
exportJWK,
109
generateSecret,
@@ -118,15 +117,12 @@ function getJWTAlgorithms(jwks: JwksConfig | null) {
118117
return algorithms
119118
}
120119

121-
const jwtCache = createMonitoredCache<string, { token: string; payload: JWTPayload }>(
122-
JWT_CACHE_NAME,
123-
{
124-
maxSize: 1024 * 1024 * 50, // 50MB
125-
sizeCalculation: (value) => objectSizeOf(value),
126-
ttlResolution: 5000, // 5 seconds
127-
purgeStaleIntervalMs: DEFAULT_MONITORED_CACHE_PURGE_STALE_INTERVAL_MS,
128-
}
129-
)
120+
const jwtCache = createLruCache<string, { token: string; payload: JWTPayload }>(JWT_CACHE_NAME, {
121+
maxSize: 1024 * 1024 * 50, // 50MB
122+
sizeCalculation: (value) => objectSizeOf(value),
123+
ttlResolution: 5000, // 5 seconds
124+
purgeStaleIntervalMs: DEFAULT_CACHE_PURGE_STALE_INTERVAL_MS,
125+
})
130126

131127
/**
132128
* Verifies if a JWT is valid and caches the payload
@@ -140,7 +136,7 @@ export async function verifyJWTWithCache(
140136
secret: string,
141137
jwks?: { keys: JwksConfigKey[] } | null
142138
) {
143-
const cachedVerification = getFromMonitoredCache(JWT_CACHE_NAME, jwtCache, token)
139+
const cachedVerification = jwtCache.get(token)
144140
if (
145141
cachedVerification &&
146142
cachedVerification.payload.exp &&

src/internal/cache/adapter.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
}

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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { LRUCache as BaseLruCache } from 'lru-cache'
2+
import { CacheLookupOptions, CacheLookupOutcome, OutcomeAwareCache } 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 OutcomeAwareCache<K, V, LruCacheSetOptions<K, V>>
16+
{
17+
private readonly cache: BaseLruCache<K, V>
18+
19+
constructor(options: LruCacheOptions<K, V>) {
20+
const { purgeStaleIntervalMs, ...cacheOptions } = options
21+
22+
this.cache = new BaseLruCache<K, V>({
23+
...cacheOptions,
24+
})
25+
26+
if (purgeStaleIntervalMs) {
27+
const timer = setInterval(() => {
28+
this.cache.purgeStale()
29+
}, purgeStaleIntervalMs)
30+
timer.unref?.()
31+
}
32+
}
33+
34+
get(key: K, options?: CacheLookupOptions): V | undefined {
35+
return this.getWithOutcome(key).value
36+
}
37+
38+
getWithOutcome(key: K) {
39+
const status: BaseLruCache.Status<V> = {}
40+
const value = this.cache.get(key, { status })
41+
const outcome = (status.get || (value === undefined ? 'miss' : 'hit')) as CacheLookupOutcome
42+
43+
return { value, outcome }
44+
}
45+
46+
set(key: K, value: V, options?: LruCacheSetOptions<K, V>): void {
47+
this.cache.set(key, value, options)
48+
}
49+
50+
delete(key: K): boolean {
51+
return this.cache.delete(key)
52+
}
53+
54+
getStats() {
55+
return {
56+
entries: this.cache.size,
57+
sizeBytes: this.cache.calculatedSize,
58+
}
59+
}
60+
61+
purgeStale(): boolean {
62+
return this.cache.purgeStale()
63+
}
64+
}
65+
66+
export function createLruCache<K extends {}, V extends {}>(
67+
options: LruCacheOptions<K, V>
68+
): LruCache<K, V>
69+
export function createLruCache<K extends {}, V extends {}>(
70+
name: CacheName,
71+
options: LruCacheOptions<K, V>
72+
): OutcomeAwareCache<K, V, LruCacheSetOptions<K, V>>
73+
export function createLruCache<K extends {}, V extends {}>(
74+
nameOrOptions: CacheName | LruCacheOptions<K, V>,
75+
maybeOptions?: LruCacheOptions<K, V>
76+
) {
77+
if (typeof nameOrOptions !== 'string') {
78+
return new LruCache(nameOrOptions)
79+
}
80+
81+
const cacheName = nameOrOptions
82+
const options = maybeOptions as LruCacheOptions<K, V>
83+
const cache = new LruCache<K, V>({
84+
...options,
85+
disposeAfter: withCacheEvictionMetrics(cacheName, options.disposeAfter),
86+
})
87+
88+
return monitorCache(cacheName, cache, {
89+
purgeStale: () => {
90+
cache.purgeStale()
91+
},
92+
})
93+
}

src/internal/cache/monitoring.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import {
2+
cacheEntries,
3+
cacheEvictionsTotal,
4+
cacheRequestsTotal,
5+
cacheSizeBytes,
6+
isMetricEnabled,
7+
meter,
8+
} from '@internal/monitoring/metrics'
9+
import { Attributes } from '@opentelemetry/api'
10+
import { CacheLookupOptions, OutcomeAwareCache } from './adapter'
11+
import { CacheName } from './names'
12+
13+
type CacheDisposeHandler<K, V, R extends string> = (value: V, key: K, reason: R) => void
14+
15+
type MonitorCacheOptions = {
16+
purgeStale?: () => void
17+
}
18+
19+
function cacheAttrs(
20+
cache: CacheName,
21+
attrs?: Record<string, string | number | boolean>
22+
): Attributes {
23+
return attrs ? { cache, ...attrs } : { cache }
24+
}
25+
26+
export function withCacheEvictionMetrics<K, V, R extends string>(
27+
cacheName: CacheName,
28+
dispose?: CacheDisposeHandler<K, V, R>
29+
): CacheDisposeHandler<K, V, R> {
30+
return (value, key, reason) => {
31+
if (reason === 'evict') {
32+
cacheEvictionsTotal.add(1, cacheAttrs(cacheName))
33+
}
34+
35+
dispose?.(value, key, reason)
36+
}
37+
}
38+
39+
class MonitoredCache<K, V, SetOptions = undefined> implements OutcomeAwareCache<K, V, SetOptions> {
40+
constructor(
41+
private readonly name: CacheName,
42+
private readonly cache: OutcomeAwareCache<K, V, SetOptions>,
43+
options?: MonitorCacheOptions
44+
) {
45+
meter.addBatchObservableCallback(
46+
(observer) => {
47+
options?.purgeStale?.()
48+
const stats = this.cache.getStats()
49+
const attrs = cacheAttrs(this.name)
50+
51+
if (isMetricEnabled('cache_entries')) {
52+
observer.observe(cacheEntries, stats.entries, attrs)
53+
}
54+
55+
if (isMetricEnabled('cache_size_bytes')) {
56+
observer.observe(cacheSizeBytes, stats.sizeBytes, attrs)
57+
}
58+
},
59+
[cacheEntries, cacheSizeBytes]
60+
)
61+
}
62+
63+
get(key: K, options?: CacheLookupOptions): V | undefined {
64+
if (options?.recordMetrics === false) {
65+
return this.cache.get(key, options)
66+
}
67+
68+
const { value, outcome } = this.cache.getWithOutcome(key)
69+
cacheRequestsTotal.add(1, cacheAttrs(this.name, { outcome }))
70+
71+
return value
72+
}
73+
74+
getWithOutcome(key: K) {
75+
return this.cache.getWithOutcome(key)
76+
}
77+
78+
set(key: K, value: V, options?: SetOptions): void {
79+
this.cache.set(key, value, options)
80+
}
81+
82+
delete(key: K): boolean {
83+
return this.cache.delete(key)
84+
}
85+
86+
getStats() {
87+
return this.cache.getStats()
88+
}
89+
}
90+
91+
export function monitorCache<K, V, SetOptions = undefined>(
92+
cacheName: CacheName,
93+
cache: OutcomeAwareCache<K, V, SetOptions>,
94+
options?: MonitorCacheOptions
95+
): OutcomeAwareCache<K, V, SetOptions> {
96+
return new MonitoredCache(cacheName, cache, options)
97+
}

src/internal/cache/names.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const JWT_CACHE_NAME = 'jwt' as const
2+
export const TENANT_CONFIG_CACHE_NAME = 'tenant_config' as const
3+
export const TENANT_JWKS_CACHE_NAME = 'tenant_jwks' as const
4+
export const TENANT_S3_CREDENTIALS_CACHE_NAME = 'tenant_s3_credentials' as const
5+
6+
export type CacheName =
7+
| typeof JWT_CACHE_NAME
8+
| typeof TENANT_CONFIG_CACHE_NAME
9+
| typeof TENANT_JWKS_CACHE_NAME
10+
| typeof TENANT_S3_CREDENTIALS_CACHE_NAME

0 commit comments

Comments
 (0)