diff --git a/tdrive/backend/node/src/core/platform/services/database/services/orm/repository/caching-repository.ts b/tdrive/backend/node/src/core/platform/services/database/services/orm/repository/caching-repository.ts new file mode 100644 index 000000000..9ec85de1e --- /dev/null +++ b/tdrive/backend/node/src/core/platform/services/database/services/orm/repository/caching-repository.ts @@ -0,0 +1,139 @@ +import Repository, { AtomicCompareAndSetResult, FindFilter, FindOptions } from "./repository"; +import { logger } from "../../../../../../../core/platform/framework"; +import { ExecutionContext } from "../../../../../framework/api/crud-service"; +import { Connector } from "../connectors"; +import { EntityTarget } from "../types"; +import NodeCache from "node-cache"; + +const emptyStats = () => ({ hits: 0, misses: 0, wrongIndex: 0, start: new Date() }); +const CACHE_DEFAULT_TTL_S = 5; +const CACHE_DEFAULT_MAX_KEY_COUNT = 10000; +const CACHE_PRINT_PERIOD_MS = 20 * 1000; +const CACHE_PRINT_UPPER_THRESHOLD_MS = 5 * 60 * 1000; + +/** + * This is a passthrough for {@link Repository} that caches requests by a provided + * `keys` list of fields that must be globally unique. + * Only {@link Repository.findOne} returns from cache. + */ +export default class CachingRepository extends Repository { + private readonly cache; + private cacheStats = emptyStats(); + + private startPrintingStats() { + setInterval(() => { + const stats = this.cacheStats; + const ageMs = new Date().getTime() - stats.start.getTime(); + const prefix = `CachingRepository<${this.table}>(${this.keys.join(", ")})`; + if (stats.hits + stats.misses + stats.wrongIndex === 0) { + if (ageMs < CACHE_PRINT_UPPER_THRESHOLD_MS) return; + logger.info(`${prefix} - unused since ${ageMs / 1000}s`); + return; + } + const libCacheStats = this.cache.getStats(); + this.cacheStats = emptyStats(); + logger.info( + { + stats: { + keyCount: libCacheStats.keys, + valueSize: libCacheStats.vsize, + ...stats, + }, + keys: this.keys, + table: this.table, + ageMs, + }, + `${prefix} had ${stats.hits} hits and ${stats.misses} misses (${ + stats.wrongIndex + } mismatched key query) in ${ageMs / 1000}s`, + ); + }, CACHE_PRINT_PERIOD_MS); + } + + constructor( + connector: Connector, + table: string, + entityType: EntityTarget, + private readonly keys: string[], + cacheOptions?: ConstructorParameters[0], + ) { + super(connector, table, entityType); + this.cache = new NodeCache({ + stdTTL: CACHE_DEFAULT_TTL_S, + maxKeys: CACHE_DEFAULT_MAX_KEY_COUNT, + ...cacheOptions, + }); + this.keys.sort(); + this.startPrintingStats(); + } + + private cacheFetEntityKey(entity: EntityType | FindFilter | undefined): string | undefined { + if (!entity) return undefined; + const indices = this.keys.map(k => entity[k] as string); + if (indices.some(x => !x)) return undefined; + return indices.map(x => encodeURIComponent(x)).join("&"); + } + + private cacheInvalidateEntity(entity: EntityType | undefined) { + const key = this.cacheFetEntityKey(entity); + if (key) this.cache.del(key); + } + + private cacheGet(keys: FindFilter): EntityType | undefined { + const key = this.cacheFetEntityKey(keys); + if (!key) { + this.cacheStats.wrongIndex++; + return undefined; + } + const cached = this.cache.get(key); + if (cached) { + this.cacheStats.hits++; + return cached; + } + this.cacheStats.misses++; + return undefined; + } + + private cacheSave(entity: EntityType) { + const key = entity && this.cacheFetEntityKey(entity); + if (!key) return; + this.cache.set(key, entity); + } + + override async atomicCompareAndSet( + entity: EntityType, + fieldName: keyof EntityType, + previousValue: FieldValueType | null, + newValue: FieldValueType | null, + ): Promise> { + this.cacheInvalidateEntity(entity); + return await super.atomicCompareAndSet(entity, fieldName, previousValue, newValue); + } + + override async save(entity: EntityType, _context?: ExecutionContext): Promise { + this.cacheInvalidateEntity(entity); + await super.save(entity, _context); + } + + override async saveAll(entities: EntityType[] = [], _context?: ExecutionContext): Promise { + entities.forEach(entity => this.cacheInvalidateEntity(entity)); + await super.saveAll(entities, _context); + } + + override async remove(entity: EntityType, _context?: ExecutionContext): Promise { + this.cacheInvalidateEntity(entity); + await super.remove(entity, _context); + } + + async findOne( + filters: FindFilter, + options: FindOptions = {}, + context?: ExecutionContext, + ): Promise { + const cachedValue = this.cacheGet(filters); + if (cachedValue) return cachedValue; + const result = (await this.find(filters, options, context)).getEntities()[0] || null; + this.cacheSave(result); + return result; + } +} diff --git a/tdrive/backend/node/src/core/platform/services/database/services/orm/repository/manager.ts b/tdrive/backend/node/src/core/platform/services/database/services/orm/repository/manager.ts index d5ccde570..d3abe3946 100644 --- a/tdrive/backend/node/src/core/platform/services/database/services/orm/repository/manager.ts +++ b/tdrive/backend/node/src/core/platform/services/database/services/orm/repository/manager.ts @@ -1,9 +1,15 @@ import { logger } from "../../../../../../../core/platform/framework"; import DatabaseService from "../.."; import Repository from "./repository"; +import CachingRepository from "./caching-repository"; import { EntityTarget } from "../types"; export class RepositoryManager { + private static toCacheEntities = new Map(); + /** When an entity is called with a key, registry instances from `getRepository` will be {@link CachingRepository} */ + public static registerEntityToCacheRegistryBy(table: string, keys: string[]) { + this.toCacheEntities.set(table, keys); + } // eslint-disable-next-line @typescript-eslint/no-explicit-any private repositories: Map> = new Map>(); @@ -14,7 +20,15 @@ export class RepositoryManager { entity: EntityTarget, ): Promise> { if (!this.repositories.has(table)) { - const repository = new Repository(this.databaseService.getConnector(), table, entity); + const cacheKeys = RepositoryManager.toCacheEntities.get(table); + const repository = cacheKeys + ? new CachingRepository( + this.databaseService.getConnector(), + table, + entity, + cacheKeys, + ) + : new Repository(this.databaseService.getConnector(), table, entity); try { await repository.init(); diff --git a/tdrive/backend/node/src/services/documents/entities/drive-file.ts b/tdrive/backend/node/src/services/documents/entities/drive-file.ts index 5a793abc6..fa5a170aa 100644 --- a/tdrive/backend/node/src/services/documents/entities/drive-file.ts +++ b/tdrive/backend/node/src/services/documents/entities/drive-file.ts @@ -5,9 +5,13 @@ import { DriveFileAccessLevel, publicAccessLevel } from "../types"; import { FileVersion } from "./file-version"; import search from "./drive-file.search"; import * as UUIDTools from "../../../utils/uuid"; +import { RepositoryManager } from "../../../core/platform/services/database/services/orm/repository/manager"; export const TYPE = "drive_files"; export type DriveScope = "personal" | "shared"; + +RepositoryManager.registerEntityToCacheRegistryBy(TYPE, ["id"]); + /** * This represents an item in the file hierarchy. * diff --git a/tdrive/backend/node/src/services/user/entities/company_user.ts b/tdrive/backend/node/src/services/user/entities/company_user.ts index b09c95dd6..9182f2b35 100644 --- a/tdrive/backend/node/src/services/user/entities/company_user.ts +++ b/tdrive/backend/node/src/services/user/entities/company_user.ts @@ -1,9 +1,12 @@ import { merge } from "lodash"; import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators"; import { CompanyUserRole } from "../web/types"; +import { RepositoryManager } from "../../../core/platform/services/database/services/orm/repository/manager"; export const TYPE = "group_user"; +RepositoryManager.registerEntityToCacheRegistryBy(TYPE, ["group_id", "user_id"]); + /** * Link between a company and a user */ diff --git a/tdrive/backend/node/src/services/user/entities/user.ts b/tdrive/backend/node/src/services/user/entities/user.ts index fa1acc8c8..1173966db 100644 --- a/tdrive/backend/node/src/services/user/entities/user.ts +++ b/tdrive/backend/node/src/services/user/entities/user.ts @@ -2,10 +2,13 @@ import { isNumber, merge } from "lodash"; import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators"; import search from "./user.search"; import { uuid } from "../../../utils/types"; +import { RepositoryManager } from "../../../core/platform/services/database/services/orm/repository/manager"; export const TYPE = "user"; export type UserType = "anonymous" | "tech" | "regular"; +RepositoryManager.registerEntityToCacheRegistryBy(TYPE, ["id"]); + @Entity(TYPE, { primaryKey: [["id"]], globalIndexes: [["email_canonical"], ["username_canonical"]],