-
-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(pad): cache historicalAuthorData (remove join-time thundering herd) #7769
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| // Per-pad cache for the `{authorId -> {name, colorId}}` map used by | ||
| // PadMessageHandler.handleClientReady to populate clientVars | ||
| // (#7756 connect-handshake cliff 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 class HistoricalAuthorDataCache { | ||
| private cached: AuthorRecord extends never ? never : { | ||
| data: {[id: string]: AuthorRecord}; | ||
| promise?: Promise<{[id: string]: AuthorRecord}>; | ||
| builtAt: number; | ||
| } | null = null; | ||
|
|
||
| constructor( | ||
| private readonly listAuthorIds: () => string[], | ||
| private readonly getAuthor: GetAuthorFn, | ||
| private readonly ttlMs: number = 5_000, | ||
| private readonly now: () => number = Date.now, | ||
| ) {} | ||
|
|
||
| async get(): Promise<{[id: string]: AuthorRecord}> { | ||
| const now = this.now(); | ||
| const cached = this.cached; | ||
| if (cached && now - cached.builtAt < this.ttlMs) { | ||
| return cached.promise ?? cached.data; | ||
| } | ||
| const promise = this.compute(); | ||
| this.cached = {data: {}, promise, builtAt: now}; | ||
| try { | ||
| const data = await promise; | ||
| this.cached = {data, builtAt: now}; | ||
| return data; | ||
| } catch (err) { | ||
| this.cached = null; | ||
| throw err; | ||
| } | ||
| } | ||
|
|
||
| /** 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.cached = null; } | ||
|
|
||
| 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}; | ||
| })); | ||
| return out; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
86 changes: 86 additions & 0 deletions
86
src/tests/backend-new/specs/pad-historical-author-data.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| // HistoricalAuthorDataCache pins the per-pad author-data cache used by | ||
| // PadMessageHandler.handleClientReady. The cache exists to coalesce the | ||
| // Promise.all(authors.map(getAuthor)) work across simultaneous CLIENT_READY | ||
| // handshakes — see ether/etherpad#7756. | ||
| // | ||
| // The helper takes pure functions as input (no DB, no Pad), so this test | ||
| // exercises the real production code path without standing up the full | ||
| // pad / DB stack. | ||
|
|
||
| import {describe, it, expect, vi, beforeEach} from 'vitest'; | ||
| import {HistoricalAuthorDataCache, type AuthorRecord} from '../../../node/db/HistoricalAuthorDataCache'; | ||
|
|
||
| const makeCache = (ids: string[], fetcher: (id: string) => Promise<AuthorRecord | null | undefined>, ttlMs = 5_000, now = () => Date.now()) => | ||
| new HistoricalAuthorDataCache(() => ids, fetcher, ttlMs, now); | ||
|
|
||
| describe('HistoricalAuthorDataCache', () => { | ||
| let getAuthorMock: ReturnType<typeof vi.fn<(id: string) => Promise<AuthorRecord | null>>>; | ||
|
|
||
| beforeEach(() => { | ||
| getAuthorMock = vi.fn(async (id: string) => ({name: `n-${id}`, colorId: `c-${id}`})); | ||
| }); | ||
|
|
||
| it('returns one entry per author with {name, colorId}', async () => { | ||
| const cache = makeCache(['a.1', 'a.2', 'a.3'], getAuthorMock); | ||
| const data = await cache.get(); | ||
| expect(data).toEqual({ | ||
| 'a.1': {name: 'n-a.1', colorId: 'c-a.1'}, | ||
| 'a.2': {name: 'n-a.2', colorId: 'c-a.2'}, | ||
| 'a.3': {name: 'n-a.3', colorId: 'c-a.3'}, | ||
| }); | ||
| }); | ||
|
|
||
| it('coalesces 50 simultaneous get() calls into 1 fetch per author', async () => { | ||
| const cache = makeCache(['a.1', 'a.2', 'a.3'], getAuthorMock); | ||
| const results = await Promise.all(Array.from({length: 50}, () => cache.get())); | ||
| expect(results).toHaveLength(50); | ||
| expect(getAuthorMock).toHaveBeenCalledTimes(3); | ||
| for (const r of results) { | ||
| expect(Object.keys(r).sort()).toEqual(['a.1', 'a.2', 'a.3']); | ||
| } | ||
| }); | ||
|
|
||
| it('refetches once the TTL expires', async () => { | ||
| let clock = 0; | ||
| const cache = makeCache(['a.1'], getAuthorMock, 5_000, () => clock); | ||
| await cache.get(); | ||
| expect(getAuthorMock).toHaveBeenCalledTimes(1); | ||
| clock = 4_000; | ||
| await cache.get(); | ||
| expect(getAuthorMock).toHaveBeenCalledTimes(1); | ||
| clock = 6_000; | ||
| await cache.get(); | ||
| expect(getAuthorMock).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it('omits authors the fetcher returns falsy for', async () => { | ||
| const fetcher = vi.fn(async (id: string) => | ||
| id === 'a.gone' ? null : {name: `n-${id}`, colorId: 'c'}); | ||
| const cache = makeCache(['a.1', 'a.gone', 'a.2'], fetcher); | ||
| const data = await cache.get(); | ||
| expect(Object.keys(data).sort()).toEqual(['a.1', 'a.2']); | ||
| }); | ||
|
|
||
| it('invalidate() forces the next call to refetch', async () => { | ||
| const cache = makeCache(['a.1'], getAuthorMock); | ||
| await cache.get(); | ||
| await cache.get(); | ||
| expect(getAuthorMock).toHaveBeenCalledTimes(1); | ||
| cache.invalidate(); | ||
| await cache.get(); | ||
| expect(getAuthorMock).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it('a failed fetch clears the cache so the next call retries', async () => { | ||
| let attempt = 0; | ||
| const flakyFetcher = vi.fn(async (id: string) => { | ||
| attempt++; | ||
| if (attempt === 1) throw new Error('first attempt fails'); | ||
| return {name: `n-${id}`, colorId: 'c'}; | ||
| }); | ||
| const cache = makeCache(['a.1'], flakyFetcher); | ||
| await expect(cache.get()).rejects.toThrow('first attempt fails'); | ||
| const data = await cache.get(); | ||
| expect(data).toEqual({'a.1': {name: 'n-a.1', colorId: 'c'}}); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1. Cache recompute race
🐞 Bug☼ ReliabilityAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools