Skip to content

Commit de457b4

Browse files
committed
fix(connectors): surface a held reconciliation and scope the cap to eligible rows
Review round 1 on #6909. - A held pass reported an ordinary successful sync: the cap logged an error and the success update then cleared lastSyncError and reset consecutiveFailures, so source-removed documents stayed indexed with no operator signal. The notice is now threaded into the success update itself — writing it at the hold site would have been clobbered by that same update ~300 lines later, in the same run. status stays active and the failure counter still resets: a held pass is a healthy sync that declined to delete. - The cap denominator counted excluded tombstones, which partitionSyncReconciliation can never delete, inflating a budget against rows that cannot be spent. Both sides are now counted over the deletion-eligible population, matching the numerator. - completeSyncLog now only writes a row still marked started, so a late-finishing in-process run cannot overwrite a row the stale sweep already closed. The TTL is documented as a hard ceiling for both dispatch paths.
1 parent 48b0cfd commit de457b4

3 files changed

Lines changed: 272 additions & 20 deletions

File tree

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

Lines changed: 138 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { authOAuthUtilsMock } from '@sim/testing'
4+
import {
5+
authOAuthUtilsMock,
6+
dbChainMockFns,
7+
drizzleOrmMock,
8+
hasMockCondition,
9+
type MockCondition,
10+
resetDbChainMock,
11+
schemaMock,
12+
} from '@sim/testing'
513
import { generateShortId } from '@sim/utils/id'
614
import { beforeEach, describe, expect, it, vi } from 'vitest'
715
import {
@@ -12,13 +20,7 @@ import {
1220
} from '@/lib/knowledge/connectors/sync-engine'
1321
import type { ExternalDocument } from '@/connectors/types'
1422

15-
vi.mock('drizzle-orm', () => ({
16-
and: vi.fn(),
17-
eq: vi.fn(),
18-
inArray: vi.fn(),
19-
isNull: vi.fn(),
20-
ne: vi.fn(),
21-
}))
23+
vi.mock('drizzle-orm', () => drizzleOrmMock)
2224
vi.mock('@/lib/knowledge/documents/service', () => ({
2325
hardDeleteDocuments: vi.fn(),
2426
isTriggerAvailable: vi.fn(),
@@ -961,3 +963,131 @@ describe('countNonExcludedListed', () => {
961963
expect(classifySuspectListing(listed.size, ownedDocCount)).toBeNull()
962964
})
963965
})
966+
967+
describe('countDeletionEligibleOwned', () => {
968+
const doc = (id: string) => ({ id, externalId: id })
969+
const excluded = (id: string) => ({ id, externalId: id, userExcluded: true })
970+
971+
it('does not let excluded tombstones inflate the denominator', async () => {
972+
const { countDeletionEligibleOwned } = await import('@/lib/knowledge/connectors/sync-engine')
973+
974+
expect(countDeletionEligibleOwned([doc('a')], [excluded('t1'), excluded('t2')])).toBe(1)
975+
expect(countDeletionEligibleOwned([doc('a')], [doc('t1'), excluded('t2')])).toBe(2)
976+
})
977+
978+
it('excludes user-excluded rows from the live side too', async () => {
979+
const { countDeletionEligibleOwned } = await import('@/lib/knowledge/connectors/sync-engine')
980+
981+
expect(countDeletionEligibleOwned([doc('a'), excluded('b')], [])).toBe(1)
982+
})
983+
984+
it('agrees with the numerator on which population it counts', async () => {
985+
const { classifySuspectListing, countDeletionEligibleOwned, countNonExcludedListed } =
986+
await import('@/lib/knowledge/connectors/sync-engine')
987+
988+
/**
989+
* 100 live + 100 excluded tombstones. Counting the excluded tombstones would
990+
* put the denominator at 200 and hide a listing that returned nothing but
991+
* excluded documents.
992+
*/
993+
const existing = Array.from({ length: 100 }, (_, i) => doc(`live-${i}`))
994+
const tombstoned = Array.from({ length: 100 }, (_, i) => excluded(`ex-${i}`))
995+
const listed = new Set(tombstoned.map((d) => d.externalId))
996+
const excludedExternalIds = new Set(listed)
997+
998+
const ownedDocCount = countDeletionEligibleOwned(existing, tombstoned)
999+
const listedDocCount = countNonExcludedListed(listed, excludedExternalIds)
1000+
1001+
expect(ownedDocCount).toBe(100)
1002+
expect(listedDocCount).toBe(0)
1003+
expect(classifySuspectListing(listedDocCount, ownedDocCount)).toBe('empty')
1004+
})
1005+
})
1006+
1007+
describe('buildReconciliationHoldNotice', () => {
1008+
it('names the counts and the full-sync remedy', async () => {
1009+
const { buildReconciliationHoldNotice } = await import('@/lib/knowledge/connectors/sync-engine')
1010+
1011+
const notice = buildReconciliationHoldNotice(500, 250, 1000)
1012+
1013+
expect(notice).toContain('500')
1014+
expect(notice).toContain('250')
1015+
expect(notice).toContain('1000')
1016+
expect(notice).toContain('full sync')
1017+
})
1018+
})
1019+
1020+
describe('buildSyncSuccessUpdate', () => {
1021+
const now = new Date('2026-08-20T00:00:00.000Z')
1022+
1023+
it('carries a hold notice into lastSyncError instead of clearing it', async () => {
1024+
const { buildSyncSuccessUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
1025+
1026+
/**
1027+
* The sequencing assertion. This update runs at the end of the sync, long
1028+
* after the hold is detected, so writing the notice at the hold site would
1029+
* be clobbered here.
1030+
*/
1031+
const update = buildSyncSuccessUpdate(now, 42, null, 'held: 500 removals withheld')
1032+
1033+
expect(update.lastSyncError).toBe('held: 500 removals withheld')
1034+
})
1035+
1036+
it('still clears lastSyncError on an ordinary successful sync', async () => {
1037+
const { buildSyncSuccessUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
1038+
1039+
expect(buildSyncSuccessUpdate(now, 42, null, null).lastSyncError).toBeNull()
1040+
})
1041+
1042+
it('does not treat a held pass as a broken connector', async () => {
1043+
const { buildSyncSuccessUpdate } = await import('@/lib/knowledge/connectors/sync-engine')
1044+
1045+
const update = buildSyncSuccessUpdate(now, 42, null, 'held')
1046+
1047+
expect(update.status).toBe('active')
1048+
expect(update.consecutiveFailures).toBe(0)
1049+
})
1050+
})
1051+
1052+
describe('completeSyncLog', () => {
1053+
beforeEach(() => {
1054+
vi.clearAllMocks()
1055+
resetDbChainMock()
1056+
})
1057+
1058+
it('only writes a row that is still started', async () => {
1059+
const { completeSyncLog } = await import('@/lib/knowledge/connectors/sync-engine')
1060+
1061+
await completeSyncLog('log-1', 'completed', {
1062+
docsAdded: 1,
1063+
docsUpdated: 0,
1064+
docsDeleted: 0,
1065+
docsUnchanged: 0,
1066+
docsFailed: 0,
1067+
})
1068+
1069+
const where = dbChainMockFns.where.mock.calls[0][0]
1070+
/**
1071+
* Without this the sweep and a late-finishing in-process run race: the sweep
1072+
* marks the row failed, then the run overwrites it as completed.
1073+
*/
1074+
expect(
1075+
hasMockCondition(
1076+
where,
1077+
(node: MockCondition) =>
1078+
node.type === 'eq' &&
1079+
node.left === schemaMock.knowledgeConnectorSyncLog.status &&
1080+
node.right === 'started'
1081+
)
1082+
).toBe(true)
1083+
expect(
1084+
hasMockCondition(
1085+
where,
1086+
(node: MockCondition) =>
1087+
node.type === 'eq' &&
1088+
node.left === schemaMock.knowledgeConnectorSyncLog.id &&
1089+
node.right === 'log-1'
1090+
)
1091+
).toBe(true)
1092+
})
1093+
})

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

Lines changed: 119 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,20 @@ function calculateNextSyncTime(syncIntervalMinutes: number): Date | null {
295295
return new Date(now + syncIntervalMinutes * 60_000 + jitterMs)
296296
}
297297

298-
async function completeSyncLog(
298+
/**
299+
* Records a sync run's outcome on its log row.
300+
*
301+
* Guarded on `status = 'started'` so a run that outlives
302+
* {@link CONNECTOR_SYNC_STALE_LOCK_TTL_MS} cannot overwrite a row the
303+
* scheduler's stale sweep already closed. Without the guard the two writers
304+
* race and produce contradictory history: the sweep marks the row `failed`,
305+
* then the still-running sync reports `completed` on the same row.
306+
*
307+
* A no-op on the normal path — nothing else touches the row between its
308+
* `started` insert and this call, so the guard only ever bites once the sweep
309+
* has declared the run dead, and the sweep's verdict is the one that stands.
310+
*/
311+
export async function completeSyncLog(
299312
syncLogId: string,
300313
status: 'completed' | 'failed',
301314
result: SyncResult,
@@ -313,7 +326,12 @@ async function completeSyncLog(
313326
docsUnchanged: result.docsUnchanged,
314327
docsFailed: result.docsFailed,
315328
})
316-
.where(eq(knowledgeConnectorSyncLog.id, syncLogId))
329+
.where(
330+
and(
331+
eq(knowledgeConnectorSyncLog.id, syncLogId),
332+
eq(knowledgeConnectorSyncLog.status, 'started')
333+
)
334+
)
317335
}
318336

319337
/**
@@ -458,6 +476,73 @@ export function evaluateListingSafety(
458476
return { reason, blocked: !corroborated, corroborated }
459477
}
460478

479+
/**
480+
* Documents a reconciliation pass could actually remove.
481+
*
482+
* Both reads are filtered, not just the tombstoned one: the live read already
483+
* excludes `userExcluded` rows in SQL, so filtering it again is a no-op today,
484+
* but it keeps this count self-consistent with
485+
* {@link partitionSyncReconciliation}, which gates deletion on the same flag for
486+
* both lists. The result is the denominator for the deletion cap and for
487+
* {@link classifySuspectListing}, whose numerator
488+
* ({@link countNonExcludedListed}) ranges over the same population.
489+
*/
490+
export function countDeletionEligibleOwned(
491+
existingDocs: ReconciliationDoc[],
492+
tombstonedDocs: ReconciliationDoc[]
493+
): number {
494+
return (
495+
existingDocs.filter((d) => !d.userExcluded).length +
496+
tombstonedDocs.filter((d) => !d.userExcluded).length
497+
)
498+
}
499+
500+
/**
501+
* Operator-facing explanation of a held reconciliation pass.
502+
*
503+
* Stored on `knowledgeConnector.lastSyncError` because a hold is otherwise
504+
* invisible: the sync completes normally and an operator sees an ordinary green
505+
* run while source-removed documents stay indexed. Names the forced full sync,
506+
* which is the documented way to apply the removals once the source is verified.
507+
*/
508+
export function buildReconciliationHoldNotice(
509+
requested: number,
510+
cap: number,
511+
ownedDocCount: number
512+
): string {
513+
return (
514+
`Withheld ${requested} document removal(s) — more than the ${cap} allowed in one sync ` +
515+
`of ${ownedDocCount} documents. Documents deleted at the source are still indexed. ` +
516+
'Check the source is returning its full contents, then run a full sync to apply the removals.'
517+
)
518+
}
519+
520+
/**
521+
* The connector row a successful sync writes.
522+
*
523+
* `holdNotice` is threaded through rather than written when the hold is detected
524+
* because this update runs at the very end of the sync and would otherwise clear
525+
* `lastSyncError` in the same run. `status` stays `active` and
526+
* `consecutiveFailures` still resets: a held pass is a healthy sync that declined
527+
* to delete, not a failure, and marking it broken would stop it syncing at all.
528+
*/
529+
export function buildSyncSuccessUpdate(
530+
now: Date,
531+
actualDocCount: number,
532+
nextSyncAt: Date | null,
533+
holdNotice: string | null
534+
) {
535+
return {
536+
status: 'active' as const,
537+
lastSyncAt: now,
538+
lastSyncError: holdNotice,
539+
lastSyncDocCount: actualDocCount,
540+
nextSyncAt,
541+
consecutiveFailures: 0,
542+
updatedAt: now,
543+
}
544+
}
545+
461546
/**
462547
* The document count to attribute to the previous sync when reconstructing its
463548
* listing.
@@ -1391,7 +1476,14 @@ export async function executeSync(
13911476
* same thing. Only evaluated when reconciliation would otherwise run, so
13921477
* healthy syncs pay nothing and no existing gate is loosened.
13931478
*/
1394-
const ownedDocCount = existingDocs.length + tombstonedDocs.length
1479+
/**
1480+
* Counted over deletion-eligible rows on both sides. The live read filters
1481+
* excluded documents in SQL; the tombstoned read only projects the flag, so
1482+
* excluded tombstones must be dropped here or they inflate a denominator
1483+
* governing a population they are not part of. Matches `listedDocCount`,
1484+
* which `countNonExcludedListed` already puts on the same footing.
1485+
*/
1486+
const ownedDocCount = countDeletionEligibleOwned(existingDocs, tombstonedDocs)
13951487
/**
13961488
* Counted over the same population as `ownedDocCount`: excluded documents
13971489
* are absent from the live read, so they must not inflate the numerator.
@@ -1441,7 +1533,23 @@ export async function executeSync(
14411533
ownedDocCount,
14421534
options?.fullSync
14431535
)
1536+
/**
1537+
* Surfaced on the connector so a held pass is visible to an operator rather
1538+
* than only in logs: without it the sync completes green, clears
1539+
* `lastSyncError`, and source-removed documents stay indexed with no signal.
1540+
* Written through the success update at the end of this run rather than
1541+
* here — that update sets `lastSyncError: null` unconditionally and would
1542+
* otherwise clobber this within the same sync. `status` is deliberately left
1543+
* `active`: the sync itself succeeded, and marking the connector broken
1544+
* would stop it syncing at all.
1545+
*/
1546+
let reconciliationHoldNotice: string | null = null
14441547
if (capped.held) {
1548+
reconciliationHoldNotice = buildReconciliationHoldNotice(
1549+
capped.requested,
1550+
capped.cap,
1551+
ownedDocCount
1552+
)
14451553
logger.error('Reconciliation deletions held — exceeds per-sync blast-radius cap', {
14461554
connectorId,
14471555
connectorType: connector.connectorType,
@@ -1685,15 +1793,14 @@ export async function executeSync(
16851793
const now = new Date()
16861794
await db
16871795
.update(knowledgeConnector)
1688-
.set({
1689-
status: 'active',
1690-
lastSyncAt: now,
1691-
lastSyncError: null,
1692-
lastSyncDocCount: actualDocCount,
1693-
nextSyncAt: calculateNextSyncTime(connector.syncIntervalMinutes),
1694-
consecutiveFailures: 0,
1695-
updatedAt: now,
1696-
})
1796+
.set(
1797+
buildSyncSuccessUpdate(
1798+
now,
1799+
actualDocCount,
1800+
calculateNextSyncTime(connector.syncIntervalMinutes),
1801+
reconciliationHoldNotice
1802+
)
1803+
)
16971804
.where(
16981805
and(
16991806
eq(knowledgeConnector.id, connectorId),

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@ export const CONNECTOR_SYNC_MAX_DURATION_SECONDS = 3600
1212
* MUST stay above {@link CONNECTOR_SYNC_MAX_DURATION_SECONDS}: reclaiming frees the
1313
* lock for another sync, so a TTL at or below the run ceiling would start a second
1414
* sync while the first is still writing, both racing the same documents.
15+
*
16+
* This is a hard ceiling for BOTH execution paths, not just the queued one. A
17+
* Trigger.dev run is killed at {@link CONNECTOR_SYNC_MAX_DURATION_SECONDS}, so it
18+
* is provably dead well before this. The fallback path is not: when Trigger.dev is
19+
* unavailable, `dispatchSync` runs `executeSync` fire-and-forget inside the web
20+
* process with no duration cap, and such a run genuinely can still be executing
21+
* when this TTL expires.
22+
*
23+
* Treating it as dead anyway is deliberate. An unbounded background sync in a
24+
* recyclable web process that has run for two hours is indistinguishable from one
25+
* whose process was recycled out from under it, and the cost of guessing wrong in
26+
* the other direction is a connector locked out of syncing forever. The sweep's
27+
* verdict is therefore authoritative: `completeSyncLog` is guarded on
28+
* `status = 'started'`, so a late finisher cannot overwrite a row already closed
29+
* here, and it loses the race by design rather than by accident.
1530
*/
1631
export const CONNECTOR_SYNC_STALE_LOCK_TTL_MS = CONNECTOR_SYNC_MAX_DURATION_SECONDS * 2 * 1000
1732

0 commit comments

Comments
 (0)