diff --git a/packages/store/node-server-sdk-dynamodb/__tests__/DynamoDBCoreAvailability.test.ts b/packages/store/node-server-sdk-dynamodb/__tests__/DynamoDBCoreAvailability.test.ts new file mode 100644 index 0000000000..549403a254 --- /dev/null +++ b/packages/store/node-server-sdk-dynamodb/__tests__/DynamoDBCoreAvailability.test.ts @@ -0,0 +1,122 @@ +import { interfaces, PersistentDataStoreWrapper } from '@launchdarkly/node-server-sdk'; + +import DynamoDBCore from '../src/DynamoDBCore'; +import DynamoDBFeatureStore from '../src/DynamoDBFeatureStore'; + +jest.mock('@launchdarkly/node-server-sdk', () => { + const actual = jest.requireActual('@launchdarkly/node-server-sdk'); + return { + ...actual, + PersistentDataStoreWrapper: jest.fn(), + }; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const featuresKind: interfaces.PersistentStoreDataKind = { + namespace: 'features', + deserialize: (data: string) => JSON.parse(data), +}; + +const allData: interfaces.KindKeyedStore = [ + { + key: featuresKind, + item: [{ key: 'flagA', item: { version: 1, deleted: false, serializedItem: '{"version":1}' } }], + }, +]; + +it('reports an init error through the callback when the batch write fails', (done) => { + const state = { + prefixedKey: (key: string) => key, + query: jest.fn().mockResolvedValue([]), + batchWrite: jest.fn().mockRejectedValue(new Error('write failed')), + }; + // @ts-ignore Partial state mock for testing. + const core = new DynamoDBCore('test-table', state); + core.init(allData, (err) => { + expect(err).toEqual(new Error('write failed')); + done(); + }); +}); + +it('reports an init error through the callback when reading existing items fails', (done) => { + const state = { + prefixedKey: (key: string) => key, + query: jest.fn().mockRejectedValue(new Error('read failed')), + batchWrite: jest.fn(), + }; + // @ts-ignore Partial state mock for testing. + const core = new DynamoDBCore('test-table', state); + core.init(allData, (err) => { + expect(err).toEqual(new Error('read failed')); + expect(state.batchWrite).not.toHaveBeenCalled(); + done(); + }); +}); + +it('calls back without an error when init succeeds', (done) => { + const state = { + prefixedKey: (key: string) => key, + query: jest.fn().mockResolvedValue([]), + batchWrite: jest.fn().mockResolvedValue(undefined), + }; + // @ts-ignore Partial state mock for testing. + const core = new DynamoDBCore('test-table', state); + core.init(allData, (err) => { + expect(err).toBeUndefined(); + done(); + }); +}); + +it('calls back true from isStoreAvailable when the read succeeds', (done) => { + const state = { + prefixedKey: (key: string) => key, + get: jest.fn().mockResolvedValue(undefined), + }; + // @ts-ignore Partial state mock for testing. + const core = new DynamoDBCore('test-table', state); + core.isStoreAvailable((isAvailable) => { + expect(isAvailable).toBe(true); + done(); + }); +}); + +it('calls back false from isStoreAvailable when the read fails', (done) => { + const state = { + prefixedKey: (key: string) => key, + get: jest.fn().mockRejectedValue(new Error('connection failed')), + }; + // @ts-ignore Partial state mock for testing. + const core = new DynamoDBCore('test-table', state); + core.isStoreAvailable((isAvailable) => { + expect(isAvailable).toBe(false); + done(); + }); +}); + +it('does not reject its returned promise when the isStoreAvailable callback throws', async () => { + const state = { + prefixedKey: (key: string) => key, + get: jest.fn().mockResolvedValue(undefined), + }; + // @ts-ignore Partial state mock for testing. + const core = new DynamoDBCore('test-table', state); + await expect( + core.isStoreAvailable(() => { + throw new Error('callback exploded'); + }), + ).resolves.toBeUndefined(); +}); + +it('forwards isStoreAvailable through the feature store facade', (done) => { + (PersistentDataStoreWrapper as unknown as jest.Mock).mockImplementation(() => ({ + isStoreAvailable: (callback: (isAvailable: boolean) => void) => callback(true), + })); + const store = new DynamoDBFeatureStore('test-table'); + store.isStoreAvailable((isAvailable) => { + expect(isAvailable).toBe(true); + done(); + }); +}); diff --git a/packages/store/node-server-sdk-dynamodb/src/DynamoDBCore.ts b/packages/store/node-server-sdk-dynamodb/src/DynamoDBCore.ts index 2d1123f623..ce9d0d52e9 100644 --- a/packages/store/node-server-sdk-dynamodb/src/DynamoDBCore.ts +++ b/packages/store/node-server-sdk-dynamodb/src/DynamoDBCore.ts @@ -124,14 +124,14 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { async init( allData: interfaces.KindKeyedStore, - callback: () => void, + callback: (err?: Error) => void, ) { let items: Record[]; try { items = await this._readExistingItems(allData); } catch (error) { this._logger?.error(`Error reading existing items from DynamoDB: ${error}`); - callback(); + callback(error as Error); return; } @@ -178,6 +178,8 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { await this._state.batchWrite(this._tableName, ops); } catch (error) { this._logger?.error(`Error writing to DynamoDB: ${error}`); + callback(error as Error); + return; } callback(); } @@ -267,6 +269,26 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { callback(initialized); } + async isStoreAvailable(callback: (isAvailable: boolean) => void) { + let isAvailable = false; + try { + // A cheap read. The store is available when the request succeeds. The result + // value does not matter. + await this._state.get(this._tableName, this._initializedToken()); + isAvailable = true; + } catch { + isAvailable = false; + } + // Callback outside the try for the read above, so a failed read is never + // mistaken for a callback error. It gets its own try/catch so a throw from the + // caller's callback cannot reject this method's returned promise. + try { + callback(isAvailable); + } catch { + // The caller's callback is responsible for handling its own errors. + } + } + close(): void { this._state.close(); } diff --git a/packages/store/node-server-sdk-dynamodb/src/DynamoDBFeatureStore.ts b/packages/store/node-server-sdk-dynamodb/src/DynamoDBFeatureStore.ts index b26c781cdf..fd74f12e7c 100644 --- a/packages/store/node-server-sdk-dynamodb/src/DynamoDBFeatureStore.ts +++ b/packages/store/node-server-sdk-dynamodb/src/DynamoDBFeatureStore.ts @@ -44,7 +44,7 @@ export default class DynamoDBFeatureStore implements LDFeatureStore { this._wrapper.all(kind, callback); } - init(allData: LDFeatureStoreDataStorage, callback: () => void): void { + init(allData: LDFeatureStoreDataStorage, callback: (err?: Error) => void): void { this._wrapper.init(allData, callback); } @@ -52,7 +52,11 @@ export default class DynamoDBFeatureStore implements LDFeatureStore { this._wrapper.delete(kind, key, version, callback); } - upsert(kind: interfaces.DataKind, data: LDKeyedFeatureStoreItem, callback: () => void): void { + upsert( + kind: interfaces.DataKind, + data: LDKeyedFeatureStoreItem, + callback: (err?: Error) => void, + ): void { this._wrapper.upsert(kind, data, callback); } @@ -60,6 +64,17 @@ export default class DynamoDBFeatureStore implements LDFeatureStore { this._wrapper.initialized(callback); } + isStoreAvailable(callback: (isAvailable: boolean) => void): void { + if (this._wrapper.isStoreAvailable) { + this._wrapper.isStoreAvailable(callback); + return; + } + // No availability check on the wrapped core. Report unavailable so recovery + // falls back to the next successful write instead of a probe that can never + // report true. + callback(false); + } + close(): void { this._wrapper.close(); }