Skip to content
Open
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
Expand Up @@ -1180,3 +1180,49 @@ it('stops initializer chain when a transfer-none changeSet triggers fdv1 fallbac
expect(dataCallback).toHaveBeenCalledWith(fdv1Payload);
ds.close();
});

// -- FDv1 fallback re-trigger guard (regression: SDK-2617) --

it('does not re-trigger fallback when the fdv1 synchronizer itself yields a fallback-flagged result', async () => {
const dataCallback = jest.fn();
const statusManager = makeStatusManager();
const logger = makeLogger();

const fdv2Payload = makePayload({ state: 'fdv2-selector' });
const fdv1PayloadA = makePayload({ state: 'fdv1-a' });
const fdv1PayloadB = makePayload({ state: 'fdv1-b' });

let fdv2Created = 0;
const fdv2Factory = jest.fn(() => {
fdv2Created += 1;
return makeMockSynchronizer([changeSet(fdv2Payload, { fdv1Fallback: true, fdv1FallbackTtlMs: 0 })]);
});
const fdv1Sync = makeMockSynchronizer([
changeSet(fdv1PayloadA, { fdv1Fallback: true }),
changeSet(fdv1PayloadB, { fdv1Fallback: false }),
]);

const slots: SynchronizerSlot[] = [
createSynchronizerSlot({ create: fdv2Factory }),
createSynchronizerSlot({ create: () => fdv1Sync }, { isFDv1Fallback: true }),
];

const ds = createFDv2DataSource({
initializerFactories: [],
synchronizerSlots: slots,
dataCallback,
statusManager,
selectorGetter: noSelector,
logger,
});

await ds.start();

await statusManager.waitForState('VALID', 3);
expect(dataCallback).toHaveBeenCalledWith(fdv1PayloadA);
expect(dataCallback).toHaveBeenCalledWith(fdv1PayloadB);
expect(fdv2Created).toBe(1);

ds.close();
});

Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,59 @@ it('close prevents further gets', () => {
expect(manager.getNextInitializerAndSetActive()).toBeUndefined();
expect(manager.getNextAvailableSynchronizerAndSetActive()).toBeUndefined();
});

// -- fdv2Recovery and isCurrentSynchronizerFDv1Fallback --

it('fdv2Recovery blocks FDv1 slots and unblocks non-FDv1 slots', () => {
const fdv2Factory = { create: jest.fn(() => ({ next: jest.fn(), close: jest.fn() })) };
const fdv1Factory = { create: jest.fn(() => ({ next: jest.fn(), close: jest.fn() })) };

const slots: SynchronizerSlot[] = [
createSynchronizerSlot(fdv2Factory),
createSynchronizerSlot(fdv1Factory, { isFDv1Fallback: true }),
];

const sm = createSourceManager([], slots, () => undefined);

// Engage FDv1 fallback first
sm.fdv1Fallback();
expect(slots[0].state).toBe('blocked');
expect(slots[1].state).toBe('available');

// Recover: FDv2 unblocked, FDv1 blocked
sm.fdv2Recovery();
expect(slots[0].state).toBe('available');
expect(slots[1].state).toBe('blocked');
});

it('fdv2Recovery resets the synchronizer index so the next selection starts from FDv2', () => {
const fdv2Factory = { create: jest.fn(() => ({ next: jest.fn(), close: jest.fn() })) };
const fdv1Factory = { create: jest.fn(() => ({ next: jest.fn(), close: jest.fn() })) };

const slots: SynchronizerSlot[] = [
createSynchronizerSlot(fdv2Factory),
createSynchronizerSlot(fdv1Factory, { isFDv1Fallback: true }),
];

const sm = createSourceManager([], slots, () => undefined);
sm.fdv1Fallback();

// Advance into the FDv1 slot
sm.getNextAvailableSynchronizerAndSetActive();
expect(sm.isCurrentSynchronizerFDv1Fallback).toBe(true);

// After recovery the FDv2 slot is first
sm.fdv2Recovery();
const next = sm.getNextAvailableSynchronizerAndSetActive();
expect(next).toBeDefined();
expect(sm.isCurrentSynchronizerFDv1Fallback).toBe(false);
});

