Skip to content

Commit 1dffbf7

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 3530024 commit 1dffbf7

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
@@ -1059,15 +1059,83 @@ describe('countDeletionEligibleOwned', () => {
10591059
})
10601060

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

1065-
const notice = buildReconciliationHoldNotice(500, 250, 1000)
1065+
/**
1066+
* Asserted whole rather than by three independent `toContain` checks on
1067+
* distinct digit strings: those passed even with the first two arguments
1068+
* swapped, which inverts the message into "withheld 250 — more than the 500
1069+
* allowed" and misleads the operator it exists to inform.
1070+
*/
1071+
expect(buildReconciliationHoldNotice(500, 250, 1000)).toBe(
1072+
'Withheld 500 document removal(s) — more than the 250 allowed in one sync ' +
1073+
'of 1000 documents. Documents deleted at the source are still indexed. ' +
1074+
'Check the source is returning its full contents, then run a full sync to apply the removals.'
1075+
)
1076+
})
1077+
1078+
it('cannot be satisfied by swapping the withheld and cap counts', async () => {
1079+
const { buildReconciliationHoldNotice } = await import('@/lib/knowledge/connectors/sync-engine')
1080+
1081+
expect(buildReconciliationHoldNotice(500, 250, 1000)).not.toBe(
1082+
buildReconciliationHoldNotice(250, 500, 1000)
1083+
)
1084+
})
1085+
})
1086+
1087+
describe('buildSyncFailureUpdate', () => {
1088+
const now = new Date('2026-08-20T00:00:00.000Z')
1089+
const minutesAfter = (mins: number) => new Date(now.getTime() + mins * 60 * 1000)
1090+
1091+
it('backs off on the shared ladder below the threshold', async () => {
1092+
const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
1093+
1094+
const first = buildSyncFailureUpdate(now, 0, 'boom')
1095+
expect(first.status).toBe('error')
1096+
expect(first.consecutiveFailures).toBe(1)
1097+
expect(first.lastSyncError).toBe('boom')
1098+
expect(first.nextSyncAt).toEqual(minutesAfter(30))
1099+
1100+
const third = buildSyncFailureUpdate(now, 2, 'boom')
1101+
expect(third.consecutiveFailures).toBe(3)
1102+
expect(third.nextSyncAt).toEqual(minutesAfter(90))
1103+
})
1104+
1105+
it('treats a null counter as a first failure', async () => {
1106+
const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
1107+
1108+
expect(buildSyncFailureUpdate(now, null, 'boom').consecutiveFailures).toBe(1)
1109+
expect(buildSyncFailureUpdate(now, undefined, 'boom').nextSyncAt).toEqual(minutesAfter(30))
1110+
})
1111+
1112+
it('disables exactly at the threshold, not before it', async () => {
1113+
const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
1114+
const { MAX_CONSECUTIVE_FAILURES } = await import('@/lib/knowledge/connectors/sync-limits')
1115+
1116+
/**
1117+
* The path the auto-disable breaker actually runs through in-process. Only
1118+
* the reaper's SQL equivalent was covered before, so an off-by-one here —
1119+
* disabling a connector one failure early — was invisible.
1120+
*/
1121+
const below = buildSyncFailureUpdate(now, MAX_CONSECUTIVE_FAILURES - 2, 'boom')
1122+
expect(below.status).toBe('error')
1123+
expect(below.consecutiveFailures).toBe(MAX_CONSECUTIVE_FAILURES - 1)
1124+
expect(below.nextSyncAt).not.toBeNull()
1125+
1126+
const at = buildSyncFailureUpdate(now, MAX_CONSECUTIVE_FAILURES - 1, 'boom')
1127+
expect(at.status).toBe('disabled')
1128+
expect(at.consecutiveFailures).toBe(MAX_CONSECUTIVE_FAILURES)
1129+
expect(at.nextSyncAt).toBeNull()
1130+
expect(at.lastSyncError).toContain('reconnect')
1131+
})
1132+
1133+
it('releases the ownership token on both outcomes', async () => {
1134+
const { buildSyncFailureUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
1135+
const { MAX_CONSECUTIVE_FAILURES } = await import('@/lib/knowledge/connectors/sync-limits')
10661136

1067-
expect(notice).toContain('500')
1068-
expect(notice).toContain('250')
1069-
expect(notice).toContain('1000')
1070-
expect(notice).toContain('full sync')
1137+
expect(buildSyncFailureUpdate(now, 0, 'boom').syncLockToken).toBeNull()
1138+
expect(buildSyncFailureUpdate(now, MAX_CONSECUTIVE_FAILURES, 'boom').syncLockToken).toBeNull()
10711139
})
10721140
})
10731141

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

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,39 @@ export function buildReconciliationHoldNotice(
659659
)
660660
}
661661

