Skip to content

Commit 7bf3c31

Browse files
authored
fix: FDv2 -- cache initializer returns transfer-none on cache miss (#1275)
<!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > Changes FDv2 initialization semantics so cache misses/no-storage now produce a `changeSet` with payload `type: 'none'` instead of an `interrupted` status, which can affect whether `start()` considers data received and when it rejects. > > **Overview** > Cache initialization now reports cache miss / missing storage / corrupt cache as a **transfer-none** result (`changeSet` with `payload.type: 'none'` and no updates) rather than returning an `interrupted` status. > > The FDv2 orchestrator is updated to *skip* `changeSet` results with `payload.type: 'none'` during initialization (no `dataCallback`, and they don’t count as “data received”), with tests and helpers adjusted to cover the new behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ba3d456. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 7ed2c08 commit 7bf3c31

5 files changed

Lines changed: 64 additions & 25 deletions

File tree

packages/shared/sdk-client/__tests__/datasource/fdv2/CacheInitializer.test.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -135,41 +135,44 @@ describe('CacheInitializer', () => {
135135
}
136136
});
137137

138-
it('returns interrupted on cache miss', async () => {
138+
it('returns transfer-none on cache miss', async () => {
139139
const storage = makeMemoryStorage();
140140

141141
const initializer = createInitializer(storage, crypto, context);
142142
const result = await initializer.run();
143143

144-
expect(result.type).toBe('status');
145-
if (result.type === 'status') {
146-
expect(result.state).toBe('interrupted');
144+
expect(result.type).toBe('changeSet');
145+
if (result.type === 'changeSet') {
146+
expect(result.payload.type).toBe('none');
147+
expect(result.payload.updates).toHaveLength(0);
147148
}
148149
});
149150

150-
it('returns interrupted when storage is undefined', async () => {
151+
it('returns transfer-none when storage is undefined', async () => {
151152
const logger = makeMockLogger();
152153
const initializer = createInitializer(undefined, crypto, context, logger);
153154
const result = await initializer.run();
154155

155-
expect(result.type).toBe('status');
156-
if (result.type === 'status') {
157-
expect(result.state).toBe('interrupted');
156+
expect(result.type).toBe('changeSet');
157+
if (result.type === 'changeSet') {
158+
expect(result.payload.type).toBe('none');
159+
expect(result.payload.updates).toHaveLength(0);
158160
}
159161
expect(logger.debug).toHaveBeenCalledWith('No storage available for cache initializer');
160162
});
161163

162-
it('returns interrupted on corrupt JSON (treated as cache miss)', async () => {
164+
it('returns transfer-none on corrupt JSON (treated as cache miss)', async () => {
163165
const storage = makeMemoryStorage();
164166
const storageKey = await namespaceForContextData(crypto, TEST_NAMESPACE, context);
165167
await storage.set(storageKey, 'not valid json!!!');
166168

167169
const initializer = createInitializer(storage, crypto, context);
168170
const result = await initializer.run();
169171

170-
expect(result.type).toBe('status');
171-
if (result.type === 'status') {
172-
expect(result.state).toBe('interrupted');
172+
expect(result.type).toBe('changeSet');
173+
if (result.type === 'changeSet') {
174+
expect(result.payload.type).toBe('none');
175+
expect(result.payload.updates).toHaveLength(0);
173176
}
174177
});
175178

packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,48 @@ it('resolves start() when all initializers exhausted but data was received', asy
9292
ds.close();
9393
});
9494

95+
it('skips transfer-none initializer results without calling dataCallback', async () => {
96+
const dataCallback = jest.fn();
97+
const statusManager = makeStatusManager();
98+
const nonePayload = makePayload({ type: 'none' });
99+
const fullPayload = makePayload({ state: 'selector' });
100+
101+
const ds = createFDv2DataSource({
102+
initializerFactories: [
103+
makeInitFactory(makeMockInitializer(changeSet(nonePayload, false))),
104+
makeInitFactory(makeMockInitializer(changeSet(fullPayload, false))),
105+
],
106+
synchronizerSlots: [],
107+
dataCallback,
108+
statusManager,
109+
selectorGetter: noSelector,
110+
});
111+
112+
await ds.start();
113+
114+
expect(dataCallback).toHaveBeenCalledTimes(1);
115+
expect(dataCallback).toHaveBeenCalledWith(fullPayload);
116+
ds.close();
117+
});
118+
119+
it('does not mark data received for transfer-none initializer results', async () => {
120+
const dataCallback = jest.fn();
121+
const statusManager = makeStatusManager();
122+
const nonePayload = makePayload({ type: 'none' });
123+
124+
const ds = createFDv2DataSource({
125+
initializerFactories: [makeInitFactory(makeMockInitializer(changeSet(nonePayload, false)))],
126+
synchronizerSlots: [],
127+
dataCallback,
128+
statusManager,
129+
selectorGetter: noSelector,
130+
});
131+
132+
await expect(ds.start()).rejects.toThrow('All data sources exhausted');
133+
expect(dataCallback).not.toHaveBeenCalled();
134+
ds.close();
135+
});
136+
95137
it('continues past initializer errors', async () => {
96138
const dataCallback = jest.fn();
97139
const statusManager = makeStatusManager();

packages/shared/sdk-client/__tests__/datasource/fdv2/orchestrationTestHelpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export function makePayload(
7676
): internal.Payload {
7777
return {
7878
version: 1,
79-
state: opts.state ?? 'test-selector',
79+
...('state' in opts ? { state: opts.state } : { state: 'test-selector' }),
8080
type: opts.type ?? 'full',
8181
updates: [],
8282
};

packages/shared/sdk-client/src/datasource/fdv2/CacheInitializer.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,7 @@ import { Context, Crypto, internal, LDLogger, Storage } from '@launchdarkly/js-s
33
import { readFreshness } from '../../storage/freshness';
44
import { loadCachedFlags } from '../../storage/loadCachedFlags';
55
import { Flag } from '../../types';
6-
import {
7-
changeSet,
8-
errorInfoFromUnknown,
9-
FDv2SourceResult,
10-
interrupted,
11-
shutdown,
12-
} from './FDv2SourceResult';
6+
import { changeSet, FDv2SourceResult, shutdown } from './FDv2SourceResult';
137
import { Initializer } from './Initializer';
148
import { InitializerFactory } from './SourceManager';
159

@@ -50,13 +44,13 @@ async function loadFromCache(config: CacheInitializerConfig): Promise<FDv2Source
5044

5145
if (!storage) {
5246
logger?.debug('No storage available for cache initializer');
53-
return interrupted(errorInfoFromUnknown('No storage available'), false);
47+
return changeSet({ version: 0, type: 'none', updates: [] }, false);
5448
}
5549

5650
const cached = await loadCachedFlags(storage, crypto, environmentNamespace, context, logger);
5751
if (!cached) {
5852
logger?.debug('Cache miss for context');
59-
return interrupted(errorInfoFromUnknown('Cache miss'), false);
53+
return changeSet({ version: 0, type: 'none', updates: [] }, false);
6054
}
6155

6256
const updates: internal.Update[] = Object.entries(cached.flags).map(

packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,18 +145,18 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour
145145
return;
146146
}
147147

148-
if (result.type === 'changeSet') {
148+
if (result.type === 'changeSet' && result.payload.type !== 'none') {
149149
applyChangeSet(result);
150150

151151
if (handleFdv1Fallback(result)) {
152-
// FDv1 fallback triggered during initialization data was received
152+
// FDv1 fallback triggered during initialization -- data was received
153153
// but we should move to synchronizers where the FDv1 adapter will run.
154154
dataReceived = true;
155155
break;
156156
}
157157

158158
if (result.payload.state) {
159-
// Got basis data with a selector initialization is complete.
159+
// Got basis data with a selector -- initialization is complete.
160160
markInitialized();
161161
return;
162162
}

0 commit comments

Comments
 (0)