-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix(server-core): build one orchestrator api per id, not one per concurrent caller #11834
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
Open
paveltiunov
wants to merge
6
commits into
master
Choose a base branch
from
pavel-claude/busy-lamport-wpo548
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+242
−0
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
cbcf6cc
fix(server-core): build one orchestrator api per id, not one per conc…
claude b84140a
fix(server-core): clear the in-flight build memo on reset, tidy its test
claude 03f116e
refactor(server-core): trim the memo's comments, say what its guard i…
claude 2414fb0
test(server-core): pin that the build memo is keyed per orchestrator id
claude dab76ac
test(server-core): keep the reset case's comment to what the test bod…
claude f609972
test(server-core): say "one shared id for all callers", not the reverse
claude 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
206 changes: 206 additions & 0 deletions
206
packages/cubejs-server-core/test/unit/getOrchestratorApi.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,206 @@ | ||
| // A replaced `OrchestratorStorage` entry is released, and releasing an api closes | ||
| // its Cube Store connection for good -- so building an api twice for one id | ||
| // destroys the connection of whoever holds the api it replaces. | ||
|
|
||
| import { CubejsServerCore } from '../../src'; | ||
| import { OrchestratorApi } from '../../src/core/OrchestratorApi'; | ||
|
|
||
| const cores: CubejsServerCore[] = []; | ||
|
|
||
| function createServerCore(options: Record<string, unknown> = {}) { | ||
| const core = new CubejsServerCore(<any>{ | ||
| apiSecret: 'secret', | ||
| driverFactory: () => <any>({ type: 'postgres' }), | ||
| // One shared id for all callers: a burst of requests carrying the same | ||
| // security context, which is what the deployment that reported this served. | ||
| contextToOrchestratorId: () => 'ORCHESTRATOR_ID', | ||
| ...options, | ||
| }); | ||
|
|
||
| cores.push(core); | ||
|
|
||
| return core; | ||
| } | ||
|
|
||
| async function callConcurrently(core: CubejsServerCore, times: number) { | ||
| return Promise.all( | ||
| Array.from({ length: times }, (_, i) => core.getOrchestratorApi({ | ||
| requestId: `request-${i}`, | ||
| authInfo: null, | ||
| securityContext: null, | ||
| } as any)) | ||
| ); | ||
| } | ||
|
|
||
| // `disposeAfter` releases asynchronously, so let the microtasks drain first. | ||
| const flushReleases = () => new Promise(resolve => { setImmediate(resolve); }); | ||
|
|
||
| async function waitFor(condition: () => boolean) { | ||
| for (let i = 0; i < 100 && !condition(); i++) { | ||
| await flushReleases(); | ||
| } | ||
|
|
||
| if (!condition()) { | ||
| throw new Error('Timed out waiting for the build to reach its gate'); | ||
| } | ||
| } | ||
|
paveltiunov marked this conversation as resolved.
|
||
|
|
||
| describe('CubejsServerCore.getOrchestratorApi', () => { | ||
| let release: jest.SpyInstance; | ||
|
|
||
| beforeEach(() => { | ||
| // Releasing is what closes the Cube Store web socket, so counting these | ||
| // calls counts the connections destroyed. | ||
| release = jest.spyOn(OrchestratorApi.prototype, 'release') | ||
| .mockImplementation(async () => undefined); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| // In `afterEach` rather than at the end of each test: `releaseConnections()` | ||
| // is what cancels the scheduled refresh timer the constructor starts, and a | ||
| // test that fails before its last line would otherwise leave it running. | ||
| await Promise.all(cores.splice(0).map(core => core.releaseConnections())); | ||
|
|
||
| release.mockRestore(); | ||
| }); | ||
|
|
||
| test('concurrent callers of one id share a single orchestrator api', async () => { | ||
| const apis = await callConcurrently(createServerCore(), 5); | ||
|
|
||
| expect(new Set(apis).size).toEqual(1); | ||
| }); | ||
|
|
||
| test('no orchestrator handed to a caller is released behind its back', async () => { | ||
| await callConcurrently(createServerCore(), 5); | ||
|
|
||
| expect(release).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| // One `/v1/load` with `total: true` runs its data query and its count query | ||
| // through `Promise.all`, each fetching the api for itself, so a single request | ||
| // is enough to race with itself. | ||
| test('the two queries of one total:true request get the same api', async () => { | ||
| const [dataQuery, countQuery] = await callConcurrently(createServerCore(), 2); | ||
|
|
||
| expect(dataQuery).toBe(countQuery); | ||
| expect(release).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('callers of different ids get an api each', async () => { | ||
| const core = createServerCore({ | ||
| contextToOrchestratorId: (context: any) => context.requestId, | ||
| }); | ||
|
|
||
| const apis = await callConcurrently(core, 3); | ||
|
|
||
| // The other cases all pin one id, so a memo keyed too loosely -- or not | ||
| // keyed at all -- would satisfy every one of them. | ||
| expect(new Set(apis).size).toEqual(3); | ||
| expect(release).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('a later caller reuses the cached api rather than building another', async () => { | ||
| const core = createServerCore(); | ||
|
|
||
| const [first] = await callConcurrently(core, 3); | ||
| const later = await callConcurrently(core, 3); | ||
|
|
||
| expect(later.every(api => api === first)).toBe(true); | ||
| expect(release).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('a failed build is not left behind to fail every later request', async () => { | ||
| const core = createServerCore(); | ||
| let attempts = 0; | ||
|
|
||
| jest.spyOn(core as any, 'orchestratorOptions').mockImplementation(async () => { | ||
| attempts += 1; | ||
|
|
||
| if (attempts === 1) { | ||
| throw new Error('orchestrator options are not available yet'); | ||
| } | ||
|
|
||
| return {}; | ||
| }); | ||
|
|
||
| await expect(callConcurrently(core, 3)).rejects.toThrow('orchestrator options are not available yet'); | ||
|
|
||
| // All three shared the one failing build, and the failure did not become | ||
| // the cached answer for this id. | ||
| expect(attempts).toEqual(1); | ||
| await expect(callConcurrently(core, 1)).resolves.toBeDefined(); | ||
| expect(attempts).toEqual(2); | ||
| }); | ||
|
|
||
| // Reachable only when the build dropped by `resetInstanceState()` fails: one | ||
| // that succeeds fills the cache on its way out, and callers read the cache | ||
| // before the memo. Deleting the entry it finds would send the replacement | ||
| // build's callers back to building an api each. | ||
| test('a build that outlives a reset does not drop the build that replaced it', async () => { | ||
| const core = createServerCore(); | ||
| const gates: Array<() => void> = []; | ||
| let builds = 0; | ||
|
|
||
| jest.spyOn(core as any, 'orchestratorOptions').mockImplementation(async () => { | ||
| const build = builds++; | ||
|
|
||
| // Only the two builds this test orchestrates are held: a third one is the | ||
| // defect, and letting it run to completion makes the assertions below | ||
| // report the duplicate rather than time out waiting on it. | ||
| if (build < 2) { | ||
| await new Promise<void>(resolve => { gates.push(resolve); }); | ||
| } | ||
|
|
||
| if (build === 0) { | ||
| throw new Error('the deployment went away mid-build'); | ||
| } | ||
|
|
||
| return {}; | ||
| }); | ||
|
|
||
| const acrossReset = callConcurrently(core, 1); | ||
| await waitFor(() => gates.length === 1); | ||
|
|
||
| await core.resetInstanceState(); | ||
|
|
||
| const afterReset = callConcurrently(core, 1); | ||
| await waitFor(() => gates.length === 2); | ||
|
|
||
| gates[0](); | ||
| await expect(acrossReset).rejects.toThrow('the deployment went away mid-build'); | ||
|
|
||
| // The replacement is still in flight and the cache is still empty, so this | ||
| // caller can only be answered by the memo entry the failure just ran past. | ||
| const later = callConcurrently(core, 1); | ||
| gates[1](); | ||
|
|
||
| expect((await later)[0]).toBe((await afterReset)[0]); | ||
| expect(builds).toEqual(2); | ||
| expect(release).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('the Cube Store driver of a live caller is not closed under it', async () => { | ||
| // The step that turns a released api into the reported error: `release()` | ||
| // closes the external driver, and that close is terminal. | ||
| release.mockRestore(); | ||
|
|
||
| const closed: number[] = []; | ||
| let drivers = 0; | ||
| const core = createServerCore({ | ||
| externalDriverFactory: () => { | ||
| const id = drivers++; | ||
|
|
||
| return { | ||
| testConnection: async () => undefined, | ||
| release: async () => { closed.push(id); }, | ||
| }; | ||
| }, | ||
| }); | ||
|
|
||
| const apis = await callConcurrently(core, 3); | ||
| await flushReleases(); | ||
|
|
||
| expect(closed).toEqual([]); | ||
| expect(new Set(apis).size).toEqual(1); | ||
| }); | ||
| }); | ||
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.
Uh oh!
There was an error while loading. Please reload this page.