662+
/**
663+
* The connector row a failed sync writes.
664+
*
665+
* Extracted for the same reason as {@link buildSyncSuccessUpdate}: this is the
666+
* path the auto-disable breaker runs through, so the threshold and the backoff
667+
* it applies need to be assertable without standing up the whole sync. The
668+
* in-process ladder here and the reaper's SQL ladder must agree — they are two
669+
* writers of one policy, both sourced from
670+
* {@link connectorFailureBackoffMinutes}.
671+
*/
672+
export function buildSyncFailureUpdate(
673+
now: Date,
674+
previousFailures: number | null | undefined,
675+
errorMessage: string
676+
) {
677+
const failures = (previousFailures ?? 0) + 1
678+
const disabled = failures >= MAX_CONSECUTIVE_FAILURES
679+
680+
return {
681+
status: (disabled ? 'disabled' : 'error') as 'disabled' | 'error',
682+
lastSyncError: disabled
683+
? 'Connector disabled after repeated sync failures. Please reconnect.'
684+
: errorMessage,
685+
nextSyncAt: disabled
686+
? null
687+
: new Date(now.getTime() + connectorFailureBackoffMinutes(failures) * 60 * 1000),
688+
consecutiveFailures: failures,
689+
// Releases the lock so a stale token can never match a later run.
690+
syncLockToken: null,
691+
updatedAt: now,
692+
}
693+
}
694+
662695
/**
663696
* The connector row a successful sync writes.
664697
*
@@ -2055,29 +2088,24 @@ export async function executeSync(
20552088
try {
20562089
await completeSyncLog(syncLogId, 'failed', result, errorMessage)
20572090

2058-
const now = new Date()
2059-
const failures = (connector.consecutiveFailures ?? 0) + 1
2060-
const disabled = failures >= MAX_CONSECUTIVE_FAILURES
2061-
const backoffMinutes = connectorFailureBackoffMinutes(failures)
2062-
const nextSync = disabled ? null : new Date(now.getTime() + backoffMinutes * 60 * 1000)
2091+
const failureUpdate = buildSyncFailureUpdate(
2092+
new Date(),
2093+
connector.consecutiveFailures,
2094+
errorMessage
2095+
)
20632096

2064-
if (disabled) {
2097+
if (failureUpdate.status === 'disabled') {
20652098
logger.warn('Connector disabled after repeated failures', {
20662099
connectorId,
2067-
consecutiveFailures: failures,
2100+
consecutiveFailures: failureUpdate.consecutiveFailures,
20682101
})
20692102
}
20702103

2071-
const failureWriteLanded = await writeTerminalConnectorState(connectorId, syncLogId, {
2072-
status: disabled ? 'disabled' : 'error',
2073-
lastSyncError: disabled
2074-
? 'Connector disabled after repeated sync failures. Please reconnect.'
2075-
: errorMessage,
2076-
nextSyncAt: nextSync,
2077-
consecutiveFailures: failures,
2078-
syncLockToken: null,
2079-
updatedAt: now,
2080-
})
2104+
const failureWriteLanded = await writeTerminalConnectorState(
2105+
connectorId,
2106+
syncLogId,
2107+
failureUpdate
2108+
)
20812109

20822110
/**
20832111
* Deliberately does NOT get {@link applySupersededOutcome}. `result.error`

0 commit comments

Comments
 (0)