mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
281b778b2d
* fix: Suspended users should not be included in cached member count for groups * fix: Defer CounterCache hook registration until model is initialized The previous test-only no-op hid a timing bug where setImmediate could fire before the Sequelize instance had registered the related model, causing "Model not initialized" failures. Poll until the model is ready, and unref the pending immediate so it does not keep the event loop alive in environments where the database is never initialized. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * perf: Reduce overhead of group member count invalidation Select only the groupId column with raw queries and de-duplicate before issuing Redis deletes, avoiding loading full GroupUser rows into memory when a user belongs to many groups. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: unref Redis healthcheck interval Don't keep the Node event loop alive solely for the periodic ping; the event loop should drain on its own when the application is shutting down or a Jest worker is finishing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor: Centralize counter cache key in RedisPrefixHelper Avoid duplicating the "count:<Model>:<relation>:<id>" string between the CounterCache decorator and the User suspension hook by routing both through a single getCounterCacheKey helper. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: Walk to parent transaction when scheduling cache invalidation Nested savepoints commit independently of their outer transaction, so afterCommit callbacks attached to the inner transaction may run after the outer rolls back, or never run at all. Match the pattern used in Collection, Event, and base/Model and walk to the parent transaction so the cache invalidation fires after the real outer commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
157 lines
4.5 KiB
TypeScript
157 lines
4.5 KiB
TypeScript
import { Day } from "@shared/utils/time";
|
|
import Logger from "@server/logging/Logger";
|
|
import Redis from "@server/storage/redis";
|
|
import { MutexLock } from "./MutexLock";
|
|
|
|
/**
|
|
* Result type for cache callbacks that need to specify a dynamic expiry.
|
|
*/
|
|
export interface CacheResult<T> {
|
|
/** The data to cache. */
|
|
data: T;
|
|
/** Cache expiry in seconds. If not provided, uses the default expiry passed to getDataOrSet. */
|
|
expiry?: number;
|
|
}
|
|
|
|
/**
|
|
* A Helper class for server-side cache management
|
|
*/
|
|
export class CacheHelper {
|
|
// Default expiry time for cache data in seconds
|
|
private static defaultDataExpiry = Day.seconds;
|
|
|
|
/**
|
|
* Given a key this method will attempt to get the data from cache store first
|
|
* If data is not found, it will call the callback to get the data and save it in cache
|
|
* using a distributed lock to prevent multiple writes.
|
|
*
|
|
* The callback can return either:
|
|
* - A plain value of type T (uses the default expiry)
|
|
* - A CacheResult<T> object with { data, expiry } for dynamic expiry
|
|
*
|
|
* @param key Cache key
|
|
* @param callback Callback to get the data if not found in cache
|
|
* @param expiry Default cache data expiry in seconds
|
|
* @param lockTimeout Lock timeout in milliseconds
|
|
* @returns The data from cache or the result of the callback
|
|
*/
|
|
public static async getDataOrSet<T>(
|
|
key: string,
|
|
callback: () => Promise<T | CacheResult<T> | undefined>,
|
|
expiry: number,
|
|
lockTimeout: number = MutexLock.defaultLockTimeout
|
|
): Promise<T | undefined> {
|
|
let cache = await this.getData<T>(key);
|
|
|
|
if (cache) {
|
|
return cache;
|
|
}
|
|
|
|
// Nothing in the cache, acquire a lock to prevent multiple writes
|
|
let lock;
|
|
const lockKey = `lock:${key}`;
|
|
try {
|
|
try {
|
|
lock = await MutexLock.acquire(lockKey, lockTimeout);
|
|
} catch (err) {
|
|
Logger.error(`Could not acquire lock for ${key}`, err);
|
|
}
|
|
cache = await this.getData<T>(key);
|
|
if (cache) {
|
|
return cache;
|
|
}
|
|
|
|
// Get the data from the callback and save it in cache
|
|
const result = await callback();
|
|
if (result) {
|
|
// Check if result is a CacheResult with dynamic expiry
|
|
const isCacheResult =
|
|
typeof result === "object" &&
|
|
"data" in result &&
|
|
Object.keys(result).every((k) => k === "data" || k === "expiry");
|
|
|
|
if (isCacheResult) {
|
|
const { data, expiry: dynamicExpiry } = result as CacheResult<T>;
|
|
await this.setData<T>(key, data, dynamicExpiry ?? expiry);
|
|
return data;
|
|
}
|
|
|
|
await this.setData<T>(key, result as T, expiry);
|
|
return result as T;
|
|
}
|
|
return undefined;
|
|
} finally {
|
|
if (lock) {
|
|
await MutexLock.release(lock);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Given a key, gets the data from cache store
|
|
*
|
|
* @param key Key against which data will be accessed
|
|
*/
|
|
public static async getData<T>(key: string): Promise<T | undefined> {
|
|
try {
|
|
const data = await Redis.defaultClient.get(key);
|
|
if (data !== null) {
|
|
return JSON.parse(data);
|
|
}
|
|
} catch (err) {
|
|
// just log it, response can still be obtained using the fetch call
|
|
Logger.error(`Could not fetch cached response against ${key}`, err);
|
|
}
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Given a key, data and cache config, saves the data in cache store
|
|
*
|
|
* @param key Cache key
|
|
* @param data Data to be saved against the key
|
|
* @param expiry Cache data expiry in seconds
|
|
*/
|
|
public static async setData<T>(key: string, data: T, expiry?: number) {
|
|
try {
|
|
await Redis.defaultClient.set(
|
|
key,
|
|
JSON.stringify(data),
|
|
"EX",
|
|
expiry || CacheHelper.defaultDataExpiry
|
|
);
|
|
} catch (err) {
|
|
// just log it, can skip caching and directly return response
|
|
Logger.error(`Could not cache response against ${key}`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a single cached entry by key.
|
|
*
|
|
* @param key Cache key to remove.
|
|
*/
|
|
public static async removeData(key: string) {
|
|
try {
|
|
await Redis.defaultClient.del(key);
|
|
} catch (err) {
|
|
Logger.error(`Could not remove cached entry against ${key}`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears all cache data with the given prefix
|
|
*
|
|
* @param prefix Prefix to clear cache data
|
|
*/
|
|
public static async clearData(prefix: string) {
|
|
const keys = await Redis.defaultClient.keys(`${prefix}*`);
|
|
|
|
await Promise.all(
|
|
keys.map(async (key) => {
|
|
await Redis.defaultClient.del(key);
|
|
})
|
|
);
|
|
}
|
|
}
|