Skip to content

Commit 51ae6a9

Browse files
committed
fix(connectors): stop a reclaimed run from overwriting the reaper's verdict
Review round 2 on #6909. The terminal connector writes were unguarded, so a run that outlived the stale lock could still land its result after the reaper reclaimed it: flipping status back to active, zeroing consecutiveFailures, and erasing a backoff or an auto-disable the breaker had just applied. Both terminal paths now go through a single writer that applies the still-holds-the-lock guard itself, so no future terminal path can be added without it. The knowledge-base-deleted write stays outside deliberately — it runs before the lock is acquired, so the guard would silently discard it. A superseded success is reported as an error rather than a clean sync, matching the treatment lock contention already gets. The failure path deliberately keeps its real error message instead: it already reports failure, so overwriting the cause would lose the diagnostic and gain nothing. Falls out of the same guard: a connector paused mid-sync is no longer flipped back to active by the completing run.
1 parent 6dd65b2 commit 51ae6a9

2 files changed

Lines changed: 252 additions & 33 deletions

File tree

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

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,3 +1091,146 @@ describe('completeSyncLog', () => {
10911091
).toBe(true)
10921092
})
10931093
})
1094+
1095+
describe('stillHoldsSyncLock', () => {
1096+
it('requires the connector to still be syncing', async () => {
1097+
const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine')
1098+
1099+
/**
1100+
* Without this a run reclaimed by the stale sweep still writes its terminal
1101+
* result: clearing the backoff, un-disabling the connector, and resetting a
1102+
* failure counter the sweep just advanced.
1103+
*/
1104+
expect(
1105+
hasMockCondition(
1106+
stillHoldsSyncLock('c-1'),
1107+
(node: MockCondition) =>
1108+
node.type === 'eq' &&
1109+
node.left === schemaMock.knowledgeConnector.status &&
1110+
node.right === 'syncing'
1111+
)
1112+
).toBe(true)
1113+
})
1114+
1115+
it('still scopes to the connector and skips archived or deleted rows', async () => {
1116+
const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine')
1117+
1118+
const condition = stillHoldsSyncLock('c-1')
1119+
1120+
expect(
1121+
hasMockCondition(
1122+
condition,
1123+
(node: MockCondition) =>
1124+
node.type === 'eq' &&
1125+
node.left === schemaMock.knowledgeConnector.id &&
1126+
node.right === 'c-1'
1127+
)
1128+
).toBe(true)
1129+
expect(
1130+
hasMockCondition(
1131+
condition,
1132+
(node: MockCondition) =>
1133+
node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.archivedAt
1134+
)
1135+
).toBe(true)
1136+
expect(
1137+
hasMockCondition(
1138+
condition,
1139+
(node: MockCondition) =>
1140+
node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.deletedAt
1141+
)
1142+
).toBe(true)
1143+
})
1144+
})
1145+
1146+
describe('writeTerminalConnectorState', () => {
1147+
beforeEach(() => {
1148+
vi.clearAllMocks()
1149+
resetDbChainMock()
1150+
})
1151+
1152+
it('applies the sync-lock guard itself so no caller can omit it', async () => {
1153+
const { writeTerminalConnectorState } = await import('@/lib/knowledge/connectors/sync-engine')
1154+
1155+
/**
1156+
* The property that closes the gap a shared-helper-by-convention left open:
1157+
* both terminal paths route through here and neither builds a WHERE clause,
1158+
* so removing the guard is a single-site edit that this assertion catches.
1159+
*/
1160+
await writeTerminalConnectorState('c-1', { status: 'active' })
1161+
1162+
const where = dbChainMockFns.where.mock.calls[0][0]
1163+
expect(
1164+
hasMockCondition(
1165+
where,
1166+
(node: MockCondition) =>
1167+
node.type === 'eq' &&
1168+
node.left === schemaMock.knowledgeConnector.status &&
1169+
node.right === 'syncing'
1170+
)
1171+
).toBe(true)
1172+
expect(
1173+
hasMockCondition(
1174+
where,
1175+
(node: MockCondition) =>
1176+
node.type === 'eq' &&
1177+
node.left === schemaMock.knowledgeConnector.id &&
1178+
node.right === 'c-1'
1179+
)
1180+
).toBe(true)
1181+
})
1182+
1183+
it('passes the caller values through untouched', async () => {
1184+
const { writeTerminalConnectorState } = await import('@/lib/knowledge/connectors/sync-engine')
1185+
1186+
const values = { status: 'error', consecutiveFailures: 4, nextSyncAt: null }
1187+
await writeTerminalConnectorState('c-1', values)
1188+
1189+
expect(dbChainMockFns.set.mock.calls[0][0]).toEqual(values)
1190+
})
1191+
1192+
it('reports whether the write landed', async () => {
1193+
const { writeTerminalConnectorState } = await import('@/lib/knowledge/connectors/sync-engine')
1194+
1195+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }])
1196+
expect(await writeTerminalConnectorState('c-1', { status: 'active' })).toBe(true)
1197+
1198+
dbChainMockFns.returning.mockResolvedValueOnce([])
1199+
expect(await writeTerminalConnectorState('c-1', { status: 'active' })).toBe(false)
1200+
})
1201+
})
1202+
1203+
describe('applySupersededOutcome', () => {
1204+
const result = {
1205+
docsAdded: 3,
1206+
docsUpdated: 1,
1207+
docsDeleted: 0,
1208+
docsUnchanged: 2,
1209+
docsFailed: 0,
1210+
}
1211+
1212+
it('leaves a run that kept its lock untouched', async () => {
1213+
const { applySupersededOutcome } = await import('@/lib/knowledge/connectors/sync-engine')
1214+
1215+
expect(applySupersededOutcome(result, true)).toEqual(result)
1216+
})
1217+
1218+
it('flags a discarded run so the task wrapper does not report it as clean', async () => {
1219+
const { applySupersededOutcome, SUPERSEDED_SYNC_ERROR } = await import(
1220+
'@/lib/knowledge/connectors/sync-engine'
1221+
)
1222+
1223+
const superseded = applySupersededOutcome(result, false)
1224+
1225+
// The task wrapper reports `success: !result.error`.
1226+
expect(superseded.error).toBe(SUPERSEDED_SYNC_ERROR)
1227+
expect(Boolean(superseded.error)).toBe(true)
1228+
})
1229+
1230+
it('preserves the document counters of the discarded run', async () => {
1231+
const { applySupersededOutcome } = await import('@/lib/knowledge/connectors/sync-engine')
1232+
1233+
// Those writes landed — only the connector-level bookkeeping was discarded.
1234+
expect(applySupersededOutcome(result, false)).toMatchObject(result)
1235+
})
1236+
})

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

