Skip to content

Commit

Permalink
Add tiered OBO cache with Redis
Browse files Browse the repository at this point in the history
  • Loading branch information
cskrov committed Jul 19, 2024
1 parent 7b8ff11 commit 5781b35
Show file tree
Hide file tree
Showing 10 changed files with 140 additions and 9 deletions.
3 changes: 3 additions & 0 deletions nais/nais.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ spec:
envFrom:
- secret: kabal-session-key
- secret: slack-url
redis:
- instance: obo-cache
access: readwrite
liveness:
path: /isAlive
initialDelay: 3
Expand Down
Binary file modified server/bun.lockb
Binary file not shown.
3 changes: 2 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"fastify-metrics": "11.0.0",
"jose": "5.6.3",
"openid-client": "5.6.5",
"prom-client": "15.1.3"
"prom-client": "15.1.3",
"redis": "^4.6.15"
},
"devDependencies": {
"@eslint/js": "9.6.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@ import { Counter, Gauge, Histogram } from 'prom-client';

const labelNames = ['hit'] as const;

export const cacheRedisGauge = new Counter({
name: 'obo_redis_cache',
help: 'Number of requests to the Redis OBO cache. "hit" is the type of hit: "miss", "invalid", "hit" or "expired".',
labelNames,
registers: [proxyRegister],
});

export const cacheGauge = new Counter({
name: 'obo_cache',
help: 'Number of requests to the OBO cache. "hit" is the type of hit: "miss", "hit", or "expired".',
help: 'Number of requests to the OBO cache. "hit" is the type of hit: "miss", "redis", "hit", or "expired".',
labelNames,
registers: [proxyRegister],
});
Expand Down
54 changes: 54 additions & 0 deletions server/src/auth/cache/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { OboCacheInterface } from '@app/auth/cache/interface';
import { oboMemoryCache } from '@app/auth/cache/memory-cache';
import { OboRedisCache } from '@app/auth/cache/redis-cache';
import { optionalEnvString } from '@app/config/env-var';

const REDIS_URI = optionalEnvString('REDIS_URI_OBO_CACHE');
const REDIS_USERNAME = optionalEnvString('REDIS_USERNAME_OBO_CACHE');
const REDIS_PASSWORD = optionalEnvString('REDIS_PASSWORD_OBO_CACHE');

class OboTieredCache implements OboCacheInterface {
private oboRedisCache: OboRedisCache;

constructor(redisUri: string, redisUsername: string, redisPassword: string) {
this.oboRedisCache = new OboRedisCache(redisUri, redisUsername, redisPassword);
}

public async get(key: string): Promise<string | null> {
const memoryHit = await oboMemoryCache.get(key);

if (memoryHit !== null) {
return memoryHit.token;
}

const redisHit = await this.oboRedisCache.get(key);

if (redisHit !== null) {
oboMemoryCache.set(key, redisHit.token, redisHit.expiresAt);

return redisHit.token;
}

return null;
}

public async set(key: string, token: string, expiresAt: number): Promise<void> {
await Promise.all([oboMemoryCache.set(key, token, expiresAt), this.oboRedisCache.set(key, token, expiresAt)]);
}
}

class OboSimpleCache {
public async get(key: string): Promise<string | null> {
const memoryHit = await oboMemoryCache.get(key);

return memoryHit?.token ?? null;
}

public async set(key: string, token: string, expiresAt: number): Promise<void> {
await oboMemoryCache.set(key, token, expiresAt);
}
}

const hasRedis = REDIS_URI !== undefined && REDIS_USERNAME !== undefined && REDIS_PASSWORD !== undefined;

export const oboCache = hasRedis ? new OboTieredCache(REDIS_URI, REDIS_USERNAME, REDIS_PASSWORD) : new OboSimpleCache();
9 changes: 9 additions & 0 deletions server/src/auth/cache/interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface OboCacheTierInterface {
get(key: string): Promise<{ token: string; expiresAt: number } | null>;
set(key: string, token: string, expiresAt: number): Promise<void>;
}

export interface OboCacheInterface {
get(key: string): Promise<string | null>;
set(key: string, token: string, expiresAt: number): Promise<void>;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { cacheGauge, cacheSizeGauge } from '@app/auth/cache-gauge';
import { cacheGauge, cacheSizeGauge } from '@app/auth/cache/cache-gauge';
import { OboCacheTierInterface } from '@app/auth/cache/interface';
import { getLogger } from '@app/logger';

const log = getLogger('obo-cache');

type Value = [string, number];

export class OboMemoryCache {
export class OboMemoryCache implements OboCacheTierInterface {
private cache: Map<string, Value> = new Map();

constructor() {
Expand All @@ -16,7 +17,7 @@ export class OboMemoryCache {
setInterval(() => this.clean(), 10 * 60 * 1000); // 10 minutes.
}

public async get(key: string): Promise<string | null> {
public async get(key: string) {
const value = this.cache.get(key);

if (value === undefined) {
Expand All @@ -37,7 +38,7 @@ export class OboMemoryCache {

cacheGauge.inc({ hit: 'hit' });

return token;
return { token, expiresAt };
}

public async set(key: string, token: string, expiresAt: number) {
Expand Down Expand Up @@ -80,4 +81,4 @@ export class OboMemoryCache {

const now = () => Math.ceil(Date.now() / 1_000);

export const oboCache = new OboMemoryCache();
export const oboMemoryCache = new OboMemoryCache();
56 changes: 56 additions & 0 deletions server/src/auth/cache/redis-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { RedisClientType, createClient } from 'redis';
import { getLogger } from '@app/logger';
import { OboCacheTierInterface } from '@app/auth/cache/interface';
import { cacheGauge, cacheRedisGauge } from '@app/auth/cache/cache-gauge';

const log = getLogger('redis-obo-cache');

export class OboRedisCache implements OboCacheTierInterface {
private client: RedisClientType;

constructor(url: string, username: string, password: string) {
this.client = createClient({ url, username, password });
this.client.on('error', (error) => log.error({ msg: 'Redis Client Error', error }));
this.client.connect();
}

public async get(key: string) {
/**
* ttl() gets remaining time to live in seconds.
* Returns -2 if the key does not exist.
* Returns -1 if the key exists but has no associated expire.
* @see https://redis.io/docs/latest/commands/ttl/
*/
const [token, ttl] = await Promise.all([this.client.get(key), this.client.ttl(key)]);

if (token === null || ttl === -2) {
cacheRedisGauge.inc({ hit: 'miss' });

return null;
}

if (ttl === -1) {
cacheRedisGauge.inc({ hit: 'invalid' });
this.client.del(key);

return null;
}

if (ttl === 0) {
cacheRedisGauge.inc({ hit: 'expired' });

return null;
}

cacheRedisGauge.inc({ hit: 'hit' });
cacheGauge.inc({ hit: 'redis' });

return { token, expiresAt: now() + ttl };
}

public async set(key: string, token: string, expiresAt: number) {
await this.client.set(key, token, { EXAT: expiresAt });
}
}

const now = () => Math.floor(Date.now() / 1_000);
2 changes: 1 addition & 1 deletion server/src/auth/on-behalf-of.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Client, GrantBody } from 'openid-client';
import { AZURE_APP_CLIENT_ID, NAIS_CLUSTER_NAME } from '@app/config/config';
import { getLogger } from '@app/logger';
import { oboCache } from '@app/auth/cache';
import { oboCache } from '@app/auth/cache/cache';

const log = getLogger('obo-token');

Expand Down
2 changes: 1 addition & 1 deletion server/src/plugins/obo-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getLogger } from '@app/logger';
import { getOnBehalfOfAccessToken } from '@app/auth/on-behalf-of';
import fastifyPlugin from 'fastify-plugin';
import { isDeployed } from '@app/config/env';
import { oboRequestDuration } from '@app/auth/cache-gauge';
import { oboRequestDuration } from '@app/auth/cache/cache-gauge';

const log = getLogger('obo-token-plugin');

Expand Down

0 comments on commit 5781b35

Please sign in to comment.