feat(pad): cache historicalAuthorData (remove join-time thundering herd)
Summary
Per-pad cache for the {authorId → {name, colorId}} map that handleClientReady builds for every CLIENT_READY message. Eliminates a thundering-herd condition on join when multiple users connect at once to a long-lived pad.
Origin: the dive's 250-author cliff turned out to be two stacked artifacts
I built this PR thinking it would move the dive's 250-author cliff (#7756). It didn't, and the data is interesting:
-
The 250-author cliff was a measurement artifact.
NODE_ENV=productionenables Etherpad's per-IPcommitRateLimiting.handleMessageruns the rate limiter on every message includingCLIENT_READY. With the dive harness and SUT colocated on one runner, all simulated authors share127.0.0.1= one rate-limit bucket = trips at N × edit-rate. Fixed in etherpad-load-test#105. -
The real cliff on a 4-vCPU GitHub runner is 350-400 authors, with or without this PR. Both develop and this branch hit the
p95flag at step 400 withp95 ≈ 2000 msand CPU at 54-65 CPU-seconds per 8-second dwell. Steady-state fan-out CPU saturation, not join-path cost.
So: this PR doesn't move the dive's cliff. It does fix a real thundering-herd condition that only appears when many users join one pad at the same time, which is exactly the case it's worth caching for — just not what limits concurrency at the saturation boundary.
Change
Each CLIENT_READY previously did:
const authors = pad.getAllAuthors();
await Promise.all(authors.map(async (id) => {
const a = await authorManager.getAuthor(id);
historicalAuthorData[id] = {name: a.name, colorId: a.colorId};
}));
At 200 existing authors × 50 simultaneous joiners that's 10 000 ueberdb cache lookups + async/await bookkeeping for a result the joiners share. New per-pad cache (5-second TTL) collapses that to one shared computation per burst.
src/node/db/HistoricalAuthorDataCache.ts— pure helper, no DB or Pad dependency. Takes the author-id list and the per-id fetcher as constructor args.Pad.getHistoricalAuthorData()— lazy-inits a cache per pad.PadMessageHandler.handleClientReady— replaces the inline Promise.all loop with a single await.
Tests (6/6)
- Returns one entry per author with
{name, colorId}. - 50 simultaneous
get()calls produce 1 fetch per author — the win. - Refetches once the TTL expires.
- Omits authors the fetcher returns falsy for.
- Explicit
invalidate()works (hookable later for author-add events). - Failed fetch leaves the cache clearable, next call retries.
When this matters
Production-significant when many users join the same pad in a short window: classroom-style "everyone open the link now", live-event onboarding bursts, integration tests, etc. The cost it removes is CPU-bound on the event loop right at the moment new users are waiting on CLIENT_VARS, so removing it improves perceived join latency under that specific load.
The dive doesn't exercise this scenario at a level where it dominates; the cliff at 350-400 in the dive is true steady-state CPU saturation. Moving that ceiling further is a separate investigation (engine.io transport-level packing per #7767, or bigger hardware).
Test plan
- CI green
- 6/6 unit tests pass
- Steady-state numbers unchanged at all step sizes through 350 in run 25949421120 vs develop in run 25949525421
- Manual: open a pad with N>50 authors, join from 10 browsers within 2 seconds. Expect no observable change vs develop except the connect-time CPU spike from
getAuthorfans should disappear.
🤖 Generated with Claude Code