Skip to content

Commit 3c0a07b

Browse files
committed
test(connectors): pin the failure ladder on both sides and cover the disable path
Review round 5 on #6909. The nextSyncAt test recomputed its expected interval from the SQL's own binds and compared against the helper using those same constants, so both sides derived from one source and the assertion held for any values. It pinned the rendered SQL text but nothing about SQL-JS equivalence: a consistent refactor of both, or SQL whose text was right but whose semantics diverged, would have passed. Both sides now assert concrete values, so neither can move alone. The hold notice was checked with independent substring matches on distinct digits, so swapping the withheld count and the cap produced an inverted, misleading operator message that still passed. Now pinned whole, plus an assertion that the two orderings differ. Extracted buildSyncFailureUpdate to mirror the success path, covering the in-process ladder, a null counter treated as a first failure, the disable firing exactly at the threshold rather than one early, and the ownership token released on both outcomes. That is the path the disable ratchet runs through and it was previously covered only on the reaper's SQL side.
1 parent 68b82f3 commit 3c0a07b

3 files changed

Lines changed: 161 additions & 28 deletions

File tree

apps/sim/app/api/knowledge/connectors/sync/route.test.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
import type { NextRequest } from 'next/server'
1818
import { beforeEach, describe, expect, it, vi } from 'vitest'
1919
import {
20+
CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES,
21+
CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES,
2022
connectorFailureBackoffMinutes,
2123
MAX_CONSECUTIVE_FAILURES,
2224
} from '@/lib/knowledge/connectors/sync-limits'
@@ -133,12 +135,47 @@ describe('connector sync scheduler stale-lock reaper', () => {
133135

134136
const [threshold, step, cap] = numericBinds(nextSyncAt)
135137
expect(threshold).toBe(MAX_CONSECUTIVE_FAILURES)
138+
expect(step).toBe(CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES)
139+
expect(cap).toBe(CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES)
136140

137-
// Recomputing the ladder from the binds the SQL actually carries makes this
138-
// fail the moment the route and `connectorFailureBackoffMinutes` drift apart.
139-
for (const failures of [1, 2, 5, 10, 47, 48, 100]) {
140-
expect(Math.min(failures * step, cap)).toBe(connectorFailureBackoffMinutes(failures))
141-
}
141+
/**
142+
* Pinned to literals, not recomputed from the binds. Comparing
143+
* `Math.min(failures * step, cap)` against `connectorFailureBackoffMinutes`
144+
* derived both sides from the same two constants, so it held for any values
145+
* AND any shape — swapping the SQL's `*` for `+` left every substring and
146+
* every bind untouched. The shape is pinned by the string assertion above;
147+
* these pin the magnitudes independently of both the SQL and the helper.
148+
*/
149+
expect(step).toBe(30)
150+
expect(cap).toBe(1440)
151+
})
152+
153+
it('applies the same minutes in SQL that the shared helper computes in JS', async () => {
154+
/**
155+
* The equivalence the ladder test above only appeared to establish. The SQL
156+
* encodes `LEAST((failures) * 30, 1440)`; these fix what the JS helper
157+
* returns for the same inputs, so the two cannot drift without one of the
158+
* two assertions failing.
159+
*/
160+
expect(connectorFailureBackoffMinutes(1)).toBe(30)
161+
expect(connectorFailureBackoffMinutes(2)).toBe(60)
162+
expect(connectorFailureBackoffMinutes(3)).toBe(90)
163+
expect(connectorFailureBackoffMinutes(9)).toBe(270)
164+
// 48 * 30 is exactly the cap; either side of it must clamp, not overshoot.
165+
expect(connectorFailureBackoffMinutes(47)).toBe(1410)
166+
expect(connectorFailureBackoffMinutes(48)).toBe(1440)
167+
expect(connectorFailureBackoffMinutes(49)).toBe(1440)
168+
expect(connectorFailureBackoffMinutes(100)).toBe(1440)
169+
})
170+
171+
it('releases the reclaimed run ownership token', async () => {
172+
await runTickRecovering(['connector-1'])
173+
174+
/**
175+
* Without this the reclaimed run's token still matches its own terminal
176+
* write, so it can overwrite the verdict this reclaim just recorded.
177+
*/
178+
expect(setPayloadForUpdate(0).syncLockToken).toBeNull()
142179
})
143180

