feat(pad): cache historicalAuthorData (remove join-time thundering herd)

Draft
JO
5mo ago
+274-16

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:

  1. The 250-author cliff was a measurement artifact. NODE_ENV=production enables Etherpad's per-IP commitRateLimiting. handleMessage runs the rate limiter on every message including CLIENT_READY. With the dive harness and SUT colocated on one runner, all simulated authors share 127.0.0.1 = one rate-limit bucket = trips at N × edit-rate. Fixed in etherpad-load-test#105.

  2. 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 p95 flag at step 400 with p95 ≈ 2000 ms and 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 getAuthor fans should disappear.

🤖 Generated with Claude Code