it('isCurrentSynchronizerFDv1Fallback returns false when synchronizer index is -1 (before first selection)', () => {
const slots: SynchronizerSlot[] = [
createSynchronizerSlot({ create: jest.fn() }, { isFDv1Fallback: true }),
];
const sm = createSourceManager([], slots, () => undefined);
// No selection made yet (index = -1)
expect(sm.isCurrentSynchronizerFDv1Fallback).toBe(false);
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function conditionTimer(timeoutMs: number, type: ConditionType, taskName: string
const timed = cancelableTimedPromise(timeoutMs / 1000, taskName);
return {
promise: timed.promise.then(
() => new Promise<ConditionType>(() => {}), // cancelled never settle
() => new Promise<ConditionType>(() => {}), // cancelled - never settle
() => type, // timeout fired
),
cancel: timed.cancel,
Expand Down Expand Up @@ -109,6 +109,8 @@ function createCondition(
});

function startTimer() {
// idempotent: a fallback condition calls start() on every interrupted status,
// so a timer already running must not be restarted by a later one
if (!timer && !closed) {
timer = conditionTimer(timeoutMs, type, `${type} condition`);
timer.promise.then((t) => {
Expand All @@ -123,7 +125,7 @@ function createCondition(
timer = undefined;
}

// No inform handler start immediately (recovery behavior).
// No inform handler - start immediately (recovery behavior)
if (!informHandler) {
startTimer();
}
Expand All @@ -139,6 +141,8 @@ function createCondition(
},

close() {
// left unresolved on purpose: getConditions() always builds a
// fresh group for the next iteration, so no caller is ever left waiting on this
closed = true;
cancelTimer();
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour
}

function handleFdv1Fallback(result: FDv2SourceResult): boolean {
// Guard: if the FDv1 fallback synchronizer itself produces a result flagged
// fdv1Fallback, do not re-run the fallback machinery - we are already on FDv1.
if (sourceManager.isCurrentSynchronizerFDv1Fallback) {
return false;
}
if (result.fdv1Fallback && sourceManager.hasFDv1Fallback()) {
sourceManager.fdv1Fallback();
return true;
Expand Down Expand Up @@ -255,7 +260,7 @@ export function createFDv2DataSource(config: FDv2DataSourceConfig): FDv2DataSour
logger?.debug('Fallback condition active for current synchronizer.');
}

// try/finally ensures conditions are closed on all code paths.
// Conditions hold timers; close them even if the inner loop throws or breaks early.
let synchronizerRunning = true;
try {
while (!closed && synchronizerRunning) {
Expand Down
21 changes: 21 additions & 0 deletions packages/shared/sdk-client/src/datasource/fdv2/SourceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ export interface SourceManager {
/** Block all non-FDv1 synchronizers and unblock FDv1 synchronizers. */
fdv1Fallback(): void;

/**
* Reverses {@link fdv1Fallback}: blocks all FDv1 fallback synchronizers and
* unblocks non-FDv1 synchronizers, then resets the synchronizer index so the
* next selection starts from the primary FDv2 synchronizer.
*/
fdv2Recovery(): void;

/** True if the currently active synchronizer slot is an FDv1 fallback. */
readonly isCurrentSynchronizerFDv1Fallback: boolean;

/** True if the current synchronizer is the first available (primary). */
isPrimeSynchronizer(): boolean;

Expand Down Expand Up @@ -213,6 +223,17 @@ export function createSourceManager(
synchronizerIndex = -1;
},

fdv2Recovery() {
synchronizerSlots.forEach((slot) => {
slot.state = slot.isFDv1Fallback ? 'blocked' : 'available';
});
synchronizerIndex = -1;
},
Comment on lines +226 to +231

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 New recovery path back to the modern data connection is never used, so a fallback stays permanent

A new way to switch back from the legacy fallback connection to the normal one is added (fdv2Recovery() at packages/shared/sdk-client/src/datasource/fdv2/SourceManager.ts:226-231) but nothing in the product code ever calls it, so once the SDK falls back it stays there for the life of the client.
Impact: Customers whose SDK is temporarily directed to the legacy connection never return to the normal one, even after the directive's expiry time has passed.

Why the recovery path is unreachable

A repo-wide search shows fdv2Recovery and the accompanying isCurrentSynchronizerFDv1Fallback getter are referenced only from packages/shared/sdk-client/__tests__/datasource/fdv2/SourceManager.test.ts:475,497 (plus the guard use of the getter at packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts:137). The orchestrator's recovery branch only calls sourceManager.resetSourceIndex() (packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts:289-291), which does not unblock the FDv2 slots that fdv1Fallback() blocked. Furthermore, after fdv1Fallback() only one slot remains available, so getConditions() (packages/shared/sdk-client/src/datasource/fdv2/Conditions.ts:213-215) returns an empty group and no recovery timer is ever created. The fdv1FallbackTtlMs value carried on results is likewise never consumed by the orchestrator.

Prompt for agents
SourceManager gains fdv2Recovery(), which blocks FDv1 fallback slots, unblocks non-FDv1 slots and resets the synchronizer index, but no production code path calls it. In FDv2DataSource.runSynchronizers the 'recovery' condition branch only calls resetSourceIndex(), and after fdv1Fallback() there is exactly one available synchronizer so getConditions() returns an empty group and no recovery timer is created at all. The fdv1FallbackTtlMs value that sources attach to results is also never read by the orchestrator. Either wire fdv2Recovery() into a TTL-driven recovery path (e.g. schedule a recovery based on fdv1FallbackTtlMs while running on the FDv1 fallback synchronizer, and call fdv2Recovery() when it fires), or drop the unused API until the recovery feature is implemented.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will be handled later in the stack #1875


get isCurrentSynchronizerFDv1Fallback(): boolean {
return synchronizerSlots[synchronizerIndex]?.isFDv1Fallback === true;
},

isPrimeSynchronizer(): boolean {
return synchronizerIndex === findFirstAvailableIndex();
},
Expand Down
Loading