144181
it('does not stamp lastSyncAt when reclaiming a stale lock', async () => {

apps/sim/lib/knowledge/connectors/sync-engine.test.ts

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,15 +1245,83 @@ describe('countDeletionEligibleOwned', () => {
12451245
})
12461246

12471247
describe('buildReconciliationHoldNotice', () => {
1248-
it('names the counts and the full-sync remedy', async () => {
1248+
it('places each count in its own role', async () => {
12491249
const { buildReconciliationHoldNotice } = await import('@/lib/knowledge/connectors/sync-engine')
12501250

1251-
const notice = buildReconciliationHoldNotice(500, 250, 1000)
1251+
/**
1252+
* Asserted whole rather than by three independent `toContain` checks on
1253+
* distinct digit strings: those passed even with the first two arguments
1254+
* swapped, which inverts the message into "withheld 250 — more than the 500
1255+
* allowed" and misleads the operator it exists to inform.
1256+
*/
1257+
expect(buildReconciliationHoldNotice(500, 250, 1000)).toBe(
1258+
'Withheld 500 document removal(s) — more than the 250 allowed in one sync ' +
1259+
'of 1000 documents. Documents deleted at the source are still indexed. ' +
1260+
'Check the source is returning its full contents, then run a full sync to apply the removals.'
1261+
)
1262+
})
1263+
1264+
it('cannot be satisfied by swapping the withheld and cap counts', async () => {
1265+
const { buildReconciliationHoldNotice } = await import('@/lib/knowledge/connectors/sync-engine')
1266+
1267+
expect(buildReconciliationHoldNotice(500, 250, 1000)).not.toBe(
1268+
buildReconciliationHoldNotice(250, 500, 1000)
1269+
)
1270+
})
1271+
})
1272+
1273+
describe('buildSyncFailureUpdate', () => {
1274+
const now = new Date('2026-08-20T00:00:00.000Z')
1275+
const minutesAfter = (mins: number) => new Date(now.getTime() + mins * 60 * 1000)
1276+
1277+
it('backs off on the shared ladder below the threshold', async () => {
1278+
const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
1279+
1280+
const first = buildSyncFailureUpdate(now, 0, 'boom')
1281+
expect(first.status).toBe('error')
1282+
expect(first.consecutiveFailures).toBe(1)
1283+
expect(first.lastSyncError).toBe('boom')
1284+
expect(first.nextSyncAt).toEqual(minutesAfter(30))
1285+
1286+
const third = buildSyncFailureUpdate(now, 2, 'boom')
1287+
expect(third.consecutiveFailures).toBe(3)
1288+
expect(third.nextSyncAt).toEqual(minutesAfter(90))
1289+
})
1290+
1291+
it('treats a null counter as a first failure', async () => {
1292+
const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
1293+
1294+
expect(buildSyncFailureUpdate(now, null, 'boom').consecutiveFailures).toBe(1)
1295+
expect(buildSyncFailureUpdate(now, undefined, 'boom').nextSyncAt).toEqual(minutesAfter(30))
1296+
})
1297+
1298+
it('disables exactly at the threshold, not before it', async () => {
1299+
const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
1300+
const { MAX_CONSECUTIVE_FAILURES } = await import('@/lib/knowledge/connectors/sync-limits')
1301+
1302+
/**
1303+
* The path the auto-disable breaker actually runs through in-process. Only
1304+
* the reaper's SQL equivalent was covered before, so an off-by-one here —
1305+
* disabling a connector one failure early — was invisible.
1306+
*/
1307+
const below = buildSyncFailureUpdate(now, MAX_CONSECUTIVE_FAILURES - 2, 'boom')
1308+
expect(below.status).toBe('error')
1309+
expect(below.consecutiveFailures).toBe(MAX_CONSECUTIVE_FAILURES - 1)
1310+
expect(below.nextSyncAt).not.toBeNull()
1311+
1312+
const at = buildSyncFailureUpdate(now, MAX_CONSECUTIVE_FAILURES - 1, 'boom')
1313+
expect(at.status).toBe('disabled')
1314+
expect(at.consecutiveFailures).toBe(MAX_CONSECUTIVE_FAILURES)
1315+
expect(at.nextSyncAt).toBeNull()
1316+
expect(at.lastSyncError).toContain('reconnect')
1317+
})
1318+
1319+
it('releases the ownership token on both outcomes', async () => {
1320+
const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
1321+
const { MAX_CONSECUTIVE_FAILURES } = await import('@/lib/knowledge/connectors/sync-limits')
12521322

1253-
expect(notice).toContain('500')
1254-
expect(notice).toContain('250')
1255-
expect(notice).toContain('1000')
1256-
expect(notice).toContain('full sync')
1323+
expect(buildSyncFailureUpdate(now, 0, 'boom').syncLockToken).toBeNull()
1324+
expect(buildSyncFailureUpdate(now, MAX_CONSECUTIVE_FAILURES, 'boom').syncLockToken).toBeNull()
12571325
})
12581326
})
12591327

apps/sim/lib/knowledge/connectors/sync-engine.ts

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,39 @@ export function buildReconciliationHoldNotice(
772772
)
773773
}
774774

775+
/**
776+
* The connector row a failed sync writes.
777+
*
778+
* Extracted for the same reason as {@link buildSyncSuccessUpdate}: this is the
779+
* path the auto-disable breaker runs through, so the threshold and the backoff
780+
* it applies need to be assertable without standing up the whole sync. The
781+
* in-process ladder here and the reaper's SQL ladder must agree — they are two
782+
* writers of one policy, both sourced from
783+
* {@link connectorFailureBackoffMinutes}.
784+
*/
785+
export function buildSyncFailureUpdate(
786+
now: Date,
787+
previousFailures: number | null | undefined,
788+
errorMessage: string
789+
) {
790+
const failures = (previousFailures ?? 0) + 1
791+
const disabled = failures >= MAX_CONSECUTIVE_FAILURES
792+
793+
return {
794+
status: (disabled ? 'disabled' : 'error') as 'disabled' | 'error',
795+
lastSyncError: disabled
796+
? 'Connector disabled after repeated sync failures. Please reconnect.'
797+
: errorMessage,
798+
nextSyncAt: disabled
799+
? null
800+
: new Date(now.getTime() + connectorFailureBackoffMinutes(failures) * 60 * 1000),
801+
consecutiveFailures: failures,
802+
// Releases the lock so a stale token can never match a later run.
803+
syncLockToken: null,
804+
updatedAt: now,
805+
}
806+
}
807+
775808
/**
776809
* The connector row a successful sync writes.
777810
*
@@ -2177,29 +2210,24 @@ export async function executeSync(
21772210
try {
21782211
await completeSyncLog(syncLogId, 'failed', result, errorMessage)
21792212

2180-
const now = new Date()
2181-
const failures = (connector.consecutiveFailures ?? 0) + 1
2182-
const disabled = failures >= MAX_CONSECUTIVE_FAILURES
2183-
const backoffMinutes = connectorFailureBackoffMinutes(failures)
2184-
const nextSync = disabled ? null : new Date(now.getTime() + backoffMinutes * 60 * 1000)
2213+
const failureUpdate = buildSyncFailureUpdate(
2214+
new Date(),
2215+
connector.consecutiveFailures,
2216+
errorMessage
2217+
)
21852218

2186-
if (disabled) {
2219+
if (failureUpdate.status === 'disabled') {
21872220
logger.warn('Connector disabled after repeated failures', {
21882221
connectorId,
2189-
consecutiveFailures: failures,
2222+
consecutiveFailures: failureUpdate.consecutiveFailures,
21902223
})
21912224
}
21922225

2193-
const failureWriteLanded = await writeTerminalConnectorState(connectorId, syncLogId, {
2194-
status: disabled ? 'disabled' : 'error',
2195-
lastSyncError: disabled
2196-
? 'Connector disabled after repeated sync failures. Please reconnect.'
2197-
: errorMessage,
2198-
nextSyncAt: nextSync,
2199-
consecutiveFailures: failures,
2200-
syncLockToken: null,
2201-
updatedAt: now,
2202-
})
2226+
const failureWriteLanded = await writeTerminalConnectorState(
2227+
connectorId,
2228+
syncLogId,
2229+
failureUpdate
2230+
)
22032231

22042232
/**
22052233
* Deliberately does NOT get {@link applySupersededOutcome}. `result.error`

0 commit comments

Comments
 (0)