Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions app/api/core/infrastructure/redisCache/RedisCacheService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { CacheOptions, CacheService } from 'api/core/libs/cache/CacheService';
import { tenants } from 'api/tenants';
import { RedisClient } from 'redis';

type Deps = {
redisClient: RedisClient;
tenants: typeof tenants;
};

class RedisCacheService implements CacheService {
constructor(private deps: Deps) {}

private createKey(key: string): string {
const tenantName = this.deps.tenants.current().name;

return `tenant:${tenantName}:${key}`;
}

async get<T>(key: string): Promise<T | null> {
const redisKey = this.createKey(key);

return new Promise<T | null>((resolve, reject) => {
this.deps.redisClient.get(redisKey, (err, reply) => {
if (err) {
return reject(err);
}

if (reply) {
try {
const parsed: T = JSON.parse(reply);
return resolve(parsed);
} catch (parseErr) {
return reject(parseErr);
}
}

return resolve(null);
});
});
}

async set<T>(key: string, value: T, options?: CacheOptions): Promise<void> {
const redisKey = this.createKey(key);
const stringValue = JSON.stringify(value);

return new Promise<void>((resolve, reject) => {
if (options?.ttl) {
this.deps.redisClient.setex(redisKey, options.ttl, stringValue, err => {
if (err) {
reject(err);
return;
}

resolve();
});
} else {
this.deps.redisClient.set(redisKey, stringValue, err => {
if (err) {
reject(err);
return;
}
resolve();
});
}
});
}

async delete(key: string): Promise<void> {
const redisKey = this.createKey(key);

return new Promise<void>((resolve, reject) => {
this.deps.redisClient.del(redisKey, err => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}

async deletePattern(pattern: string): Promise<void> {
const redisPattern = this.createKey(pattern);

return new Promise<void>((resolve, reject) => {
this.deps.redisClient.keys(redisPattern, (err, keys) => {
if (err) {
reject(err);
return;
}

if (keys && keys.length > 0) {
// Delete all matching keys
this.deps.redisClient.del(...keys, delErr => {
if (delErr) {
reject(delErr);
return;
}
resolve();
});
} else {
resolve();
}
});
});
}
}

export { RedisCacheService };
29 changes: 29 additions & 0 deletions app/api/core/infrastructure/redisCache/specs/DAOWithDecorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Cached } from 'api/core/libs/cache/Decorators';
import { Db, ObjectId } from 'mongodb';
import { CacheService } from 'api/core/libs/cache/CacheService';
import { MongoDataSource } from '../../mongodb/common/MongoDataSource';
import { MongoTransactionManager } from '../../mongodb/common/MongoTransactionManager';

class DataAccessObjectWithDecorator extends MongoDataSource {
protected collectionName = 'entities';

constructor(
db: Db,
transactionManager: MongoTransactionManager,
private cache: CacheService
) {
super(db, transactionManager);
}

@Cached({ key: 'all_entities', ttl: 300 })
async getAll() {
return this.getCollection().find({}).toArray();
}

@Cached({ key: id => `entity_${id}`, ttl: 300 })
async getById(id: string) {
return this.getCollection().findOne({ _id: ObjectId.createFromHexString(id) });
}
}

export { DataAccessObjectWithDecorator };
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Db, ObjectId } from 'mongodb';
import { CacheService } from 'api/core/libs/cache/CacheService';
import { MongoDataSource } from '../../mongodb/common/MongoDataSource';
import { MongoTransactionManager } from '../../mongodb/common/MongoTransactionManager';

class DataSourceWithService extends MongoDataSource {
protected collectionName = 'entities';

constructor(
db: Db,
transactionManager: MongoTransactionManager,
private cache: CacheService
) {
super(db, transactionManager);
}

async getAll() {
const cache = await this.cache.get('all_entities');
if (cache) {
return cache;
}

const dbo = await this.getCollection().find({}).toArray();
await this.cache.set('all_entities', dbo);
}

async getById(id: string) {
const cache = await this.cache.get(`entity_${id}`);
if (cache) {
return cache;
}

const dbo = await this.getCollection().findOne({ _id: ObjectId.createFromHexString(id) });
await this.cache.set(`entity_${id}`, dbo);

return dbo;
}
}

export { DataSourceWithService };
101 changes: 101 additions & 0 deletions app/api/core/infrastructure/redisCache/specs/Usage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { getFixturesFactory } from 'api/utils/fixturesFactory';
import { DBFixture } from 'api/utils/testing_db';
import { testingEnvironment } from 'api/utils/testingEnvironment';
import { RedisClient } from 'redis';
import { tenants } from 'api/tenants';
import { DataAccessObjectWithDecorator } from './DAOWithDecorator';
import { getConnection } from '../../mongodb/common/getConnectionForCurrentTenant';
import { TransactionManagerFactory } from '../../factories/TransactionManagerFactory';
import { RedisCacheService } from '../RedisCacheService';
import { DataSourceWithService } from './DataSourceWithService';

const factory = getFixturesFactory();

const fixtures: DBFixture = {
templates: Array.from({ length: 20_000 }).map((_, index) =>
factory.template(`template_${index + 1}`)
),
};

describe('RedisCache Usage Specs', () => {
let redisClient: RedisClient;
let cacheService: RedisCacheService;
let dao: DataAccessObjectWithDecorator;
let ds: DataSourceWithService;

beforeAll(async () => {
redisClient = new RedisClient({ host: 'localhost', port: 6379 });
redisClient.on('error', console.error);

await testingEnvironment.setUp(fixtures);

cacheService = new RedisCacheService({ redisClient, tenants });
dao = new DataAccessObjectWithDecorator(
getConnection(),
TransactionManagerFactory.default(),
cacheService
);
ds = new DataSourceWithService(
getConnection(),
TransactionManagerFactory.default(),
cacheService
);
});

afterEach(async () => {
await new Promise(resolve => {
redisClient.flushdb(resolve);
});
});

afterAll(async () => {
await testingEnvironment.tearDown();
await new Promise(resolve => {
redisClient.quit(resolve);
});
});

describe('Decorators', () => {
it('getAll()', async () => {
console.time('getAll()');
await dao.getAll(); // First call, should fetch from DB and cache
console.timeEnd('getAll()');

console.time('getAll() - cached');
await dao.getAll(); // Second call, should fetch from cache
console.timeEnd('getAll() - cached');
});

it('getById()', async () => {
console.time('getById()');
await dao.getById(factory.id('template_1').toString()); // First call, should fetch from DB and cache
console.timeEnd('getById()');

console.time('getById() - cached');
await dao.getById(factory.id('template_1').toString()); // Second call, should fetch from cache
console.timeEnd('Second call');
});
});

describe('Using service directly', () => {
it('getAll()', async () => {
console.time('getAll()');
await ds.getAll(); // First call, should fetch from DB and cache
console.timeEnd('getAll()');

console.time('getAll() - cached');
await ds.getAll(); // Second call, should fetch from cache
console.timeEnd('getAll() - cached');
});

it('getById()', async () => {
console.time('getById()');
await ds.getById(factory.id('template_1').toString()); // First call, should fetch from DB and cache
console.timeEnd('getById()');

console.time('getById() - cached');
await ds.getById(factory.id('template_1').toString()); // Second call, should fetch from cache
console.timeEnd('getById() - cached');
});
});
});
12 changes: 12 additions & 0 deletions app/api/core/libs/cache/CacheService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
interface CacheOptions {
ttl?: number; // in seconds
}

interface CacheService {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, options?: CacheOptions): Promise<void>;
delete(key: string): Promise<void>;
deletePattern(pattern: string): Promise<void>;
}

