Skip to content

Commit f7d68dc

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 db08310 commit f7d68dc

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
@@ -1277,3 +1277,146 @@ describe('completeSyncLog', () => {
12771277
).toBe(true)
12781278
})
12791279
})
1280+
1281+
describe('stillHoldsSyncLock', () => {
1282+
it('requires the connector to still be syncing', async () => {
1283+
const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine')
1284+
1285+
/**
1286+
* Without this a run reclaimed by the stale sweep still writes its terminal
1287+
* result: clearing the backoff, un-disabling the connector, and resetting a
1288+
* failure counter the sweep just advanced.
1289+
*/
1290+
expect(
1291+
hasMockCondition(
1292+
stillHoldsSyncLock('c-1'),
1293+
(node: MockCondition) =>
1294+
node.type === 'eq' &&
1295+
node.left === schemaMock.knowledgeConnector.status &&
1296+
node.right === 'syncing'
1297+
)
1298+
).toBe(true)
1299+
})
1300+
1301+
it('still scopes to the connector and skips archived or deleted rows', async () => {
1302+
const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine')
1303+
1304+
const condition = stillHoldsSyncLock('c-1')
1305+
1306+
expect(
1307+
hasMockCondition(
1308+
condition,
1309+
(node: MockCondition) =>
1310+
node.type === 'eq' &&
1311+
node.left === schemaMock.knowledgeConnector.id &&
1312+
node.right === 'c-1'
1313+
)
1314+
).toBe(true)
1315+
expect(
1316+
hasMockCondition(
1317+
condition,
1318+
(node: MockCondition) =>
1319+
node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.archivedAt
1320+
)
1321+
).toBe(true)
1322+
expect(
1323+
hasMockCondition(
1324+
condition,
1325+
(node: MockCondition) =>
1326+
node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.deletedAt
1327+
)
1328+
).toBe(true)
1329+
})
1330+
})
1331+
1332+
describe('writeTerminalConnectorState', () => {
1333+
beforeEach(() => {
1334+
vi.clearAllMocks()
1335+
resetDbChainMock()
1336+
})
1337+
1338+
it('applies the sync-lock guard itself so no caller can omit it', async () => {
1339+
const { writeTerminalConnectorState } = await import('@/lib/knowledge/connectors/sync-engine')
1340+
1341+
/**
1342+
* The property that closes the gap a shared-helper-by-convention left open:
1343+
* both terminal paths route through here and neither builds a WHERE clause,
1344+
* so removing the guard is a single-site edit that this assertion catches.
1345+
*/
1346+
await writeTerminalConnectorState('c-1', { status: 'active' })
1347+
1348+
const where = dbChainMockFns.where.mock.calls[0][0]
1349+
expect(
1350+
hasMockCondition(
1351+
where,
1352+
(node: MockCondition) =>
1353+
node.type === 'eq' &&
1354+
node.left === schemaMock.knowledgeConnector.status &&
1355+
node.right === 'syncing'
1356+
)
1357+
).toBe(true)
1358+
expect(
1359+
hasMockCondition(
1360+
where,
1361+
(node: MockCondition) =>
1362+
node.type === 'eq' &&
1363+
node.left === schemaMock.knowledgeConnector.id &&
1364+
node.right === 'c-1'
1365+
)
1366+
).toBe(true)
1367+
})
1368+
1369+
it('passes the caller values through untouched', async () => {
1370+
const { writeTerminalConnectorState } = await import('@/lib/knowledge/connectors/sync-engine')
1371+
1372+
const values = { status: 'error', consecutiveFailures: 4, nextSyncAt: null }
1373+
await writeTerminalConnectorState('c-1', values)
1374+
1375+
expect(dbChainMockFns.set.mock.calls[0][0]).toEqual(values)
1376+
})
1377+
1378+
it('reports whether the write landed', async () => {
1379+
const { writeTerminalConnectorState } = await import('@/lib/knowledge/connectors/sync-engine')
1380+
1381+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }])
1382+
expect(await writeTerminalConnectorState('c-1', { status: 'active' })).toBe(true)
1383+
1384+
dbChainMockFns.returning.mockResolvedValueOnce([])
1385+
expect(await writeTerminalConnectorState('c-1', { status: 'active' })).toBe(false)
1386+
})
1387+
})
1388+
1389+
describe('applySupersededOutcome', () => {
1390+
const result = {
1391+
docsAdded: 3,
1392+
docsUpdated: 1,
1393+
docsDeleted: 0,
1394+
docsUnchanged: 2,
1395+
docsFailed: 0,
1396+
}
1397+
1398+
it('leaves a run that kept its lock untouched', async () => {
1399+
const { applySupersededOutcome } = await import('@/lib/knowledge/connectors/sync-engine')
1400+
1401+
expect(applySupersededOutcome(result, true)).toEqual(result)
1402+
})
1403+
1404+
it('flags a discarded run so the task wrapper does not report it as clean', async () => {
1405+
const { applySupersededOutcome, SUPERSEDED_SYNC_ERROR } = await import(
1406+
'@/lib/knowledge/connectors/sync-engine'
1407+
)
1408+
1409+
const superseded = applySupersededOutcome(result, false)
1410+
1411+
// The task wrapper reports `success: !result.error`.
1412+
expect(superseded.error).toBe(SUPERSEDED_SYNC_ERROR)
1413+
expect(Boolean(superseded.error)).toBe(true)
1414+
})
1415+
1416+
it('preserves the document counters of the discarded run', async () => {
1417+
const { applySupersededOutcome } = await import('@/lib/knowledge/connectors/sync-engine')
1418+
1419+
// Those writes landed — only the connector-level bookkeeping was discarded.
1420+
expect(applySupersededOutcome(result, false)).toMatchObject(result)
1421+
})
1422+
})

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