Lines changed: 109 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,76 @@ export async function completeSyncLog(
334334
)
335335
}
336336

337+
/**
338+
* Matches the connector row only while this run still holds its sync lock.
339+
*
340+
* `executeSync` sets `status = 'syncing'` when it acquires the lock, so that
341+
* value means "I am still the writer". Anything else means another actor took
342+
* the row: the scheduler's stale sweep reclaimed it to `error`/`disabled` and
343+
* may already have dispatched a replacement, or a user paused it. In every such
344+
* case this run's terminal write must not land — otherwise it clears a backoff
345+
* the breaker just set, un-disables a connector, or flips a paused connector
346+
* back to `active`.
347+
*
348+
* Guards both terminal paths. The failure path needs it as much as the success
349+
* path: a reclaimed run's failure would double-increment a counter the sweep
350+
* already advanced and overwrite its backoff with a shorter one.
351+
*/
352+
export function stillHoldsSyncLock(connectorId: string) {
353+
return and(
354+
eq(knowledgeConnector.id, connectorId),
355+
eq(knowledgeConnector.status, 'syncing'),
356+
isNull(knowledgeConnector.archivedAt),
357+
isNull(knowledgeConnector.deletedAt)
358+
)
359+
}
360+
361+
/** Columns a terminal write may set. Both paths write a subset of the same set. */
362+
type ConnectorTerminalUpdate = Partial<typeof knowledgeConnector.$inferInsert>
363+
364+
/**
365+
* The only way a sync run writes its terminal state onto the connector row.
366+
*
367+
* Callers pass their own values and never build a WHERE clause: the
368+
* {@link stillHoldsSyncLock} guard is applied here, so there is exactly one
369+
* place it can be removed from and a terminal path added later cannot forget
370+
* it. Returns whether the write landed — false means the run was reclaimed
371+
* mid-flight and its bookkeeping was discarded in favour of whoever took the
372+
* row.
373+
*/
374+
export async function writeTerminalConnectorState(
375+
connectorId: string,
376+
values: ConnectorTerminalUpdate
377+
): Promise<boolean> {
378+
const written = await db
379+
.update(knowledgeConnector)
380+
.set(values)
381+
.where(stillHoldsSyncLock(connectorId))
382+
.returning({ id: knowledgeConnector.id })
383+
384+
return written.length > 0
385+
}
386+
387+
/**
388+
* Reported when a run's terminal write matched no rows because the run no longer
389+
* held its lock. Its document writes still landed; only its connector-level
390+
* bookkeeping was discarded, in favour of whoever reclaimed the row.
391+
*/
392+
export const SUPERSEDED_SYNC_ERROR = 'sync_superseded'
393+
394+
/**
395+
* Marks a superseded run so the task wrapper's `success: !result.error` does not
396+
* report a discarded run as a clean sync — the same reason a lock-contended run
397+
* returns `sync_in_progress` rather than an empty success.
398+
*/
399+
export function applySupersededOutcome(
400+
result: SyncResult,
401+
terminalWriteLanded: boolean
402+
): SyncResult {
403+
if (terminalWriteLanded) return result
404+
return { ...result, error: SUPERSEDED_SYNC_ERROR }
405+
}
406+
337407
/**
338408
* Decides whether deletion reconciliation may run for a sync.
339409
*
@@ -1791,23 +1861,24 @@ export async function executeSync(
17911861
)
17921862

17931863
const now = new Date()
1794-
await db
1795-
.update(knowledgeConnector)
1796-
.set(
1797-
buildSyncSuccessUpdate(
1798-
now,
1799-
actualDocCount,
1800-
calculateNextSyncTime(connector.syncIntervalMinutes),
1801-
reconciliationHoldNotice
1802-
)
1803-
)
1804-
.where(
1805-
and(
1806-
eq(knowledgeConnector.id, connectorId),
1807-
isNull(knowledgeConnector.archivedAt),
1808-
isNull(knowledgeConnector.deletedAt)
1809-
)
1864+
const successWriteLanded = await writeTerminalConnectorState(
1865+
connectorId,
1866+
buildSyncSuccessUpdate(
1867+
now,
1868+
actualDocCount,
1869+
calculateNextSyncTime(connector.syncIntervalMinutes),
1870+
reconciliationHoldNotice
18101871
)
1872+
)
1873+
1874+
if (!successWriteLanded) {
1875+
logger.warn('Sync result discarded — connector was reclaimed while this run was executing', {
1876+
connectorId,
1877+
syncLogId,
1878+
...result,
1879+
})
1880+
return applySupersededOutcome(result, false)
1881+
}
18111882

18121883
logger.info('Sync completed', { connectorId, ...result })
18131884
return result
@@ -1860,24 +1931,29 @@ export async function executeSync(
18601931
})
18611932
}
18621933

1863-
await db
1864-
.update(knowledgeConnector)
1865-
.set({
1866-
status: disabled ? 'disabled' : 'error',
1867-
lastSyncError: disabled
1868-
? 'Connector disabled after repeated sync failures. Please reconnect.'
1869-
: errorMessage,
1870-
nextSyncAt: nextSync,
1871-
consecutiveFailures: failures,
1872-
updatedAt: now,
1873-
})
1874-
.where(
1875-
and(
1876-
eq(knowledgeConnector.id, connectorId),
1877-
isNull(knowledgeConnector.archivedAt),
1878-
isNull(knowledgeConnector.deletedAt)
1879-
)
1934+
const failureWriteLanded = await writeTerminalConnectorState(connectorId, {
1935+
status: disabled ? 'disabled' : 'error',
1936+
lastSyncError: disabled
1937+
? 'Connector disabled after repeated sync failures. Please reconnect.'
1938+
: errorMessage,
1939+
nextSyncAt: nextSync,
1940+
consecutiveFailures: failures,
1941+
updatedAt: now,
1942+
})
1943+
1944+
/**
1945+
* Deliberately does NOT get {@link applySupersededOutcome}. `result.error`
1946+
* is set to the real failure cause below and the task wrapper already
1947+
* reports this run as unsuccessful, so overwriting it with
1948+
* `sync_superseded` would destroy the diagnostic without changing the
1949+
* reported outcome. The supersession is carried by this log line instead.
1950+
*/
1951+
if (!failureWriteLanded) {
1952+
logger.warn(
1953+
'Sync failure discarded — connector was reclaimed while this run was executing',
1954+
{ connectorId, syncLogId, error: errorMessage }
18801955
)
1956+
}
18811957
} catch (recoveryError) {
18821958
logger.error('Failed to record sync failure', {
18831959
connectorId,

0 commit comments

Comments
 (0)