Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<interfaces.PersistentStoreDataKind> = [
{
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();
});
});
26 changes: 24 additions & 2 deletions packages/store/node-server-sdk-dynamodb/src/DynamoDBCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,14 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore {

async init(
allData: interfaces.KindKeyedStore<interfaces.PersistentStoreDataKind>,
callback: () => void,
callback: (err?: Error) => void,
) {
let items: Record<string, AttributeValue>[];
try {
items = await this._readExistingItems(allData);
} catch (error) {
this._logger?.error(`Error reading existing items from DynamoDB: ${error}`);
callback();
callback(error as Error);
return;
}

Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,37 @@ 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);
}

delete(kind: interfaces.DataKind, key: string, version: number, callback: () => void): void {
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);
}

initialized(callback: (isInitialized: boolean) => void): void {
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();
}
Expand Down
Loading