-
Notifications
You must be signed in to change notification settings - Fork 31
#3 Implement in memory feature store and async wrapper. #8
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
52 commits
Select commit
Hold shift + click to select a range
a090170
Work on initial scaffold.
kinyoklion 0b470ea
Update index exports.
kinyoklion 93fbac6
Add an extra blank line.
kinyoklion 8be08da
Attempt using mixin for event emitter.
kinyoklion 5302683
Add implementation for emitting events for big segment status.
kinyoklion 263fccd
Add documentation.
kinyoklion 831b964
Fix lint and interface hierarchy.
kinyoklion 3b1ece5
Start implementation.
kinyoklion cec9858
Progress on option handling.
kinyoklion 954a135
Add application tags support.
kinyoklion 1a0dffc
Un-generalize tags.
kinyoklion a141aeb
Fix tag formatting.
kinyoklion 40d27e3
Comments.
kinyoklion f429571
Add internal to validated options.
kinyoklion 5792122
Add tests for type checks. Finish implementing type checks.
kinyoklion b93ed50
Add remaining validator tests.
kinyoklion 82599e2
Improve coverage collection. Start implementing application tags tests.
kinyoklion ef67115
Add test logger and finish tag tests.
kinyoklion cd8357a
Start testing the Configuration class.
kinyoklion 7ea76b5
Fix validation. Start on individual option tests.
kinyoklion 82788fd
Add remaining config tests.
kinyoklion 9740584
Make sure to check resolves in the logger.
kinyoklion 2517279
Start implementation.
kinyoklion b573151
Implement check for service endpoints consistency. Improve testing me…
kinyoklion 4283204
Merge branch 'main' into rlamb/sc-154352/options-parsing
kinyoklion 2f55d1a
Merge branch 'main' into rlamb/sc-154351/attributes-and-context
kinyoklion 9080329
Remove unintended file.
kinyoklion c59c5b9
Merge branch 'rlamb/sc-154352/options-parsing' into rlamb/sc-154351/a…
kinyoklion af34183
Empty context.
kinyoklion 5e0d9b1
Update server-sdk-common/src/options/Configuration.ts
kinyoklion 3b207fd
Progress
kinyoklion 79dd5d6
Refactor expectMessages.
kinyoklion ab50ad1
Merge branch 'rlamb/sc-154352/options-parsing' into rlamb/sc-154351/a…
kinyoklion 2b38d40
Show what was received.
kinyoklion 36c584d
Merge master
kinyoklion 59ed4c8
Finish moving things.
kinyoklion a977353
Move validation. Implement more of Context.
kinyoklion a935f1a
Basic context and attribute reference structure.
kinyoklion ba71331
Add attribute reference tests.
kinyoklion a7a04ea
Add some comments.
kinyoklion db8e51d
Flesh out public interface for contexts. Add more documentation.
kinyoklion e23954b
Finish adding context tests.
kinyoklion 7fc75e1
Move logging settings/interfaces to sdk-common, implement loggers, im…
kinyoklion bbd02bb
Lint
kinyoklion 8d9d3fa
Remove incorrect comment.
kinyoklion de3a5e4
Add in memory data store.
kinyoklion fe0801a
Add an async facade for using stores.
kinyoklion 7765856
Add unit tests.
kinyoklion 2612b6d
Make memory store members private.
kinyoklion 37a03cd
Update server-sdk-common/src/store/AsyncStoreFacade.ts
kinyoklion 48282d2
Update server-sdk-common/src/store/AsyncStoreFacade.ts
kinyoklion 30240ae
Resolve merge conflicts.
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
150 changes: 150 additions & 0 deletions
150
server-sdk-common/__tests__/store/InMemoryFeatureStore.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,150 @@ | ||
| import AsyncStoreFacade from '../../src/store/AsyncStoreFacade'; | ||
| import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; | ||
| import VersionedDataKinds from '../../src/store/VersionedDataKinds'; | ||
|
|
||
| // To simplify testing the memory store will be wrapped with the async facade. | ||
| // Writing the tests with callbacks would make them much more difficult to follow. | ||
|
|
||
| describe('given an empty feature store and async facade', () => { | ||
| const store = new AsyncStoreFacade(new InMemoryFeatureStore()); | ||
|
|
||
| it('can be initialized', async () => { | ||
| await store.init({}); | ||
| const initialized = await store.initialized(); | ||
| expect(initialized).toBeTruthy(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('given an initialized feature store', () => { | ||
| let featureStore: AsyncStoreFacade; | ||
| const feature1 = { key: 'foo', version: 10 }; | ||
| const feature2 = { key: 'bar', version: 10 }; | ||
|
|
||
| beforeEach(async () => { | ||
| featureStore = new AsyncStoreFacade(new InMemoryFeatureStore()); | ||
| return featureStore.init({ | ||
| [VersionedDataKinds.Features.namespace]: { | ||
| foo: feature1, | ||
| bar: feature2, | ||
| }, | ||
| [VersionedDataKinds.Segments.namespace]: {}, | ||
| }); | ||
| }); | ||
|
|
||
| it('gets an existing feature', async () => { | ||
| const feature = await featureStore.get(VersionedDataKinds.Features, 'foo'); | ||
| expect(feature).toStrictEqual(feature1); | ||
| }); | ||
|
|
||
| it('gets null for a feature that does not exist', async () => { | ||
| const feature = await featureStore.get(VersionedDataKinds.Features, 'unknown'); | ||
| expect(feature).toBeNull(); | ||
| }); | ||
|
|
||
| it('gets all features', async () => { | ||
| const features = await featureStore.all(VersionedDataKinds.Features); | ||
| expect(features).toStrictEqual({ | ||
| foo: feature1, | ||
| bar: feature2, | ||
| }); | ||
| }); | ||
|
|
||
| it('does not upsert an older version', async () => { | ||
| await featureStore.upsert(VersionedDataKinds.Features, { | ||
| ...feature1, | ||
| version: feature1.version - 1, | ||
| }); | ||
| const feature = await featureStore.get(VersionedDataKinds.Features, 'foo'); | ||
| expect(feature).toEqual(feature1); | ||
| }); | ||
|
|
||
| it('does upsert a newer version', async () => { | ||
| const updatedFeature = { | ||
| ...feature1, | ||
| version: feature1.version + 1, | ||
| }; | ||
| await featureStore.upsert(VersionedDataKinds.Features, updatedFeature); | ||
| const feature = await featureStore.get(VersionedDataKinds.Features, 'foo'); | ||
| expect(feature).toEqual(updatedFeature); | ||
| }); | ||
|
|
||
| it('does upsert a new feature', async () => { | ||
| const newFeature = { | ||
| key: 'new-feature', | ||
| version: feature1.version + 1, | ||
| }; | ||
| await featureStore.upsert(VersionedDataKinds.Features, newFeature); | ||
| const feature = await featureStore.get(VersionedDataKinds.Features, newFeature.key); | ||
| expect(feature).toEqual(newFeature); | ||
| }); | ||
|
|
||
| it('handles race conditions in upserts', async () => { | ||
| const ver1 = { key: feature1.key, version: feature1.version + 1 }; | ||
| const ver2 = { key: feature1.key, version: feature1.version + 2 }; | ||
|
|
||
| // Intentionally not awaiting these. | ||
| const p1 = featureStore.upsert(VersionedDataKinds.Features, ver1); | ||
| const p2 = featureStore.upsert(VersionedDataKinds.Features, ver2); | ||
|
|
||
| // Let them both finish. | ||
| await Promise.all([p2, p1]); | ||
|
|
||
| const feature = await featureStore.get(VersionedDataKinds.Features, feature1.key); | ||
| expect(feature).toEqual(ver2); | ||
| }); | ||
|
|
||
| it('deletes with newer version', async () => { | ||
| featureStore.delete(VersionedDataKinds.Features, feature1.key, feature1.version + 1); | ||
| const feature = await featureStore.get(VersionedDataKinds.Features, feature1.key); | ||
| expect(feature).toBeNull(); | ||
| }); | ||
|
|
||
| it('does not delete with older version', async () => { | ||
| featureStore.delete(VersionedDataKinds.Features, feature1.key, feature1.version - 1); | ||
| const feature = await featureStore.get(VersionedDataKinds.Features, feature1.key); | ||
| expect(feature).toStrictEqual(feature1); | ||
| }); | ||
|
|
||
| it('allows deleting an unknown feature', async () => { | ||
| featureStore.delete(VersionedDataKinds.Features, 'unknown', 10); | ||
| const feature = await featureStore.get(VersionedDataKinds.Features, 'unknown'); | ||
| expect(feature).toBeNull(); | ||
| }); | ||
|
|
||
| it('does not upsert older version after delete', async () => { | ||
| const key = 'featureKey'; | ||
| featureStore.delete(VersionedDataKinds.Features, key, 10); | ||
|
|
||
| featureStore.upsert(VersionedDataKinds.Features, { | ||
| key, | ||
| version: 9, | ||
| }); | ||
| const feature = await featureStore.get(VersionedDataKinds.Features, key); | ||
| expect(feature).toBeNull(); | ||
| }); | ||
|
|
||
| it('does upsert newer version after delete', async () => { | ||
| const key = 'featureKey'; | ||
| featureStore.delete(VersionedDataKinds.Features, key, 10); | ||
|
|
||
| featureStore.upsert(VersionedDataKinds.Features, { | ||
| key, | ||
| version: 11, | ||
| }); | ||
| const feature = await featureStore.get(VersionedDataKinds.Features, key); | ||
| expect(feature).toStrictEqual({ | ||
| key, | ||
| version: 11, | ||
| }); | ||
| }); | ||
|
|
||
| it('does upsert a new item of unknown kind', async () => { | ||
| const newPotato = { | ||
| key: 'new-feature', | ||
| version: 1, | ||
| }; | ||
| await featureStore.upsert({namespace: 'potato'}, newPotato); | ||
| const feature = await featureStore.get({namespace: 'potato'}, newPotato.key); | ||
| expect(feature).toEqual(newPotato); | ||
| }); | ||
| }); |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import { DataKind } from '../api/interfaces'; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Our existing interface uses callbacks. We will still want to be able to use stores written against them. But we will not want to use callbacks because they make the code an unmaintainable hellscape. So, I am providing this adapter so the code can be written sanely. |
||
| import { | ||
| LDFeatureStore, | ||
| LDFeatureStoreDataStorage, | ||
| LDFeatureStoreItem, | ||
| LDFeatureStoreKindData, | ||
| LDKeyedFeatureStoreItem, | ||
| } from '../api/subsystems'; | ||
|
|
||
| /** | ||
| * A basic wrapper to make async methods with callbacks into promises. | ||
| * | ||
| * @param method | ||
| * @returns A promisified version of the method. | ||
| */ | ||
| function promisify<T>(method: (callback: (val: T) => void) => void): Promise<T> { | ||
| return new Promise<T>((resolve) => { | ||
| method((val: T) => { resolve(val); }); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Provides an async interface to a feature store. | ||
| * | ||
| * This allows for using a store using async/await instead of callbacks. | ||
| * | ||
| * @internal | ||
| */ | ||
| export default class AsyncStoreFacade { | ||
| private store: LDFeatureStore; | ||
|
|
||
| constructor(store: LDFeatureStore) { | ||
| this.store = store; | ||
| } | ||
|
|
||
| async get(kind: DataKind, key: string): Promise<LDFeatureStoreItem | null> { | ||
| return promisify((cb) => { | ||
| this.store.get(kind, key, cb); | ||
| }); | ||
| } | ||
|
|
||
| async all(kind: DataKind): Promise<LDFeatureStoreKindData> { | ||
| return promisify((cb) => { | ||
| this.store.all(kind, cb); | ||
| }); | ||
| } | ||
|
|
||
| async init(allData: LDFeatureStoreDataStorage): Promise<void> { | ||
| return promisify((cb) => { | ||
| this.store.init(allData, cb); | ||
| }); | ||
| } | ||
|
|
||
| async delete(kind: DataKind, key: string, version: number): Promise<void> { | ||
| return promisify((cb) => { | ||
| this.store.delete(kind, key, version, cb); | ||
| }); | ||
| } | ||
|
|
||
| async upsert(kind: DataKind, data: LDKeyedFeatureStoreItem): Promise<void> { | ||
| return promisify((cb) => { | ||
| this.store.upsert(kind, data, cb); | ||
| }); | ||
| } | ||
|
|
||
| async initialized(): Promise<boolean> { | ||
| return promisify((cb) => { | ||
| this.store.initialized(cb); | ||
| }); | ||
| } | ||
|
|
||
| close(): void { | ||
| this.store.close(); | ||
| } | ||
| } | ||
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,91 @@ | ||
| import { DataKind } from '../api/interfaces'; | ||
| import { | ||
| LDFeatureStoreDataStorage, | ||
| LDKeyedFeatureStoreItem, | ||
| LDFeatureStore, | ||
| LDFeatureStoreKindData, | ||
| LDFeatureStoreItem, | ||
| } from '../api/subsystems'; | ||
|
|
||
| /** | ||
| * Clone an object using JSON. This will not preserve | ||
| * non-JSON types (like functions). | ||
| * @param obj | ||
| * @returns A clone of the object. | ||
| */ | ||
| function clone(obj: any): any { | ||
| return JSON.parse(JSON.stringify(obj)); | ||
| } | ||
|
|
||
| export default class InMemoryFeatureStore implements LDFeatureStore { | ||
| private allData: LDFeatureStoreDataStorage = {}; | ||
|
|
||
| private initCalled = false; | ||
|
|
||
| private addItem(kind: DataKind, key: string, item: LDFeatureStoreItem) { | ||
| let items = this.allData[kind.namespace]; | ||
| if (!items) { | ||
| items = {}; | ||
| this.allData[kind.namespace] = items; | ||
| } | ||
| if (Object.hasOwnProperty.call(items, key)) { | ||
| const old = items[key]; | ||
| if (!old || old.version < item.version) { | ||
| items[key] = item; | ||
| } | ||
| } else { | ||
| items[key] = item; | ||
| } | ||
| } | ||
|
|
||
| get(kind: DataKind, key: string, callback: (res: LDFeatureStoreItem | null) => void): void { | ||
| const items = this.allData[kind.namespace]; | ||
| if (items) { | ||
| if (Object.prototype.hasOwnProperty.call(items, key)) { | ||
| const item = items[key]; | ||
| if (item && !item.deleted) { | ||
| return callback?.(item); | ||
| } | ||
| } | ||
| } | ||
| return callback?.(null); | ||
| } | ||
|
|
||
| all(kind: DataKind, callback: (res: LDFeatureStoreKindData) => void): void { | ||
| const result: LDFeatureStoreKindData = {}; | ||
| const items = this.allData[kind.namespace] ?? {}; | ||
| Object.entries(items).forEach(([key, item]) => { | ||
| if (item && !item.deleted) { | ||
| result[key] = item; | ||
| } | ||
| }); | ||
| callback?.(result); | ||
| } | ||
|
|
||
| init(allData: LDFeatureStoreDataStorage, callback: () => void): void { | ||
| this.initCalled = true; | ||
| this.allData = allData as LDFeatureStoreDataStorage; | ||
| callback?.(); | ||
| } | ||
|
|
||
| delete(kind: DataKind, key: string, version: number, callback: () => void): void { | ||
| const deletedItem = { version, deleted: true }; | ||
| this.addItem(kind, key, deletedItem); | ||
| callback?.(); | ||
| } | ||
|
|
||
| upsert(kind: DataKind, data: LDKeyedFeatureStoreItem, callback: () => void): void { | ||
| const item = clone(data); | ||
| this.addItem(kind, item.key, item); | ||
| callback?.(); | ||
| } | ||
|
|
||
| initialized(callback: (isInitialized: boolean) => void): void { | ||
| return callback?.(this.initCalled); | ||
| } | ||
|
|
||
| /* eslint-disable class-methods-use-this */ | ||
| close(): void { | ||
| // For the memory store this is a no-op. | ||
| } | ||
| } |
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.
These interfaces should all be compatible with the data as it already exists. They were just untyped before.
For TS, if people had been using these wrong, and they had implemented their own stores, then they would have to update the types.
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.
If they used it correctly, then no changes.