Lines changed: 109 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,76 @@ export async function completeSyncLog(
447447
)
448448
}
449449

450+
/**
451+
* Matches the connector row only while this run still holds its sync lock.
452+
*
453+
* `executeSync` sets `status = 'syncing'` when it acquires the lock, so that
454+
* value means "I am still the writer". Anything else means another actor took
455+
* the row: the scheduler's stale sweep reclaimed it to `error`/`disabled` and
456+
* may already have dispatched a replacement, or a user paused it. In every such
457+
* case this run's terminal write must not land — otherwise it clears a backoff
458+
* the breaker just set, un-disables a connector, or flips a paused connector
459+
* back to `active`.
460+
*
461+
* Guards both terminal paths. The failure path needs it as much as the success
462+
* path: a reclaimed run's failure would double-increment a counter the sweep
463+
* already advanced and overwrite its backoff with a shorter one.
464+
*/
465+
export function stillHoldsSyncLock(connectorId: string) {
466+
return and(
467+
eq(knowledgeConnector.id, connectorId),
468+
eq(knowledgeConnector.status, 'syncing'),
469+
isNull(knowledgeConnector.archivedAt),
470+
isNull(knowledgeConnector.deletedAt)
471+
)
472+
}
473+
474+
/** Columns a terminal write may set. Both paths write a subset of the same set. */
475+
type ConnectorTerminalUpdate = Partial<typeof knowledgeConnector.$inferInsert>
476+
477+
/**
478+
* The only way a sync run writes its terminal state onto the connector row.
479+
*
480+
* Callers pass their own values and never build a WHERE clause: the
481+
* {@link stillHoldsSyncLock} guard is applied here, so there is exactly one
482+
* place it can be removed from and a terminal path added later cannot forget
483+
* it. Returns whether the write landed — false means the run was reclaimed
484+
* mid-flight and its bookkeeping was discarded in favour of whoever took the
485+
* row.
486+
*/
487+
export async function writeTerminalConnectorState(
488+
connectorId: string,
489+
values: ConnectorTerminalUpdate
490+
): Promise<boolean> {
491+
const written = await db
492+
.update(knowledgeConnector)
493+
.set(values)
494+
.where(stillHoldsSyncLock(connectorId))
495+
.returning({ id: knowledgeConnector.id })
496+
497+
return written.length > 0
498+
}
499+
500+
/**
501+
* Reported when a run's terminal write matched no rows because the run no longer
502+
* held its lock. Its document writes still landed; only its connector-level
503+
* bookkeeping was discarded, in favour of whoever reclaimed the row.
504+
*/
505+
export const SUPERSEDED_SYNC_ERROR = 'sync_superseded'
506+
507+
/**
508+
* Marks a superseded run so the task wrapper's `success: !result.error` does not
509+
* report a discarded run as a clean sync — the same reason a lock-contended run
510+
* returns `sync_in_progress` rather than an empty success.
511+
*/
512+
export function applySupersededOutcome(
513+
result: SyncResult,
514+
terminalWriteLanded: boolean
515+
): SyncResult {
516+
if (terminalWriteLanded) return result
517+
return { ...result, error: SUPERSEDED_SYNC_ERROR }
518+
}
519+
450520
/**
451521
* Decides whether deletion reconciliation may run for a sync.
452522
*
@@ -1913,23 +1983,24 @@ export async function executeSync(
19131983
)
19141984

19151985
const now = new Date()
1916-
await db
1917-
.update(knowledgeConnector)
1918-
.set(
1919-
buildSyncSuccessUpdate(
1920-
now,
1921-
actualDocCount,
1922-
calculateNextSyncTime(connector.syncIntervalMinutes),
1923-
reconciliationHoldNotice
1924-
)
1925-
)
1926-
.where(
1927-
and(
1928-
eq(knowledgeConnector.id, connectorId),
1929-
isNull(knowledgeConnector.archivedAt),
1930-
isNull(knowledgeConnector.deletedAt)
1931-
)
1986+
const successWriteLanded = await writeTerminalConnectorState(
1987+
connectorId,
1988+
buildSyncSuccessUpdate(
1989+
now,
1990+
actualDocCount,
1991+
calculateNextSyncTime(connector.syncIntervalMinutes),
1992+
reconciliationHoldNotice
19321993
)
1994+
)
1995+
1996+
if (!successWriteLanded) {
1997+
logger.warn('Sync result discarded — connector was reclaimed while this run was executing', {
1998+
connectorId,
1999+
syncLogId,
2000+
...result,
2001+
})
2002+
return applySupersededOutcome(result, false)
2003+
}
19332004

19342005
logger.info('Sync completed', { connectorId, ...result })
19352006
return result
@@ -1982,24 +2053,29 @@ export async function executeSync(
19822053
})
19832054
}
19842055

1985-
await db
1986-
.update(knowledgeConnector)
1987-
.set({
1988-
status: disabled ? 'disabled' : 'error',
1989-
lastSyncError: disabled
1990-
? 'Connector disabled after repeated sync failures. Please reconnect.'
1991-
: errorMessage,
1992-
nextSyncAt: nextSync,
1993-
consecutiveFailures: failures,
1994-
updatedAt: now,
1995-
})
1996-
.where(
1997-
and(
1998-
eq(knowledgeConnector.id, connectorId),
1999-
isNull(knowledgeConnector.archivedAt),
2000-
isNull(knowledgeConnector.deletedAt)
2001-
)
2056+
const failureWriteLanded = await writeTerminalConnectorState(connectorId, {
2057+
status: disabled ? 'disabled' : 'error',
2058+
lastSyncError: disabled
2059+
? 'Connector disabled after repeated sync failures. Please reconnect.'
2060+
: errorMessage,
2061+
nextSyncAt: nextSync,
2062+
consecutiveFailures: failures,
2063+
updatedAt: now,
2064+
})
2065+
2066+
/**
2067+
* Deliberately does NOT get {@link applySupersededOutcome}. `result.error`
2068+
* is set to the real failure cause below and the task wrapper already
2069+
* reports this run as unsuccessful, so overwriting it with
2070+
* `sync_superseded` would destroy the diagnostic without changing the
2071+
* reported outcome. The supersession is carried by this log line instead.
2072+
*/
2073+
if (!failureWriteLanded) {
2074+
logger.warn(
2075+
'Sync failure discarded — connector was reclaimed while this run was executing',
2076+
{ connectorId, syncLogId, error: errorMessage }
20022077
)
2078+
}
20032079
} catch (recoveryError) {
20042080
logger.error('Failed to record sync failure', {
20052081
connectorId,

0 commit comments

Comments
 (0)