Skip to content

Commit a66f687

Browse files
committed
fix(connectors): identify which run holds a connector's sync lock
Review round 3 on #6909. Guarding terminal writes on status='syncing' proved only that *a* run held the lock, not that this one did. After a stale-lock reclaim dispatched a replacement, the original run's guard matched the replacement's own lock and clobbered both its state and the reaper's bookkeeping — and the live run's write was then rejected. The dead run won and the live one was discarded, which is worse than the last-write-wins behavior the guard replaced. A nullable sync_lock_token is stamped in the same statement that claims the lock, so ownership is established atomically with acquisition and matching it proves the lock is still this run's. status='syncing' stays alongside it as defence in depth and to keep a connector paused mid-sync from being flipped back to active. Rejected using updatedAt as an optimistic-concurrency token: connector updates bump it unconditionally with no status guard, so a user editing config mid-sync would strand the connector in syncing until the reaper cleared it two hours later. Migration is additive, nullable, no backfill. Existing rows read NULL, which no in-flight run can match, so a sync spanning the deploy loses only its terminal write and is re-run by the scheduler.
1 parent 51ae6a9 commit a66f687

7 files changed

Lines changed: 185 additions & 18 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
8282
lastSyncError: STALE_LOCK_ERROR_MESSAGE,
8383
nextSyncAt: reclaimedNextSyncAt(),
8484
consecutiveFailures: reclaimedFailureCount(),
85+
// Releases the reclaimed run's ownership token so its terminal write can
86+
// no longer match, even before a replacement takes the lock.
87+
syncLockToken: null,
8588
updatedAt: sql`now()`,
8689
})
8790
.where(

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

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
authOAuthUtilsMock,
66
dbChainMockFns,
77
drizzleOrmMock,
8+
flattenMockConditions,
89
hasMockCondition,
910
type MockCondition,
1011
resetDbChainMock,
@@ -1103,7 +1104,7 @@ describe('stillHoldsSyncLock', () => {
11031104
*/
11041105
expect(
11051106
hasMockCondition(
1106-
stillHoldsSyncLock('c-1'),
1107+
stillHoldsSyncLock('c-1', 'run-a'),
11071108
(node: MockCondition) =>
11081109
node.type === 'eq' &&
11091110
node.left === schemaMock.knowledgeConnector.status &&
@@ -1115,7 +1116,7 @@ describe('stillHoldsSyncLock', () => {
11151116
it('still scopes to the connector and skips archived or deleted rows', async () => {
11161117
const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine')
11171118

1118-
const condition = stillHoldsSyncLock('c-1')
1119+
const condition = stillHoldsSyncLock('c-1', 'run-a')
11191120

11201121
expect(
11211122
hasMockCondition(
@@ -1157,7 +1158,7 @@ describe('writeTerminalConnectorState', () => {
11571158
* both terminal paths route through here and neither builds a WHERE clause,
11581159
* so removing the guard is a single-site edit that this assertion catches.
11591160
*/
1160-
await writeTerminalConnectorState('c-1', { status: 'active' })
1161+
await writeTerminalConnectorState('c-1', 'run-a', { status: 'active' })
11611162

11621163
const where = dbChainMockFns.where.mock.calls[0][0]
11631164
expect(
@@ -1178,13 +1179,23 @@ describe('writeTerminalConnectorState', () => {
11781179
node.right === 'c-1'
11791180
)
11801181
).toBe(true)
1182+
// The token must be the run's own, not some other value that merely fills the slot.
1183+
expect(
1184+
hasMockCondition(
1185+
where,
1186+
(node: MockCondition) =>
1187+
node.type === 'eq' &&
1188+
node.left === schemaMock.knowledgeConnector.syncLockToken &&
1189+
node.right === 'run-a'
1190+
)
1191+
).toBe(true)
11811192
})
11821193

11831194
it('passes the caller values through untouched', async () => {
11841195
const { writeTerminalConnectorState } = await import('@/lib/knowledge/connectors/sync-engine')
11851196

11861197
const values = { status: 'error', consecutiveFailures: 4, nextSyncAt: null }
1187-
await writeTerminalConnectorState('c-1', values)
1198+
await writeTerminalConnectorState('c-1', 'run-a', values)
11881199

11891200
expect(dbChainMockFns.set.mock.calls[0][0]).toEqual(values)
11901201
})
@@ -1193,10 +1204,10 @@ describe('writeTerminalConnectorState', () => {
11931204
const { writeTerminalConnectorState } = await import('@/lib/knowledge/connectors/sync-engine')
11941205

11951206
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }])
1196-
expect(await writeTerminalConnectorState('c-1', { status: 'active' })).toBe(true)
1207+
expect(await writeTerminalConnectorState('c-1', 'run-a', { status: 'active' })).toBe(true)
11971208

11981209
dbChainMockFns.returning.mockResolvedValueOnce([])
1199-
expect(await writeTerminalConnectorState('c-1', { status: 'active' })).toBe(false)
1210+
expect(await writeTerminalConnectorState('c-1', 'run-a', { status: 'active' })).toBe(false)
12001211
})
12011212
})
12021213

@@ -1234,3 +1245,99 @@ describe('applySupersededOutcome', () => {
12341245
expect(applySupersededOutcome(result, false)).toMatchObject(result)
12351246
})
12361247
})
1248+
1249+
/**
1250+
* Evaluates a mocked drizzle condition tree against a plain row.
1251+
*
1252+
* The row-queue mocks return whatever was queued regardless of the predicate, so
1253+
* "this WHERE admits run B and rejects run A" is only observable by interpreting
1254+
* the condition tree the guard emits.
1255+
*/
1256+
function conditionMatchesRow(condition: unknown, row: Record<string, unknown>): boolean {
1257+
return flattenMockConditions(condition).every((node) => {
1258+
if (node.type === 'eq') return row[node.left as string] === node.right
1259+
if (node.type === 'isNull') return row[node.column as string] == null
1260+
throw new Error(`unhandled condition node: ${String(node.type)}`)
1261+
})
1262+
}
1263+
1264+
describe('sync lock ownership across a reclaim and reacquire', () => {
1265+
const RUN_A = 'run-a'
1266+
const RUN_B = 'run-b'
1267+
1268+
/** The connector row once run B has taken the lock that run A used to hold. */
1269+
const rowHeldByB = {
1270+
id: 'c-1',
1271+
status: 'syncing',
1272+
syncLockToken: RUN_B,
1273+
archivedAt: null,
1274+
deletedAt: null,
1275+
}
1276+
1277+
it('rejects the reclaimed run A and admits the live run B', async () => {
1278+
const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine')
1279+
1280+
/**
1281+
* A outlived the TTL, the reaper reclaimed its lock, and replacement B took
1282+
* it — so the row reads `syncing` again. Guarding on status alone matched A
1283+
* here and let the dead run clobber the live one, then rejected B's own
1284+
* write as superseded. Exactly inverted.
1285+
*/
1286+
expect(conditionMatchesRow(stillHoldsSyncLock('c-1', RUN_A), rowHeldByB)).toBe(false)
1287+
expect(conditionMatchesRow(stillHoldsSyncLock('c-1', RUN_B), rowHeldByB)).toBe(true)
1288+
})
1289+
1290+
it('rejects a run whose lock was reclaimed with no replacement yet', async () => {
1291+
const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine')
1292+
1293+
const reclaimed = {
1294+
id: 'c-1',
1295+
status: 'error',
1296+
syncLockToken: null,
1297+
archivedAt: null,
1298+
deletedAt: null,
1299+
}
1300+
1301+
expect(conditionMatchesRow(stillHoldsSyncLock('c-1', RUN_A), reclaimed)).toBe(false)
1302+
})
1303+
1304+
it('admits the run that still holds its own lock', async () => {
1305+
const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine')
1306+
1307+
const heldByA = { ...rowHeldByB, syncLockToken: RUN_A }
1308+
1309+
expect(conditionMatchesRow(stillHoldsSyncLock('c-1', RUN_A), heldByA)).toBe(true)
1310+
})
1311+
1312+
it('rejects a run whose connector was paused mid-sync', async () => {
1313+
const { stillHoldsSyncLock } = await import('@/lib/knowledge/connectors/sync-engine')
1314+
1315+
const paused = { ...rowHeldByB, status: 'paused', syncLockToken: RUN_A }
1316+
1317+
expect(conditionMatchesRow(stillHoldsSyncLock('c-1', RUN_A), paused)).toBe(false)
1318+
})
1319+
1320+
it('releases the token when a run writes its terminal success state', async () => {
1321+
const { buildSyncSuccessUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
1322+
1323+
// A stale token left behind could match a later run reusing the same id.
1324+
expect(buildSyncSuccessUpdate(new Date(), 1, null, null).syncLockToken).toBeNull()
1325+
})
1326+
})
1327+
1328+
describe('buildSyncLockAcquisition', () => {
1329+
it('claims the lock and stamps ownership in one payload', async () => {
1330+
const { buildSyncLockAcquisition } = await import('@/lib/knowledge/connectors/sync-engine')
1331+
1332+
const now = new Date('2026-08-20T00:00:00.000Z')
1333+
const acquisition = buildSyncLockAcquisition('run-a', now)
1334+
1335+
/**
1336+
* Without the token here every terminal write would fail to match its own
1337+
* run, so every sync would report superseded and leave the connector stuck
1338+
* `syncing` until the reaper cleared it.
1339+
*/
1340+
expect(acquisition.syncLockToken).toBe('run-a')
1341+
expect(acquisition.status).toBe('syncing')
1342+
})
1343+
})

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

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -337,27 +337,47 @@ export async function completeSyncLog(
337337
/**
338338
* Matches the connector row only while this run still holds its sync lock.
339339
*
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`.
340+
* `status = 'syncing'` alone is not enough: it asserts that *a* run holds the
341+
* lock, not that *this* run does. Once the scheduler reclaims a stale lock and
342+
* dispatches a replacement, the replacement sets `syncing` again — so the
343+
* original run would match, overwrite the replacement's in-flight state and the
344+
* reclaim's bookkeeping, and then reject the replacement's own write as
345+
* superseded. The dead run wins and the live one loses, which is worse than the
346+
* unguarded last-write-wins it replaced.
347+
*
348+
* `syncLockToken` is written in the same CAS that takes the lock, so matching it
349+
* proves the lock is still this run's. `status` is kept alongside as defence in
350+
* depth and to cover a user pausing the connector mid-run.
347351
*
348352
* Guards both terminal paths. The failure path needs it as much as the success
349353
* path: a reclaimed run's failure would double-increment a counter the sweep
350354
* already advanced and overwrite its backoff with a shorter one.
351355
*/
352-
export function stillHoldsSyncLock(connectorId: string) {
356+
export function stillHoldsSyncLock(connectorId: string, syncLockToken: string) {
353357
return and(
354358
eq(knowledgeConnector.id, connectorId),
355359
eq(knowledgeConnector.status, 'syncing'),
360+
eq(knowledgeConnector.syncLockToken, syncLockToken),
356361
isNull(knowledgeConnector.archivedAt),
357362
isNull(knowledgeConnector.deletedAt)
358363
)
359364
}
360365

366+
/**
367+
* The connector row a run writes when it takes the sync lock.
368+
*
369+
* `syncLockToken` is set here, in the same statement as `status`, so ownership
370+
* and the lock are established atomically — a token written afterwards would
371+
* leave a window where a terminal write could not identify its own run.
372+
*/
373+
export function buildSyncLockAcquisition(syncLogId: string, now: Date) {
374+
return {
375+
status: 'syncing' as const,
376+
syncLockToken: syncLogId,
377+
updatedAt: now,
378+
}
379+
}
380+
361381
/** Columns a terminal write may set. Both paths write a subset of the same set. */
362382
type ConnectorTerminalUpdate = Partial<typeof knowledgeConnector.$inferInsert>
363383

@@ -373,12 +393,13 @@ type ConnectorTerminalUpdate = Partial<typeof knowledgeConnector.$inferInsert>
373393
*/
374394
export async function writeTerminalConnectorState(
375395
connectorId: string,
396+
syncLockToken: string,
376397
values: ConnectorTerminalUpdate
377398
): Promise<boolean> {
378399
const written = await db
379400
.update(knowledgeConnector)
380401
.set(values)
381-
.where(stillHoldsSyncLock(connectorId))
402+
.where(stillHoldsSyncLock(connectorId, syncLockToken))
382403
.returning({ id: knowledgeConnector.id })
383404

384405
return written.length > 0
@@ -609,6 +630,8 @@ export function buildSyncSuccessUpdate(
609630
lastSyncDocCount: actualDocCount,
610631
nextSyncAt,
611632
consecutiveFailures: 0,
633+
// Releases the lock so a stale token can never match a later run.
634+
syncLockToken: null,
612635
updatedAt: now,
613636
}
614637
}
@@ -1038,9 +1061,17 @@ export async function executeSync(
10381061
}
10391062
const sourceConfig = connector.sourceConfig as Record<string, unknown>
10401063

1064+
/**
1065+
* Identifies this run for the terminal writes. Generated before the CAS and
1066+
* written by it, so ownership is established atomically with the lock — and
1067+
* reused as the sync-log row id, which makes the connector row point at the
1068+
* run that holds it.
1069+
*/
1070+
const syncLogId = generateId()
1071+
10411072
const lockResult = await db
10421073
.update(knowledgeConnector)
1043-
.set({ status: 'syncing', updatedAt: new Date() })
1074+
.set(buildSyncLockAcquisition(syncLogId, new Date()))
10441075
.where(
10451076
and(
10461077
eq(knowledgeConnector.id, connectorId),
@@ -1058,7 +1089,6 @@ export async function executeSync(
10581089
return { ...result, error: 'sync_in_progress' }
10591090
}
10601091

1061-
const syncLogId = generateId()
10621092
const syncStartedAt = new Date()
10631093
await db.insert(knowledgeConnectorSyncLog).values({
10641094
id: syncLogId,
@@ -1863,6 +1893,7 @@ export async function executeSync(
18631893
const now = new Date()
18641894
const successWriteLanded = await writeTerminalConnectorState(
18651895
connectorId,
1896+
syncLogId,
18661897
buildSyncSuccessUpdate(
18671898
now,
18681899
actualDocCount,
@@ -1931,13 +1962,14 @@ export async function executeSync(
19311962
})
19321963
}
19331964

1934-
const failureWriteLanded = await writeTerminalConnectorState(connectorId, {
1965+
const failureWriteLanded = await writeTerminalConnectorState(connectorId, syncLogId, {
19351966
status: disabled ? 'disabled' : 'error',
19361967
lastSyncError: disabled
19371968
? 'Connector disabled after repeated sync failures. Please reconnect.'
19381969
: errorMessage,
19391970
nextSyncAt: nextSync,
19401971
consecutiveFailures: failures,
1972+
syncLockToken: null,
19411973
updatedAt: now,
19421974
})
19431975

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- Identifies which sync run holds a connector's lock. `status = 'syncing'` only
2+
-- proves *a* run holds it, so after a stale-lock reclaim + replacement dispatch
3+
-- the original run still matched and clobbered the replacement's state.
4+
--
5+
-- Additive and nullable with no backfill: existing rows read NULL, which no
6+
-- in-flight run can match, so the worst case for a sync already running across
7+
-- the deploy is that its terminal write is skipped and the scheduler re-runs it.
8+
ALTER TABLE "knowledge_connector" ADD COLUMN IF NOT EXISTS "sync_lock_token" text;

packages/db/migrations/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2073,6 +2073,13 @@
20732073
"when": 1787254479175,
20742074
"tag": "0296_organization_byok_keys",
20752075
"breakpoints": true
2076+
},
2077+
{
2078+
"idx": 297,
2079+
"version": "7",
2080+
"when": 1787254579175,
2081+
"tag": "0297_connector_sync_lock_token",
2082+
"breakpoints": true
20762083
}
20772084
]
20782085
}

packages/db/schema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4321,6 +4321,15 @@ export const knowledgeConnector = pgTable(
43214321
lastSyncDocCount: integer('last_sync_doc_count'),
43224322
nextSyncAt: timestamp('next_sync_at'),
43234323
consecutiveFailures: integer('consecutive_failures').notNull().default(0),
4324+
/**
4325+
* Identifies the sync run that currently holds this connector's lock.
4326+
*
4327+
* `status = 'syncing'` only says *a* run holds it. After the scheduler
4328+
* reclaims a stale lock and dispatches a replacement, the original run would
4329+
* still see `syncing` and overwrite the replacement's state. Terminal writes
4330+
* match this token so a run can prove the lock is still *its own*.
4331+
*/
4332+
syncLockToken: text('sync_lock_token'),
43244333
createdAt: timestamp('created_at').notNull().defaultNow(),
43254334
updatedAt: timestamp('updated_at').notNull().defaultNow(),
43264335
archivedAt: timestamp('archived_at'),

packages/testing/src/mocks/schema.mock.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,6 +1269,7 @@ export const schemaMock = {
12691269
lastSyncDocCount: 'lastSyncDocCount',
12701270
nextSyncAt: 'nextSyncAt',
12711271
consecutiveFailures: 'consecutiveFailures',
1272+
syncLockToken: 'syncLockToken',
12721273
createdAt: 'createdAt',
12731274
updatedAt: 'updatedAt',
12741275
archivedAt: 'archivedAt',

0 commit comments

Comments
 (0)