Skip to content

Commit dec133b

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 Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent e709e63 commit dec133b

22 files changed

Lines changed: 2342 additions & 65 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 = 2048
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: 26 additions & 19 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

@@ -113,12 +118,21 @@ function getJWTAlgorithms(jwks: JwksConfig | null) {
113118
return algorithms
114119
}
115120

116-
const jwtCache = new LRUCache<string, { token: string; payload: JWTPayload }>({
121+
function getJWTCacheKey(token: string, secret: string, jwks?: { keys: JwksConfigKey[] } | null) {
122+
return createHash('sha256')
123+
.update(token)
124+
.update('\0')
125+
.update(secret)
126+
.update('\0')
127+
.update(JSON.stringify(jwks?.keys ?? null))
128+
.digest('base64url')
129+
}
130+
131+
const jwtCache = createLruCache<string, JWTPayload>(JWT_CACHE_NAME, {
117132
maxSize: 1024 * 1024 * 50, // 50MB
118-
sizeCalculation: (value) => {
119-
return objectSizeOf(value)
120-
},
133+
sizeCalculation: (value) => objectSizeOf(value),
121134
ttlResolution: 5000, // 5 seconds
135+
purgeStaleIntervalMs: DEFAULT_CACHE_PURGE_STALE_INTERVAL_MS,
122136
})
123137

124138
/**
@@ -133,13 +147,10 @@ export async function verifyJWTWithCache(
133147
secret: string,
134148
jwks?: { keys: JwksConfigKey[] } | null
135149
) {
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)
150+
const cacheKey = getJWTCacheKey(token, secret, jwks)
151+
const cachedPayload = jwtCache.get(cacheKey)
152+
if (cachedPayload && cachedPayload.exp && cachedPayload.exp * 1000 > Date.now()) {
153+
return Promise.resolve(cachedPayload)
143154
}
144155

145156
try {
@@ -148,13 +159,9 @@ export async function verifyJWTWithCache(
148159
return payload
149160
}
150161

151-
jwtCache.set(
152-
token,
153-
{ token, payload },
154-
{
155-
ttl: payload.exp * 1000 - Date.now(),
156-
}
157-
)
162+
jwtCache.set(cacheKey, payload, {
163+
ttl: payload.exp * 1000 - Date.now(),
164+
})
158165
return payload
159166
} catch (e) {
160167
throw e

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: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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+
const cacheEntriesEnabled = isMetricEnabled('cache_entries')
48+
const cacheSizeBytesEnabled = isMetricEnabled('cache_size_bytes')
49+
50+
if (!cacheEntriesEnabled && !cacheSizeBytesEnabled) {
51+
return
52+
}
53+
54+
options?.purgeStale?.()
55+
const stats = this.cache.getStats()
56+
const attrs = cacheAttrs(this.name)
57+
58+
if (cacheEntriesEnabled) {
59+
observer.observe(cacheEntries, stats.entries, attrs)
60+
}
61+
62+
if (cacheSizeBytesEnabled) {
63+
observer.observe(cacheSizeBytes, stats.sizeBytes, attrs)
64+
}
65+
},
66+
[cacheEntries, cacheSizeBytes]
67+
)
68+
}
69+
70+
get(key: K, options?: CacheLookupOptions): V | undefined {
71+
if (options?.recordMetrics === false) {
72+
return this.cache.get(key, options)
73+
}
74+
75+
const { value, outcome } = this.cache.getWithOutcome(key)
76+
cacheRequestsTotal.add(1, cacheAttrs(this.name, { outcome }))
77+
78+
return value
79+
}
80+
81+
getWithOutcome(key: K) {
82+
return this.cache.getWithOutcome(key)
83+
}
84+
85+
set(key: K, value: V, options?: SetOptions): void {
86+
this.cache.set(key, value, options)
87+
}
88+
89+
delete(key: K): boolean {
90+
return this.cache.delete(key)
91+
}
92+
93+
getStats() {
94+
return this.cache.getStats()
95+
}
96+
}
97+
98+
export function monitorCache<K, V, SetOptions = undefined>(
99+
cacheName: CacheName,
100+
cache: OutcomeAwareCache<K, V, SetOptions>,
101+
options?: MonitorCacheOptions
102+
): OutcomeAwareCache<K, V, SetOptions> {
103+
return new MonitoredCache(cacheName, cache, options)
104+
}

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)