Skip to content

Commit

Permalink
⚡️🍻 back: stupid cache for drive-file, user and {group,company}_user
Browse files Browse the repository at this point in the history
  • Loading branch information
ericlinagora committed Nov 10, 2024
1 parent 34743e9 commit 6f91b7e
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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<EntityType> extends Repository<EntityType> {
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;
}
this.cacheStats = emptyStats();
logger.info(
{
stats: { ...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<EntityType>,
private readonly keys: string[],
cacheOptions?: ConstructorParameters<typeof NodeCache>[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<FieldValueType>(
entity: EntityType,
fieldName: keyof EntityType,
previousValue: FieldValueType | null,
newValue: FieldValueType | null,
): Promise<AtomicCompareAndSetResult<FieldValueType>> {
this.cacheInvalidateEntity(entity);
return await super.atomicCompareAndSet(entity, fieldName, previousValue, newValue);
}

override async save(entity: EntityType, _context?: ExecutionContext): Promise<void> {
this.cacheInvalidateEntity(entity);
await super.save(entity, _context);
}

override async saveAll(entities: EntityType[] = [], _context?: ExecutionContext): Promise<void> {
entities.forEach(entity => this.cacheInvalidateEntity(entity));
await super.saveAll(entities, _context);
}

override async remove(entity: EntityType, _context?: ExecutionContext): Promise<void> {
this.cacheInvalidateEntity(entity);
await super.remove(entity, _context);
}

async findOne(
filters: FindFilter,
options: FindOptions = {},
context?: ExecutionContext,
): Promise<EntityType> {
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;
}
}
Original file line number Diff line number Diff line change
@@ -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<string, string[]>();
/** 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<string, Repository<any>> = new Map<string, Repository<any>>();

Expand All @@ -14,7 +20,15 @@ export class RepositoryManager {
entity: EntityTarget<Entity>,
): Promise<Repository<Entity>> {
if (!this.repositories.has(table)) {
const repository = new Repository<Entity>(this.databaseService.getConnector(), table, entity);
const cacheKeys = RepositoryManager.toCacheEntities.get(table);
const repository = cacheKeys
? new CachingRepository<Entity>(
this.databaseService.getConnector(),
table,
entity,
cacheKeys,
)
: new Repository<Entity>(this.databaseService.getConnector(), table, entity);

try {
await repository.init();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down
3 changes: 3 additions & 0 deletions tdrive/backend/node/src/services/user/entities/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]],
Expand Down

0 comments on commit 6f91b7e

Please sign in to comment.