mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-18 09:04:54 +00:00
Four issues raised on the first push:
1. **Cache recompute race.** If compute() ran past ttlMs, a second
get() would start a duplicate compute and the older resolution
could clobber the newer cached value. Fixed by piggybacking on
the in-flight promise regardless of TTL — never start a second
compute while one is running.
2. **Unexplained conditional type.** `AuthorRecord extends never ?
never : ...` was a leftover that confused future readers
without earning anything. Replaced with a plain `CacheState |
null` typed field with documented members.
3. **Shared cache object.** Cache hits returned the same object
reference; handleClientReady embeds it in clientVars exposed to
the clientVars hook, where a mutation by one join could bleed
into the next. Now defensively shallow-clones on every get().
4. **Missing author logs.** The previous inline Promise.all loop
in handleClientReady emitted `messageLogger.error('There is no
author for authorId...')` per missing id; the refactor dropped
that. Cache now takes an optional onMissingAuthor callback;
Pad.ts wires it to the same log4js logger so the error still
surfaces.
Plus: commit-state-promise-identity guard in refresh() — only
commit the resolved data if the in-flight promise is still the
one the state references (covers invalidate() racing a compute).
8/8 tests green: original 6 + new fresh-object guarantee + slow-
compute-past-TTL guarantee.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
96 lines
4 KiB
TypeScript
96 lines
4 KiB
TypeScript
// Per-pad cache for the `{authorId -> {name, colorId}}` map used by
|
|
// PadMessageHandler.handleClientReady to populate clientVars
|
|
// (#7756 connect-handshake investigation).
|
|
//
|
|
// At 200+ authors a burst of 50 simultaneous CLIENT_READY handshakes
|
|
// would otherwise each do Promise.all(authors.map(getAuthor)) =
|
|
// 50 * 200 = 10 000 ueberdb cache lookups inside the join hot path,
|
|
// competing for the event loop. This cache collapses that to one
|
|
// computation shared across the simultaneous joins.
|
|
//
|
|
// Extracted into its own module (rather than nested inside Pad) so it can
|
|
// be unit-tested without standing up the full pad / DB stack.
|
|
|
|
export type AuthorRecord = {name: string; colorId: string};
|
|
export type GetAuthorFn = (id: string) => Promise<AuthorRecord | null | undefined>;
|
|
export type OnMissingAuthorFn = (id: string) => void;
|
|
|
|
type CacheState = {
|
|
/** Resolved data. Empty `{}` until the first compute() resolves. */
|
|
data: {[id: string]: AuthorRecord};
|
|
/** Set iff a compute() is currently in flight. New callers await this same
|
|
* promise rather than starting a duplicate compute. Cleared on resolve. */
|
|
promise?: Promise<{[id: string]: AuthorRecord}>;
|
|
/** Wall-clock time the current data was committed. Used for TTL only. */
|
|
builtAt: number;
|
|
};
|
|
|
|
export class HistoricalAuthorDataCache {
|
|
private state: CacheState | null = null;
|
|
|
|
constructor(
|
|
private readonly listAuthorIds: () => string[],
|
|
private readonly getAuthor: GetAuthorFn,
|
|
private readonly ttlMs: number = 5_000,
|
|
private readonly now: () => number = Date.now,
|
|
/** Called once per author id that the fetcher returns falsy for.
|
|
* Lets the consumer preserve the error log that lived in the
|
|
* previous inline Promise.all loop. Optional. */
|
|
private readonly onMissingAuthor: OnMissingAuthorFn = () => {},
|
|
) {}
|
|
|
|
async get(): Promise<{[id: string]: AuthorRecord}> {
|
|
const now = this.now();
|
|
const s = this.state;
|
|
// In-flight compute: piggyback on it regardless of TTL — never start a
|
|
// second compute on top of a running one. The previous version could
|
|
// race two computes if the first ran past ttlMs, and the older
|
|
// resolution would clobber the newer cached value.
|
|
if (s?.promise) return cloneData(await s.promise);
|
|
if (s && now - s.builtAt < this.ttlMs) return cloneData(s.data);
|
|
return cloneData(await this.refresh(now));
|
|
}
|
|
|
|
/** Force the next get() to refetch. PadMessageHandler can call this when
|
|
* a new author commits, if we add hookable author-add events later. */
|
|
invalidate(): void { this.state = null; }
|
|
|
|
private refresh(now: number): Promise<{[id: string]: AuthorRecord}> {
|
|
const promise = this.compute();
|
|
this.state = {data: {}, promise, builtAt: now};
|
|
promise.then(
|
|
(data) => {
|
|
// Only commit if our promise is still the one the state references —
|
|
// covers the (unlikely) case where invalidate() ran during compute.
|
|
if (this.state?.promise === promise) {
|
|
this.state = {data, builtAt: this.now()};
|
|
}
|
|
},
|
|
() => { if (this.state?.promise === promise) this.state = null; },
|
|
);
|
|
return promise;
|
|
}
|
|
|
|
private async compute(): Promise<{[id: string]: AuthorRecord}> {
|
|
const ids = this.listAuthorIds();
|
|
const out: {[id: string]: AuthorRecord} = {};
|
|
await Promise.all(ids.map(async (id) => {
|
|
const a = await this.getAuthor(id);
|
|
if (a) out[id] = {name: a.name, colorId: a.colorId};
|
|
else this.onMissingAuthor(id);
|
|
}));
|
|
return out;
|
|
}
|
|
}
|
|
|
|
// Defensive shallow copy on every get(). Callers (notably handleClientReady,
|
|
// which embeds the result in clientVars and exposes it to the clientVars
|
|
// hook) historically received a fresh object per call; preserving that
|
|
// here so a mutation by one join can't bleed into the next.
|
|
const cloneData = (
|
|
src: {[id: string]: AuthorRecord},
|
|
): {[id: string]: AuthorRecord} => {
|
|
const out: {[id: string]: AuthorRecord} = {};
|
|
for (const k in src) out[k] = {name: src[k]!.name, colorId: src[k]!.colorId};
|
|
return out;
|
|
};
|