export type { CacheService, CacheOptions };
60 changes: 60 additions & 0 deletions app/api/core/libs/cache/Decorators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ArrayUtils } from 'api/common.v2/utils/Array';
import { CacheOptions, CacheService } from './CacheService';

type KeyGenerator = (...args: any[]) => string;
type Key = string | KeyGenerator;

type InvalidateInput = Key[];

type CachedProps = {
key: Key;
} & CacheOptions;

function Cached(options: CachedProps) {
return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;

descriptor.value = async function (...args: any[]) {
const cacheService = (this as any)?.cache as CacheService | undefined;
if (!cacheService) return original.apply(this, args);

const key = typeof options.key === 'function' ? options.key(...args) : options.key;

const cached = await cacheService.get(key);
if (cached !== null && cached !== undefined) return cached;

const result = await original.apply(this, args);
await cacheService.set(key, result, options);

return result;
};
return descriptor;
};
}

function Invalidate(keys: InvalidateInput) {
return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = async function (...args: any[]) {
const result = await original.apply(this, args);

const cacheService = (this as any).cache as CacheService | undefined;
if (!cacheService) return result;

await ArrayUtils.sequentialFor(keys, async k => {
const key = typeof k === 'function' ? k(...args) : k;

if (key.includes('*')) {
await cacheService.deletePattern(key);
} else {
await cacheService.delete(key);
}
});

return result;
};
return descriptor;
};
}

export { Cached, Invalidate };
1 change: 1 addition & 0 deletions babel.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
}
},
"plugins": [
["@babel/plugin-proposal-decorators", { "version": "legacy" }],
"@babel/plugin-proposal-object-rest-spread",
"@babel/plugin-proposal-class-properties",
"@babel/plugin-syntax-dynamic-import",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@
"@babel/helper-string-parser": "^7.27.1",
"@babel/parser": "^7.26.5",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
"@babel/plugin-proposal-object-rest-spread": "^7.20.7",
"@babel/plugin-proposal-optional-chaining": "^7.21.0",
Expand Down
Loading
Loading