refactor(server): make redis required module (#9121)

This commit is contained in:
forehalo
2024-12-13 06:27:15 +00:00
parent 81c68032e1
commit 0e73737407
40 changed files with 292 additions and 728 deletions

View File

@@ -1,51 +0,0 @@
export interface CacheSetOptions {
/**
* in milliseconds
*/
ttl?: number;
}
// extends if needed
export interface Cache {
// standard operation
get<T = unknown>(key: string): Promise<T | undefined>;
set<T = unknown>(
key: string,
value: T,
opts?: CacheSetOptions
): Promise<boolean>;
setnx<T = unknown>(
key: string,
value: T,
opts?: CacheSetOptions
): Promise<boolean>;
increase(key: string, count?: number): Promise<number>;
decrease(key: string, count?: number): Promise<number>;
delete(key: string): Promise<boolean>;
has(key: string): Promise<boolean>;
ttl(key: string): Promise<number>;
expire(key: string, ttl: number): Promise<boolean>;
// list operations
pushBack<T = unknown>(key: string, ...values: T[]): Promise<number>;
pushFront<T = unknown>(key: string, ...values: T[]): Promise<number>;
len(key: string): Promise<number>;
list<T = unknown>(key: string, start: number, end: number): Promise<T[]>;
popFront<T = unknown>(key: string, count?: number): Promise<T[]>;
popBack<T = unknown>(key: string, count?: number): Promise<T[]>;
// map operations
mapSet<T = unknown>(
map: string,
key: string,
value: T,
opts: CacheSetOptions
): Promise<boolean>;
mapIncrease(map: string, key: string, count?: number): Promise<number>;
mapDecrease(map: string, key: string, count?: number): Promise<number>;
mapGet<T = unknown>(map: string, key: string): Promise<T | undefined>;
mapDelete(map: string, key: string): Promise<boolean>;
mapKeys(map: string): Promise<string[]>;
mapRandomKey(map: string): Promise<string | undefined>;
mapLen(map: string): Promise<number>;
}

View File

@@ -1,13 +1,18 @@
import { Injectable } from '@nestjs/common';
import { LocalCache } from './local';
import { CacheRedis, SessionRedis } from '../redis';
import { CacheProvider } from './provider';
@Injectable()
export class Cache extends LocalCache {}
@Injectable()
export class SessionCache extends LocalCache {
constructor() {
super({ namespace: 'session' });
export class Cache extends CacheProvider {
constructor(redis: CacheRedis) {
super(redis);
}
}
@Injectable()
export class SessionCache extends CacheProvider {
constructor(redis: SessionRedis) {
super(redis);
}
}

View File

@@ -1,286 +0,0 @@
import Keyv, { KeyvOptions } from 'keyv';
import type { Cache, CacheSetOptions } from './def';
export class LocalCache implements Cache {
private readonly kv: Keyv;
constructor(opts: KeyvOptions = {}) {
this.kv = new Keyv(opts);
}
// standard operation
async get<T = unknown>(key: string): Promise<T | undefined> {
return this.kv.get<T>(key).catch(() => undefined);
}
async set<T = unknown>(
key: string,
value: T,
opts: CacheSetOptions = {}
): Promise<boolean> {
return this.kv
.set(key, value, opts.ttl)
.then(() => true)
.catch(() => false);
}
async setnx<T = unknown>(
key: string,
value: T,
opts?: CacheSetOptions | undefined
): Promise<boolean> {
if (!(await this.has(key))) {
return this.set(key, value, opts);
}
return false;
}
async increase(key: string, count: number = 1): Promise<number> {
const prev = (await this.get(key)) ?? 0;
if (typeof prev !== 'number') {
throw new Error(
`Expect a Number keyed by ${key}, but found ${typeof prev}`
);
}
const curr = prev + count;
return (await this.set(key, curr)) ? curr : prev;
}
async decrease(key: string, count: number = 1): Promise<number> {
return this.increase(key, -count);
}
async delete(key: string): Promise<boolean> {
return this.kv.delete(key).catch(() => false);
}
async has(key: string): Promise<boolean> {
return this.kv.has(key).catch(() => false);
}
async ttl(key: string): Promise<number> {
return this.kv
.get(key, { raw: true })
.then(raw => (raw?.expires ? raw.expires - Date.now() : Infinity))
.catch(() => 0);
}
async expire(key: string, ttl: number): Promise<boolean> {
const value = await this.kv.get(key);
return this.set(key, value, { ttl });
}
// list operations
private async getArray<T = unknown>(key: string) {
const raw = await this.kv.get<T[]>(key, { raw: true });
if (raw && !Array.isArray(raw.value)) {
throw new Error(
`Expect an Array keyed by ${key}, but found ${raw.value}`
);
}
return raw;
}
private async setArray<T = unknown>(
key: string,
value: T[],
opts: CacheSetOptions = {}
) {
return this.set(key, value, opts).then(() => value.length);
}
async pushBack<T = unknown>(key: string, ...values: T[]): Promise<number> {
let list: any[] = [];
let ttl: number | undefined = undefined;
const raw = await this.getArray(key);
if (raw) {
if (raw.value) {
list = raw.value;
}
if (raw.expires) {
ttl = raw.expires - Date.now();
}
}
list = list.concat(values);
return this.setArray(key, list, { ttl });
}
async pushFront<T = unknown>(key: string, ...values: T[]): Promise<number> {
let list: any[] = [];
let ttl: number | undefined = undefined;
const raw = await this.getArray(key);
if (raw) {
if (raw.value) {
list = raw.value;
}
if (raw.expires) {
ttl = raw.expires - Date.now();
}
}
list = values.concat(list);
return this.setArray(key, list, { ttl });
}
async len(key: string): Promise<number> {
return this.getArray<any[]>(key).then(v => v?.value?.length ?? 0);
}
/**
* list array elements with `[start, end]`
* the end indice is inclusive
*/
async list<T = unknown>(
key: string,
start: number,
end: number
): Promise<T[]> {
const raw = await this.getArray<T>(key);
if (raw?.value) {
start = (raw.value.length + start) % raw.value.length;
end = ((raw.value.length + end) % raw.value.length) + 1;
return raw.value.slice(start, end);
} else {
return [];
}
}
private async trim<T = unknown>(key: string, start: number, end: number) {
const raw = await this.getArray<T>(key);
if (raw && raw.value) {
start = (raw.value.length + start) % raw.value.length;
// make negative end index work, and end indice is inclusive
end = ((raw.value.length + end) % raw.value.length) + 1;
const result = raw.value.splice(start, end);
await this.set(key, raw.value, {
ttl: raw.expires ? raw.expires - Date.now() : undefined,
});
return result;
}
return [];
}
async popFront<T = unknown>(key: string, count: number = 1) {
return this.trim<T>(key, 0, count - 1);
}
async popBack<T = unknown>(key: string, count: number = 1) {
return this.trim<T>(key, -count, count - 1);
}
// map operations
private async getMap<T = unknown>(map: string) {
const raw = await this.kv.get<Record<string, T>>(map, { raw: true });
if (raw) {
if (typeof raw.value !== 'object') {
throw new Error(
`Expect an Object keyed by ${map}, but found ${typeof raw}`
);
}
if (Array.isArray(raw.value)) {
throw new Error(`Expect an Object keyed by ${map}, but found an Array`);
}
}
return raw;
}
private async setMap<T = unknown>(
map: string,
value: Record<string, T>,
opts: CacheSetOptions = {}
) {
return this.kv.set(map, value, opts.ttl).then(() => true);
}
async mapGet<T = unknown>(map: string, key: string): Promise<T | undefined> {
const raw = await this.getMap<T>(map);
if (raw?.value) {
return raw.value[key];
}
return undefined;
}
async mapSet<T = unknown>(
map: string,
key: string,
value: T
): Promise<boolean> {
const raw = await this.getMap(map);
const data = raw?.value ?? {};
data[key] = value;
return this.setMap(map, data, {
ttl: raw?.expires ? raw.expires - Date.now() : undefined,
});
}
async mapDelete(map: string, key: string): Promise<boolean> {
const raw = await this.getMap(map);
if (raw?.value) {
delete raw.value[key];
return this.setMap(map, raw.value, {
ttl: raw.expires ? raw.expires - Date.now() : undefined,
});
}
return false;
}
async mapIncrease(
map: string,
key: string,
count: number = 1
): Promise<number> {
const prev = (await this.mapGet(map, key)) ?? 0;
if (typeof prev !== 'number') {
throw new Error(
`Expect a Number keyed by ${key}, but found ${typeof prev}`
);
}
const curr = prev + count;
return (await this.mapSet(map, key, curr)) ? curr : prev;
}
async mapDecrease(
map: string,
key: string,
count: number = 1
): Promise<number> {
return this.mapIncrease(map, key, -count);
}
async mapKeys(map: string): Promise<string[]> {
const raw = await this.getMap(map);
if (raw?.value) {
return Object.keys(raw.value);
}
return [];
}
async mapRandomKey(map: string): Promise<string | undefined> {
const keys = await this.mapKeys(map);
return keys[Math.floor(Math.random() * keys.length)];
}
async mapLen(map: string): Promise<number> {
const raw = await this.getMap(map);
return raw?.value ? Object.keys(raw.value).length : 0;
}
}

View File

@@ -0,0 +1,199 @@
import Redis from 'ioredis';
export interface CacheSetOptions {
/**
* in milliseconds
*/
ttl?: number;
}
export class CacheProvider {
constructor(private readonly redis: Redis) {}
// standard operation
async get<T = unknown>(key: string): Promise<T> {
return this.redis
.get(key)
.then(v => {
if (v) {
return JSON.parse(v);
}
return undefined;
})
.catch(() => undefined);
}
async set<T = unknown>(
key: string,
value: T,
opts: CacheSetOptions = {}
): Promise<boolean> {
if (opts.ttl) {
return this.redis
.set(key, JSON.stringify(value), 'PX', opts.ttl)
.then(() => true)
.catch(() => false);
}
return this.redis
.set(key, JSON.stringify(value))
.then(() => true)
.catch(() => false);
}
async increase(key: string, count: number = 1): Promise<number> {
return this.redis.incrby(key, count).catch(() => 0);
}
async decrease(key: string, count: number = 1): Promise<number> {
return this.redis.decrby(key, count).catch(() => 0);
}
async setnx<T = unknown>(
key: string,
value: T,
opts: CacheSetOptions = {}
): Promise<boolean> {
if (opts.ttl) {
return this.redis
.set(key, JSON.stringify(value), 'PX', opts.ttl, 'NX')
.then(v => !!v)
.catch(() => false);
}
return this.redis
.set(key, JSON.stringify(value), 'NX')
.then(v => !!v)
.catch(() => false);
}
async delete(key: string): Promise<boolean> {
return this.redis
.del(key)
.then(v => v > 0)
.catch(() => false);
}
async has(key: string): Promise<boolean> {
return this.redis
.exists(key)
.then(v => v > 0)
.catch(() => false);
}
async ttl(key: string): Promise<number> {
return this.redis.ttl(key).catch(() => 0);
}
async expire(key: string, ttl: number): Promise<boolean> {
return this.redis
.pexpire(key, ttl)
.then(v => v > 0)
.catch(() => false);
}
// list operations
async pushBack<T = unknown>(key: string, ...values: T[]): Promise<number> {
return this.redis
.rpush(key, ...values.map(v => JSON.stringify(v)))
.catch(() => 0);
}
async pushFront<T = unknown>(key: string, ...values: T[]): Promise<number> {
return this.redis
.lpush(key, ...values.map(v => JSON.stringify(v)))
.catch(() => 0);
}
async len(key: string): Promise<number> {
return this.redis.llen(key).catch(() => 0);
}
async list<T = unknown>(
key: string,
start: number,
end: number
): Promise<T[]> {
return this.redis
.lrange(key, start, end)
.then(data => data.map(v => JSON.parse(v)))
.catch(() => []);
}
async popFront<T = unknown>(key: string, count: number = 1): Promise<T[]> {
return this.redis
.lpop(key, count)
.then(data => (data ?? []).map(v => JSON.parse(v)))
.catch(() => []);
}
async popBack<T = unknown>(key: string, count: number = 1): Promise<T[]> {
return this.redis
.rpop(key, count)
.then(data => (data ?? []).map(v => JSON.parse(v)))
.catch(() => []);
}
// map operations
async mapSet<T = unknown>(
map: string,
key: string,
value: T
): Promise<boolean> {
return this.redis
.hset(map, key, JSON.stringify(value))
.then(v => v > 0)
.catch(() => false);
}
async mapIncrease(
map: string,
key: string,
count: number = 1
): Promise<number> {
return this.redis.hincrby(map, key, count);
}
async mapDecrease(
map: string,
key: string,
count: number = 1
): Promise<number> {
return this.redis.hincrby(map, key, -count);
}
async mapGet<T = unknown>(map: string, key: string): Promise<T | undefined> {
return this.redis
.hget(map, key)
.then(v => (v ? JSON.parse(v) : undefined))
.catch(() => undefined);
}
async mapDelete(map: string, key: string): Promise<boolean> {
return this.redis
.hdel(map, key)
.then(v => v > 0)
.catch(() => false);
}
async mapKeys(map: string): Promise<string[]> {
return this.redis.hkeys(map).catch(() => []);
}
async mapRandomKey(map: string): Promise<string | undefined> {
return this.redis
.hrandfield(map, 1)
.then(v =>
typeof v === 'string'
? v
: Array.isArray(v)
? (v[0] as string)
: undefined
)
.catch(() => undefined);
}
async mapLen(map: string): Promise<number> {
return this.redis.hlen(map).catch(() => 0);
}
}

View File

@@ -20,7 +20,7 @@ export * from './guard';
export { CryptoHelper, URLHelper } from './helpers';
export { MailService } from './mailer';
export { CallMetric, metrics } from './metrics';
export { type ILocker, Lock, Locker, Mutex, RequestMutex } from './mutex';
export { Lock, Locker, Mutex, RequestMutex } from './mutex';
export {
GatewayErrorWrapper,
getOptionalModuleMetadata,

View File

@@ -1,6 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { Locker } from './local-lock';
import { Locker } from './locker';
import { Mutex, RequestMutex } from './mutex';
@Global()
@@ -11,4 +11,4 @@ import { Mutex, RequestMutex } from './mutex';
export class MutexModule {}
export { Locker, Mutex, RequestMutex };
export { type Locker as ILocker, Lock } from './lock';
export { Lock } from './lock';

View File

@@ -1,28 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Cache } from '../cache';
import { Lock, Locker as ILocker } from './lock';
@Injectable()
export class Locker implements ILocker {
constructor(private readonly cache: Cache) {}
async lock(owner: string, key: string): Promise<Lock> {
const lockKey = `MutexLock:${key}`;
const prevOwner = await this.cache.get<string>(lockKey);
if (prevOwner && prevOwner !== owner) {
throw new Error(`Lock for resource [${key}] has been holder by others`);
}
const acquired = await this.cache.set(lockKey, owner);
if (acquired) {
return new Lock(async () => {
await this.cache.delete(lockKey);
});
}
throw new Error(`Failed to acquire lock for resource [${key}]`);
}
}

View File

@@ -17,7 +17,3 @@ export class Lock implements AsyncDisposable {
await this.release();
}
}
export interface Locker {
lock(owner: string, key: string): Promise<Lock>;
}

View File

@@ -0,0 +1,66 @@
import { Injectable, Logger } from '@nestjs/common';
import { Command } from 'ioredis';
import { SessionRedis } from '../redis';
import { Lock } from './lock';
// === atomic mutex lock ===
// acquire lock
// return 1 if lock is acquired
// return 0 if lock is not acquired
const lockScript = `local key = KEYS[1]
local owner = ARGV[1]
-- if lock is not exists or lock is owned by the owner
-- then set lock to the owner and return 1, otherwise return 0
-- if the lock is not released correctly due to unexpected reasons
-- lock will be released after 60 seconds
if redis.call("get", key) == owner or redis.call("set", key, owner, "NX", "EX", 60) then
return 1
else
return 0
end`;
// release lock
// return 1 if lock is released or lock is not exists
// return 0 if lock is not owned by the owner
const unlockScript = `local key = KEYS[1]
local owner = ARGV[1]
local value = redis.call("get", key)
if value == owner then
return redis.call("del", key)
elseif value == nil then
return 1
else
return 0
end`;
@Injectable()
export class Locker {
private readonly logger = new Logger(Locker.name);
constructor(private readonly redis: SessionRedis) {}
async lock(owner: string, key: string): Promise<Lock> {
const lockKey = `MutexLock:${key}`;
this.logger.verbose(`Client ${owner} is trying to lock resource ${key}`);
const success = await this.redis.sendCommand(
new Command('EVAL', [lockScript, '1', lockKey, owner])
);
if (success === 1) {
return new Lock(async () => {
const result = await this.redis.sendCommand(
new Command('EVAL', [unlockScript, '1', lockKey, owner])
);
if (result === 0) {
throw new Error(`Failed to release lock ${key}`);
}
});
}
throw new Error(`Failed to acquire lock for resource [${key}]`);
}
}

View File

@@ -6,7 +6,7 @@ import type { Request } from 'express';
import { GraphqlContext } from '../graphql';
import { retryable } from '../utils/promise';
import { Locker } from './local-lock';
import { Locker } from './locker';
export const MUTEX_RETRY = 5;
export const MUTEX_WAIT = 100;
@@ -26,7 +26,7 @@ export class Mutex {
* ```typescript
* {
* // lock is acquired here
* await using lock = await mutex.lock('resource-key');
* await using lock = await mutex.acquire('resource-key');
* if (lock) {
* // do something
* } else {
@@ -38,7 +38,7 @@ export class Mutex {
* @param key resource key
* @returns LockGuard
*/
async lock(key: string, owner: string = 'global') {
async acquire(key: string, owner: string = 'global') {
try {
return await retryable(
() => this.locker.lock(owner, key),
@@ -83,7 +83,7 @@ export class RequestMutex extends Mutex {
return id;
}
override lock(key: string) {
return super.lock(key, this.getId());
override acquire(key: string) {
return super.acquire(key, this.getId());
}
}

View File

@@ -0,0 +1,11 @@
import { RedisOptions } from 'ioredis';
import { defineStartupConfig, ModuleConfig } from '../../base/config';
declare module '../config' {
interface AppConfig {
redis: ModuleConfig<RedisOptions>;
}
}
defineStartupConfig('redis', {});

View File

@@ -0,0 +1,14 @@
import './config';
import { Global, Module } from '@nestjs/common';
import { CacheRedis, SessionRedis, SocketIoRedis } from './instances';
@Global()
@Module({
providers: [CacheRedis, SessionRedis, SocketIoRedis],
exports: [CacheRedis, SessionRedis, SocketIoRedis],
})
export class RedisModule {}
export { CacheRedis, SessionRedis, SocketIoRedis };

View File

@@ -0,0 +1,35 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { Redis as IORedis, RedisOptions } from 'ioredis';
import { Config } from '../../base/config';
class Redis extends IORedis implements OnModuleDestroy {
constructor(opts: RedisOptions) {
super(opts);
}
onModuleDestroy() {
this.disconnect();
}
}
@Injectable()
export class CacheRedis extends Redis {
constructor(config: Config) {
super(config.redis);
}
}
@Injectable()
export class SessionRedis extends Redis {
constructor(config: Config) {
super({ ...config.redis, db: (config.redis.db ?? 0) + 2 });
}
}
@Injectable()
export class SocketIoRedis extends Redis {
constructor(config: Config) {
super({ ...config.redis, db: (config.redis.db ?? 0) + 3 });
}
}

View File

@@ -0,0 +1,56 @@
import { INestApplication } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { Server } from 'socket.io';
import { Config } from '../config';
import { AuthenticationRequired } from '../error';
import { SocketIoRedis } from '../redis';
import { WEBSOCKET_OPTIONS } from './options';
export class SocketIoAdapter extends IoAdapter {
constructor(private readonly app: INestApplication) {
super(app);
}
override createIOServer(port: number, options?: any): Server {
const config = this.app.get(WEBSOCKET_OPTIONS) as Config['websocket'];
const server: Server = super.createIOServer(port, {
...config,
...options,
});
if (config.canActivate) {
server.use((socket, next) => {
// @ts-expect-error checked
config
.canActivate(socket)
.then(pass => {
if (pass) {
next();
} else {
throw new AuthenticationRequired();
}
})
.catch(e => {
next(e);
});
});
}
const pubClient = this.app.get(SocketIoRedis);
pubClient.on('error', err => {
console.error(err);
});
const subClient = pubClient.duplicate();
subClient.on('error', err => {
console.error(err);
});
server.adapter(createAdapter(pubClient, subClient));
return server;
}
}

View File

@@ -1,70 +1,14 @@
import './config';
import {
FactoryProvider,
INestApplicationContext,
Module,
Provider,
} from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { Server } from 'socket.io';
import { Module } from '@nestjs/common';
import { Config } from '../config';
import { AuthenticationRequired } from '../error';
export const SocketIoAdapterImpl = Symbol('SocketIoAdapterImpl');
export class SocketIoAdapter extends IoAdapter {
constructor(protected readonly app: INestApplicationContext) {
super(app);
}
override createIOServer(port: number, options?: any): Server {
const config = this.app.get(WEBSOCKET_OPTIONS) as Config['websocket'];
const server: Server = super.createIOServer(port, {
...config,
...options,
});
if (config.canActivate) {
server.use((socket, next) => {
// @ts-expect-error checked
config
.canActivate(socket)
.then(pass => {
if (pass) {
next();
} else {
throw new AuthenticationRequired();
}
})
.catch(e => {
next(e);
});
});
}
return server;
}
}
const SocketIoAdapterImplProvider: Provider = {
provide: SocketIoAdapterImpl,
useValue: SocketIoAdapter,
};
export const WEBSOCKET_OPTIONS = Symbol('WEBSOCKET_OPTIONS');
export const websocketOptionsProvider: FactoryProvider = {
provide: WEBSOCKET_OPTIONS,
useFactory: (config: Config) => {
return config.websocket;
},
inject: [Config],
};
import { WEBSOCKET_OPTIONS, websocketOptionsProvider } from './options';
@Module({
providers: [SocketIoAdapterImplProvider, websocketOptionsProvider],
exports: [SocketIoAdapterImplProvider, websocketOptionsProvider],
providers: [websocketOptionsProvider],
exports: [websocketOptionsProvider],
})
export class WebSocketModule {}
export { WEBSOCKET_OPTIONS };
export { SocketIoAdapter } from './adapter';

View File

@@ -0,0 +1,13 @@
import { FactoryProvider } from '@nestjs/common';
import { Config } from '../config';
export const WEBSOCKET_OPTIONS = Symbol('WEBSOCKET_OPTIONS');
export const websocketOptionsProvider: FactoryProvider = {
provide: WEBSOCKET_OPTIONS,
useFactory: (config: Config) => {
return config.websocket;
},
inject: [Config],
};