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>
109 lines
3.8 KiB
TypeScript
109 lines
3.8 KiB
TypeScript
import isNil from "lodash/isNil";
|
|
import type {
|
|
IncludeOptions,
|
|
InferAttributes,
|
|
Transaction,
|
|
WhereOptions,
|
|
} from "sequelize";
|
|
import type { ModelClassGetter } from "sequelize-typescript";
|
|
import { CacheHelper } from "@server/utils/CacheHelper";
|
|
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
|
|
import type Model from "../base/Model";
|
|
|
|
type RelationOptions = {
|
|
/** Reference name used in cache key. */
|
|
as: string;
|
|
/** The foreign key to use for the relationship query. */
|
|
foreignKey: string;
|
|
/** Optional include used in the count query for filtering through associations. */
|
|
include?: IncludeOptions[];
|
|
/** Optional additional where clause used in the count query. */
|
|
where?: WhereOptions;
|
|
};
|
|
|
|
/**
|
|
* A decorator that caches the count of a relationship and registers model lifecycle hooks
|
|
* to invalidate the cache when models are added or removed from the relationship.
|
|
*/
|
|
export function CounterCache<
|
|
TCreationAttributes extends InferAttributes<Model>,
|
|
TModelAttributes extends InferAttributes<Model>,
|
|
T extends typeof Model,
|
|
>(
|
|
classResolver: ModelClassGetter<TCreationAttributes, TModelAttributes>,
|
|
options: RelationOptions
|
|
) {
|
|
return function (target: InstanceType<T>, _propertyKey: string) {
|
|
const modelClass = classResolver() as typeof Model;
|
|
const modelName = target.constructor.name;
|
|
|
|
const buildCacheKey = (id: unknown) =>
|
|
RedisPrefixHelper.getCounterCacheKey(modelName, options.as, String(id));
|
|
|
|
const computeCount = (id: unknown) =>
|
|
modelClass.count({
|
|
where: { [options.foreignKey]: id, ...(options.where ?? {}) },
|
|
include: options.include,
|
|
distinct: !!options.include,
|
|
});
|
|
|
|
const invalidate = async (
|
|
model: InstanceType<T>,
|
|
hookOptions?: { transaction?: Transaction | null }
|
|
) => {
|
|
const cacheKey = buildCacheKey(
|
|
model[options.foreignKey as keyof typeof model]
|
|
);
|
|
const remove = async () => {
|
|
await CacheHelper.removeData(cacheKey);
|
|
};
|
|
|
|
// Defer invalidation until after the transaction commits so that a
|
|
// rollback does not leave the cache out of sync, and so that a stale
|
|
// pre-commit count is not re-cached by a concurrent reader. Walk to
|
|
// the parent transaction when nested so the callback isn't lost when
|
|
// the savepoint releases without committing the outer transaction.
|
|
if (hookOptions?.transaction) {
|
|
const transaction =
|
|
hookOptions.transaction.parent || hookOptions.transaction;
|
|
transaction.afterCommit(remove);
|
|
} else {
|
|
await remove();
|
|
}
|
|
};
|
|
|
|
// The model class is not added to a Sequelize instance until the database
|
|
// module is first imported, which is later than decorator evaluation. Poll
|
|
// until the model is ready, then register the hooks. Use unref() so the
|
|
// pending immediate does not keep the event loop alive in environments
|
|
// (such as tests) where the database is never initialized.
|
|
const registerHooks = () => {
|
|
if (!modelClass.sequelize) {
|
|
setImmediate(registerHooks).unref();
|
|
return;
|
|
}
|
|
modelClass.addHook("afterCreate", invalidate);
|
|
modelClass.addHook("afterDestroy", invalidate);
|
|
};
|
|
setImmediate(registerHooks).unref();
|
|
|
|
return {
|
|
get() {
|
|
const cacheKey = buildCacheKey(this.id);
|
|
|
|
return CacheHelper.getData<number>(cacheKey).then((value) => {
|
|
if (!isNil(value)) {
|
|
return value;
|
|
}
|
|
|
|
return computeCount(this.id).then((count) => {
|
|
void CacheHelper.setData(cacheKey, count);
|
|
return count;
|
|
});
|
|
});
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TS rejects PropertyDescriptor as legacy decorator return type; descriptor is consumed by Sequelize at runtime.
|
|
} as any;
|
|
};
|
|
}
|