Repository navigation
feat: Move the file data sources onto the shared file data engine #2049
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
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
34cca69
feat: Add a shared file data engine
kinyoklion bcfbb02
fix: Reload when a direct file watch reports an error
kinyoklion 60a16b3
fix: Run the catch-up reload for each directory that becomes watchable
kinyoklion 07851e0
fix: Measure the settle bound with the shared monotonic clock
kinyoklion 1784d77
fix: Renew direct file watches on retargets and keep queued notify re…
kinyoklion 4d0094a
feat: Move the file data sources onto the shared file data engine
kinyoklion 8dda675
docs: Describe a directory that cannot be watched in the autoUpdate o…
kinyoklion 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
101 changes: 101 additions & 0 deletions
101
packages/sdk/server-node/__tests__/LDClientNode.fileDataSource.configmap.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,101 @@ | ||
| import * as fs from 'fs'; | ||
| import * as os from 'os'; | ||
| import * as path from 'path'; | ||
|
|
||
| import { integrations } from '@launchdarkly/js-server-sdk-common'; | ||
|
|
||
| import LDClientNode from '../src/LDClientNode'; | ||
|
|
||
| // Kubernetes updates a mounted ConfigMap or Secret with an atomic symbolic link swap: the data | ||
| // lives in a timestamped directory, `..data` links to it, and each file in the mount is a link | ||
| // into `..data`. An update writes a new timestamped directory, links `..data_tmp` to it, and | ||
| // renames `..data_tmp` over `..data`. The configured file name never appears in an event. | ||
| // Symbolic links need privileges on Windows, so the test runs elsewhere. | ||
|
|
||
| const document = (value: string) => JSON.stringify({ flagValues: { flag: value } }); | ||
|
|
||
| const settle = (ms: number) => | ||
| new Promise<void>((resolve) => { | ||
| setTimeout(resolve, ms); | ||
| }); | ||
|
|
||
| async function waitFor(condition: () => Promise<boolean>, timeoutMs: number = 5000) { | ||
| const deadline = Date.now() + timeoutMs; | ||
| while (Date.now() < deadline) { | ||
| // eslint-disable-next-line no-await-in-loop | ||
| if (await condition()) { | ||
| return; | ||
| } | ||
| // eslint-disable-next-line no-await-in-loop | ||
| await settle(20); | ||
| } | ||
| throw new Error('timed out waiting for the condition'); | ||
| } | ||
|
|
||
| const itOnPosix = process.platform === 'win32' ? it.skip : it; | ||
|
|
||
| describe('given a file data source with automatic updates over a ConfigMap-style mount', () => { | ||
| let base: string; | ||
| let mount: string; | ||
| let client: LDClientNode | undefined; | ||
| let version = 0; | ||
|
|
||
| const writeVersion = (value: string) => { | ||
| version += 1; | ||
| const name = `..2026_${version}`; | ||
| fs.mkdirSync(path.join(mount, name)); | ||
| fs.writeFileSync(path.join(mount, name, 'flags.json'), document(value)); | ||
| return name; | ||
| }; | ||
|
|
||
| const swapTo = (name: string) => { | ||
| const previous = fs.readlinkSync(path.join(mount, '..data')); | ||
| fs.symlinkSync(name, path.join(mount, '..data_tmp')); | ||
| fs.renameSync(path.join(mount, '..data_tmp'), path.join(mount, '..data')); | ||
| fs.rmSync(path.join(mount, previous), { recursive: true }); | ||
| }; | ||
|
|
||
| beforeEach(() => { | ||
| base = fs.mkdtempSync(path.join(os.tmpdir(), 'file-data-source-configmap-')); | ||
| mount = path.join(base, 'config'); | ||
| fs.mkdirSync(mount); | ||
| const first = writeVersion('a'); | ||
| fs.symlinkSync(first, path.join(mount, '..data')); | ||
| fs.symlinkSync(path.join('..data', 'flags.json'), path.join(mount, 'flags.json')); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| client?.close(); | ||
| client = undefined; | ||
| fs.rmSync(base, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| const start = async () => { | ||
| const source = new integrations.FileDataSourceFactory({ | ||
| paths: [path.join(mount, 'flags.json')], | ||
| autoUpdate: true, | ||
| }); | ||
| client = new LDClientNode('sdk-key', { | ||
| updateProcessor: source.getFactory(), | ||
| sendEvents: false, | ||
| }); | ||
| client.on('error', () => { | ||
| // A failed load is reported here. The tests observe the flag values instead. | ||
| }); | ||
| await client.waitForInitialization({ timeout: 10 }); | ||
| }; | ||
|
|
||
| const valueIs = (value: string) => async () => | ||
| (await client!.variation('flag', { key: 'user' }, 'default')) === value; | ||
|
|
||
| itOnPosix('detects each symbolic link swap', async () => { | ||
| await start(); | ||
| expect(await valueIs('a')()).toBe(true); | ||
|
|
||
| swapTo(writeVersion('b')); | ||
| await waitFor(valueIs('b'), 3000); | ||
|
|
||
| swapTo(writeVersion('c')); | ||
| await waitFor(valueIs('c'), 3000); | ||
| }); | ||
| }); |
84 changes: 84 additions & 0 deletions
84
packages/sdk/server-node/__tests__/LDClientNode.fileDataSource.directory.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,84 @@ | ||
| import * as fs from 'fs'; | ||
| import * as os from 'os'; | ||
| import * as path from 'path'; | ||
|
|
||
| import { integrations } from '@launchdarkly/js-server-sdk-common'; | ||
|
|
||
| import LDClientNode from '../src/LDClientNode'; | ||
|
|
||
| // These tests run on real files and real timers. The source waits for change notifications to | ||
| // settle for 100 ms and retries a failed load after one second. | ||
|
|
||
| const document = (value: string) => JSON.stringify({ flagValues: { flag: value } }); | ||
|
|
||
| const settle = (ms: number) => | ||
| new Promise<void>((resolve) => { | ||
| setTimeout(resolve, ms); | ||
| }); | ||
|
|
||
| async function waitFor(condition: () => Promise<boolean>, timeoutMs: number = 5000) { | ||
| const deadline = Date.now() + timeoutMs; | ||
| while (Date.now() < deadline) { | ||
| // eslint-disable-next-line no-await-in-loop | ||
| if (await condition()) { | ||
| return; | ||
| } | ||
| // eslint-disable-next-line no-await-in-loop | ||
| await settle(20); | ||
| } | ||
| throw new Error('timed out waiting for the condition'); | ||
| } | ||
|
|
||
| describe('given a file data source with automatic updates over a real directory', () => { | ||
| let base: string; | ||
| let directory: string; | ||
| let filePath: string; | ||
| let client: LDClientNode | undefined; | ||
|
|
||
| beforeEach(() => { | ||
| base = fs.mkdtempSync(path.join(os.tmpdir(), 'file-data-source-test-')); | ||
| directory = path.join(base, 'data'); | ||
| filePath = path.join(directory, 'flags.json'); | ||
| fs.mkdirSync(directory); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| client?.close(); | ||
| client = undefined; | ||
| fs.rmSync(base, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| const start = async () => { | ||
| const source = new integrations.FileDataSourceFactory({ paths: [filePath], autoUpdate: true }); | ||
| client = new LDClientNode('sdk-key', { | ||
| updateProcessor: source.getFactory(), | ||
| sendEvents: false, | ||
| }); | ||
| client.on('error', () => { | ||
| // A failed load is reported here. The tests observe the flag values instead. | ||
| }); | ||
| await client.waitForInitialization({ timeout: 10 }); | ||
| }; | ||
|
|
||
| const valueIs = (value: string) => async () => | ||
| (await client!.variation('flag', { key: 'user' }, 'default')) === value; | ||
|
|
||
| it('detects edits after the directory is deleted and created again', async () => { | ||
| fs.writeFileSync(filePath, document('a')); | ||
| await start(); | ||
| expect(await valueIs('a')()).toBe(true); | ||
|
|
||
| fs.rmSync(directory, { recursive: true }); | ||
| await settle(300); | ||
| expect(await valueIs('a')()).toBe(true); | ||
|
|
||
| // The directory comes back with the file. The retry or the new watch loads it. | ||
| fs.mkdirSync(directory); | ||
| fs.writeFileSync(filePath, document('b')); | ||
| await waitFor(valueIs('b')); | ||
|
|
||
| // Only a watch on the new directory can detect a later edit. | ||
| fs.writeFileSync(filePath, document('c')); | ||
| await waitFor(valueIs('c'), 3000); | ||
| }); | ||
| }); | ||
93 changes: 93 additions & 0 deletions
93
packages/sdk/server-node/__tests__/LDClientNode.fileDataSource.symlink.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,93 @@ | ||
| import * as fs from 'fs'; | ||
| import * as os from 'os'; | ||
| import * as path from 'path'; | ||
|
|
||
| import { integrations } from '@launchdarkly/js-server-sdk-common'; | ||
|
|
||
| import LDClientNode from '../src/LDClientNode'; | ||
|
|
||
| // A configured path can be a symbolic link to a file in another directory. The directory that | ||
| // contains the link never reports a change to the target, so the source also watches the path | ||
| // itself, which follows the link. Symbolic links need privileges on Windows, so the test runs | ||
| // elsewhere. | ||
|
|
||
| const document = (value: string) => JSON.stringify({ flagValues: { flag: value } }); | ||
|
|
||
| const settle = (ms: number) => | ||
| new Promise<void>((resolve) => { | ||
| setTimeout(resolve, ms); | ||
| }); | ||
|
|
||
| async function waitFor(condition: () => Promise<boolean>, timeoutMs: number = 5000) { | ||
| const deadline = Date.now() + timeoutMs; | ||
| while (Date.now() < deadline) { | ||
| // eslint-disable-next-line no-await-in-loop | ||
| if (await condition()) { | ||
| return; | ||
| } | ||
| // eslint-disable-next-line no-await-in-loop | ||
| await settle(20); | ||
| } | ||
| throw new Error('timed out waiting for the condition'); | ||
| } | ||
|
|
||
| const itOnPosix = process.platform === 'win32' ? it.skip : it; | ||
|
|
||
| describe('given a file data source whose configured path links to a file elsewhere', () => { | ||
| let base: string; | ||
| let target: string; | ||
| let link: string; | ||
| let client: LDClientNode | undefined; | ||
|
|
||
| const replaceTarget = (value: string) => { | ||
| const temporary = `${target}.tmp`; | ||
| fs.writeFileSync(temporary, document(value)); | ||
| fs.renameSync(temporary, target); | ||
| }; | ||
|
|
||
| beforeEach(() => { | ||
| base = fs.mkdtempSync(path.join(os.tmpdir(), 'file-data-source-symlink-')); | ||
| fs.mkdirSync(path.join(base, 'real')); | ||
| fs.mkdirSync(path.join(base, 'config')); | ||
| target = path.join(base, 'real', 'flags.json'); | ||
| link = path.join(base, 'config', 'flags.json'); | ||
| fs.writeFileSync(target, document('a')); | ||
| fs.symlinkSync(path.join('..', 'real', 'flags.json'), link); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| client?.close(); | ||
| client = undefined; | ||
| fs.rmSync(base, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| const start = async () => { | ||
| const source = new integrations.FileDataSourceFactory({ paths: [link], autoUpdate: true }); | ||
| client = new LDClientNode('sdk-key', { | ||
| updateProcessor: source.getFactory(), | ||
| sendEvents: false, | ||
| }); | ||
| client.on('error', () => { | ||
| // A failed load is reported here. The tests observe the flag values instead. | ||
| }); | ||
| await client.waitForInitialization({ timeout: 10 }); | ||
| }; | ||
|
|
||
| const valueIs = (value: string) => async () => | ||
| (await client!.variation('flag', { key: 'user' }, 'default')) === value; | ||
|
|
||
| itOnPosix('detects edits and replacements of the target', async () => { | ||
| await start(); | ||
| expect(await valueIs('a')()).toBe(true); | ||
|
|
||
| // An in-place edit of the target. | ||
| fs.writeFileSync(target, document('b')); | ||
| await waitFor(valueIs('b'), 3000); | ||
|
|
||
| // An atomic replacement of the target, twice: the direct watch is renewed after each. | ||
| replaceTarget('c'); | ||
| await waitFor(valueIs('c'), 3000); | ||
| replaceTarget('d'); | ||
| await waitFor(valueIs('d'), 3000); | ||
| }); | ||
| }); |
Oops, something went wrong.
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.
New: real-filesystem test through LDClientNode. Deletes and recreates the directory, then checks that a later edit is still detected.