diff options
Diffstat (limited to 'src/util/cache')
-rw-r--r-- | src/util/cache/Cache.ts | 85 | ||||
-rw-r--r-- | src/util/cache/EntityCache.ts | 160 | ||||
-rw-r--r-- | src/util/cache/LocalCache.ts | 91 | ||||
-rw-r--r-- | src/util/cache/index.ts | 3 |
4 files changed, 339 insertions, 0 deletions
diff --git a/src/util/cache/Cache.ts b/src/util/cache/Cache.ts new file mode 100644 index 00000000..fb66c2e3 --- /dev/null +++ b/src/util/cache/Cache.ts @@ -0,0 +1,85 @@ +/* + Fosscord: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Fosscord and Fosscord Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { EntityMetadata, FindOptionsWhere } from "typeorm"; +import { LocalCache } from "./LocalCache"; + +declare module "typeorm" { + interface BaseEntity { + metadata?: EntityMetadata; + cache: CacheManager; + } +} + +export type BaseEntityWithId = { id: string; [name: string]: any }; +export type Criteria = + | string + | string[] + | number + | number[] + | FindOptionsWhere<never>; + +export interface Cache { + get(id: string): BaseEntityWithId | undefined; + set(id: string, entity: BaseEntityWithId): this; + find(options: Record<string, never>): BaseEntityWithId | undefined; + filter(options: Record<string, never>): BaseEntityWithId[]; + delete(id: string): boolean; +} + +export class CacheManager { + // last access time to automatically remove old entities from cache after 5 minutes of inactivity (to prevent memory leaks) + cache: Cache; + + constructor() { + this.cache = new LocalCache(); + // TODO: Config.get().cache.redis; + } + + delete(id: string) { + return this.cache.delete(id); + } + + insert(entity: BaseEntityWithId) { + if (!entity.id) return; + + return this.cache.set(entity.id, entity); + } + + find(options?: Record<string, never>, select?: string[] | undefined) { + if (!options) return null; + const entity = this.cache.find(options); + if (!entity) return null; + if (!select) return entity; + + const result = {}; + for (const prop of select) { + // @ts-ignore + result[prop] = entity[prop]; + } + + // @ts-ignore + return entity.constructor.create(result); + } + + filter(options: Record<string, never>) { + return this.cache.filter(options); + } +} diff --git a/src/util/cache/EntityCache.ts b/src/util/cache/EntityCache.ts new file mode 100644 index 00000000..9135fef3 --- /dev/null +++ b/src/util/cache/EntityCache.ts @@ -0,0 +1,160 @@ +/* + Fosscord: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Fosscord and Fosscord Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ +/* eslint-disable */ +import { + DataSource, + FindOneOptions, + EntityNotFoundError, + FindOptionsWhere, +} from "typeorm"; +import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; +import { BaseClassWithId } from "../entities/BaseClass"; +import { Config, getDatabase } from "../util"; +import { CacheManager } from "./Cache"; + +function getObjectKeysAsArray(obj?: Record<string, any>) { + if (!obj) return []; + if (Array.isArray(obj)) return obj; + return Object.keys(obj); +} + +export type ThisType<T> = { + new (): T; +} & typeof BaseEntityCache; + +interface BaseEntityCache { + constructor: typeof BaseEntityCache; +} + +// @ts-ignore +class BaseEntityCache extends BaseClassWithId { + static cache: CacheManager; + static cacheEnabled: boolean; + + public get metadata() { + return getDatabase()?.getMetadata(this.constructor)!; + } + + static useDataSource(dataSource: DataSource | null) { + super.useDataSource(dataSource); + this.cacheEnabled = Config.get().cache.enabled ?? true; + if (Config.get().cache.redis) return; // TODO: Redis cache + if (!this.cacheEnabled) return; + this.cache = new CacheManager(); + } + + static async findOne<T extends BaseEntityCache>( + this: ThisType<T>, + options: FindOneOptions<T>, + ) { + // @ts-ignore + if (!this.cacheEnabled) return super.findOne(options); + let select = getObjectKeysAsArray(options.select); + + if (!select.length) { + // get all columns that are marked as select + getDatabase() + ?.getMetadata(this) + .columns.forEach((x) => { + if (!x.isSelect) return; + select.push(x.propertyName); + }); + } + if (options.relations) { + select.push(...getObjectKeysAsArray(options.relations)); + } + + const cacheResult = this.cache.find(options.where as never, select); + if (cacheResult) { + const hasAllProps = select.every((key) => { + if (key.includes(".")) return true; // @ts-ignore + return cacheResult[key] !== undefined; + }); + // console.log(`[Cache] get ${cacheResult.id} from ${cacheResult.constructor.name}`,); + if (hasAllProps) return cacheResult; + } + + // @ts-ignore + const result = await super.findOne<T>(options); + if (!result) return null; + + this.cache.insert(result as any); + + return result; + } + + static async findOneOrFail<T extends BaseEntityCache>( + this: ThisType<T>, + options: FindOneOptions<T>, + ) { + const result = await this.findOne<T>(options); + if (!result) throw new EntityNotFoundError(this, options); + return result; + } + + save() { + if (this.constructor.cacheEnabled) this.constructor.cache.insert(this); + return super.save(); + } + + remove() { + if (this.constructor.cacheEnabled) + this.constructor.cache.delete(this.id); + return super.remove(); + } + + static async update<T extends BaseEntityCache>( + this: ThisType<T>, + criteria: FindOptionsWhere<T>, + partialEntity: QueryDeepPartialEntity<T>, + ) { + // @ts-ignore + const result = super.update<T>(criteria, partialEntity); + if (!this.cacheEnabled) return result; + + const entities = this.cache.filter(criteria as never); + for (const entity of entities) { + // @ts-ignore + partialEntity.id = entity.id; + this.cache.insert(partialEntity as never); + } + + return result; + } + + static async delete<T extends BaseEntityCache>( + this: ThisType<T>, + criteria: FindOptionsWhere<T>, + ) { + // @ts-ignore + const result = super.delete<T>(criteria); + if (!this.cacheEnabled) return result; + + const entities = this.cache.filter(criteria as never); + for (const entity of entities) { + this.cache.delete(entity.id); + } + + return result; + } +} + +// needed, because typescript can't infer the type of the static methods with generics +const EntityCache = BaseEntityCache as unknown as typeof BaseClassWithId; + +export { EntityCache }; diff --git a/src/util/cache/LocalCache.ts b/src/util/cache/LocalCache.ts new file mode 100644 index 00000000..547899ac --- /dev/null +++ b/src/util/cache/LocalCache.ts @@ -0,0 +1,91 @@ +/* + Fosscord: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Fosscord and Fosscord Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { BaseEntityWithId, Cache } from "./Cache"; + +export const cacheTimeout = 1000 * 60 * 5; + +export class LocalCache extends Map<string, BaseEntityWithId> implements Cache { + last_access = new Map<string, number>(); + + constructor() { + super(); + + setInterval(() => { + const now = Date.now(); + for (const [key, value] of this.last_access) { + if (now - value > cacheTimeout) { + this.delete(key); + this.last_access.delete(key); + } + } + }, cacheTimeout); + } + + set(key: string, value: BaseEntityWithId): this { + this.last_access.set(key, Date.now()); + if (this.has(key)) this.update(key, value); + return super.set(key, value as never); + } + + get(key: string) { + const value = super.get(key); + if (value) this.last_access.set(key, Date.now()); + return value; + } + + update(id: string, entity: BaseEntityWithId) { + const oldEntity = this.get(id); + if (!oldEntity) return; + for (const key in entity) { + // @ts-ignore + if (entity[key] === undefined) continue; // @ts-ignore + oldEntity[key] = entity[key]; + } + } + + find(options: Record<string, never>): BaseEntityWithId | undefined { + if (options.id && Object.keys(options).length === 1) { + return this.get(options.id); + } + for (const entity of this.values()) { + if (objectFulfillsQuery(entity, options)) return entity; + } + } + + filter(options: Record<string, never>): BaseEntityWithId[] { + const result = []; + for (const entity of this.values()) { + if (objectFulfillsQuery(entity, options)) { + result.push(entity); + } + } + return result; + } +} + +function objectFulfillsQuery( + entity: BaseEntityWithId, + options: Record<string, never>, +) { + for (const key in options) { + // @ts-ignore + if (entity[key] !== options[key]) return false; + } + return true; +} diff --git a/src/util/cache/index.ts b/src/util/cache/index.ts new file mode 100644 index 00000000..43f0aa96 --- /dev/null +++ b/src/util/cache/index.ts @@ -0,0 +1,3 @@ +export * from "./EntityCache"; +export * from "./Cache"; +export * from "./LocalCache"; |