Skip to content

Commit 3530024

Browse files
committed
fix(connectors): heartbeat a running sync and cap deletion generations apart
Review round 4 on #6909. Two regressions the guards introduced, plus two tests that could not fail. Counting a stale-lock reclaim as a failure turned the reaper into a one-way ratchet for any sync that legitimately outran the TTL: the in-process path has no duration cap, so a long self-hosted sync was reclaimed, its successful terminal write then failed the ownership guard and was discarded, and its failure counter never reset. Ten of those and a working connector was disabled telling the user to reconnect. A running sync now refreshes updatedAt every five minutes, so the reaper's staleness predicate means "nobody is working on this" rather than "this started a long time ago". The beat is guarded on the run's own lock, so it doubles as an ownership probe: a run whose lock was reclaimed abandons immediately instead of working for hours and then discarding the result. The deletion cap summed soft and hard deletes against one ceiling sized for a single generation, so a connector with steady churn deadlocked from its second sync onward and got monotonically worse — the all-or-nothing hold blocked the very hard deletes that would have drained the tombstone backlog. Hard deletes are confirmations of removals already gated when they were soft-deleted, so each generation now caps independently. Both guard tests for the reaper asserted only the bookends of the rendered SQL, leaving the comparison itself unasserted: an inverted threshold that disabled a connector on its first hard kill passed. Both now assert the whole expression.
1 parent a66f687 commit 3530024

4 files changed

Lines changed: 312 additions & 43 deletions

File tree

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

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,18 @@ describe('connector sync scheduler stale-lock reaper', () => {
106106
await runTickRecovering(['connector-1'])
107107

108108
const status = setPayloadForUpdate(0).status
109-
const rendered = renderedSql(status)
110109

111-
expect(rendered).toContain('CASE WHEN COALESCE(')
112-
expect(rendered).toContain("THEN 'disabled' ELSE 'error' END")
113-
expect(numericBinds(status)).toContain(MAX_CONSECUTIVE_FAILURES)
110+
/**
111+
* Asserted whole rather than by its bookends: the comparison is the entire
112+
* point of this expression, and leaving it in an un-asserted middle let
113+
* `+ 2 >=`, `+ 1 >`, and an inverted `+ 1 <=` all pass. The last of those
114+
* disables a connector on its first hard kill.
115+
*/
116+
expect(renderedSql(status)).toBe(
117+
"CASE WHEN COALESCE(?, 0) + 1 >= ? THEN 'disabled' ELSE 'error' END"
118+
)
119+
expect(asFragment(status).values[0]).toBe(schemaMock.knowledgeConnector.consecutiveFailures)
120+
expect(asFragment(status).values[1]).toBe(MAX_CONSECUTIVE_FAILURES)
114121
})
115122

116123
it('derives nextSyncAt from the shared failure backoff ladder', async () => {
@@ -119,9 +126,10 @@ describe('connector sync scheduler stale-lock reaper', () => {
119126
const nextSyncAt = setPayloadForUpdate(0).nextSyncAt
120127
const rendered = renderedSql(nextSyncAt)
121128

122-
expect(rendered).toContain('THEN NULL')
123-
expect(rendered).toContain('LEAST(')
124-
expect(rendered).toContain("INTERVAL '1 minute'")
129+
expect(rendered).toBe(
130+
'CASE WHEN COALESCE(?, 0) + 1 >= ? THEN NULL ' +
131+
"ELSE now() + LEAST((COALESCE(?, 0) + 1) * ?, ?) * INTERVAL '1 minute' END"
132+
)
125133

126134
const [threshold, step, cap] = numericBinds(nextSyncAt)
127135
expect(threshold).toBe(MAX_CONSECUTIVE_FAILURES)
@@ -190,12 +198,32 @@ describe('connector sync scheduler stale-lock reaper', () => {
190198
await runTickRecovering(['connector-1'])
191199

192200
const where = dbChainMockFns.where.mock.calls[1][0]
201+
202+
/**
203+
* Checks every position, not just `column`. `eq()` builds `{left, right}`
204+
* and only `inArray()` builds `{column}`, so a `column`-only assertion
205+
* silently permitted an `eq`-scoped sweep — the exact coupling this test
206+
* exists to forbid.
207+
*/
208+
const connectorIdColumn = schemaMock.knowledgeConnectorSyncLog.connectorId
193209
expect(
194210
hasMockCondition(
195211
where,
196-
(node: MockCondition) => node.column === schemaMock.knowledgeConnectorSyncLog.connectorId
212+
(node: MockCondition) =>
213+
node.column === connectorIdColumn ||
214+
node.left === connectorIdColumn ||
215+
node.right === connectorIdColumn
197216
)
198217
).toBe(false)
218+
219+
// And positively: the sweep is keyed on the row's own age.
220+
expect(
221+
hasMockCondition(
222+
where,
223+
(node: MockCondition) =>
224+
node.type === 'lte' && node.left === schemaMock.knowledgeConnectorSyncLog.startedAt
225+
)
226+
).toBe(true)
199227
})
200228

201229
it('drives the connector write off a single clock', async () => {

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

Lines changed: 133 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -751,7 +751,7 @@ describe('capReconciliationDeletions', () => {
751751

752752
expect(result.held).toBe(false)
753753
expect(result.cap).toBe(250)
754-
expect(result.requested).toBe(250)
754+
expect(result.withheld).toBe(0)
755755
expect(result.softDeleteIds).toEqual(soft)
756756
})
757757

@@ -761,7 +761,8 @@ describe('capReconciliationDeletions', () => {
761761
const result = capReconciliationDeletions(ids('soft', 251), [], 1000, false)
762762

763763
expect(result.held).toBe(true)
764-
expect(result.requested).toBe(251)
764+
expect(result.softHeld).toBe(true)
765+
expect(result.withheld).toBe(251)
765766
})
766767

767768
it('returns empty arrays — not the inputs — when held', async () => {
@@ -774,25 +775,55 @@ describe('capReconciliationDeletions', () => {
774775
expect(result.hardDeleteIds).toEqual([])
775776
})
776777

777-
it('counts the union of soft and hard deletions against one cap', async () => {
778+
it('caps each generation separately rather than summing them', async () => {
778779
const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine')
779780

780-
const overlapping = ids('doc', 200)
781-
// Same ids on both lists must count once, not twice.
782-
expect(capReconciliationDeletions(overlapping, overlapping, 1000, false).held).toBe(false)
783-
expect(capReconciliationDeletions(ids('a', 200), ids('b', 200), 1000, false).held).toBe(true)
781+
/**
782+
* Hard deletes are the previous generation's soft deletes, already gated by
783+
* this cap once. Summing them double-counts the older generation, which is
784+
* what deadlocked a churning connector.
785+
*/
786+
const result = capReconciliationDeletions(ids('a', 200), ids('b', 200), 1000, false)
787+
788+
expect(result.held).toBe(false)
789+
expect(result.softDeleteIds).toHaveLength(200)
790+
expect(result.hardDeleteIds).toHaveLength(200)
784791
})
785792

786-
it('is bypassed by a forced fullSync', async () => {
793+
it('holds only the generation that breached the cap', async () => {
787794
const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine')
788795

789-
const hard = ids('hard', 1000)
790-
const result = capReconciliationDeletions([], hard, 1000, true)
796+
const hard = ids('hard', 100)
797+
const result = capReconciliationDeletions(ids('soft', 400), hard, 1000, false)
791798

792-
expect(result.held).toBe(false)
799+
expect(result.softHeld).toBe(true)
800+
expect(result.hardHeld).toBe(false)
801+
expect(result.softDeleteIds).toEqual([])
802+
// The confirmed generation still drains, so the backlog cannot ratchet.
793803
expect(result.hardDeleteIds).toEqual(hard)
794804
})
795805

806+
it('is bypassed by a forced fullSync, in both generations', async () => {
807+
const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine')
808+
809+
const hard = ids('hard', 1000)
810+
const hardOnly = capReconciliationDeletions([], hard, 1000, true)
811+
812+
expect(hardOnly.held).toBe(false)
813+
expect(hardOnly.hardDeleteIds).toEqual(hard)
814+
815+
/**
816+
* Exercised per generation: asserting only the hard list left the soft
817+
* branch's bypass untested, so dropping it there was invisible.
818+
*/
819+
const soft = ids('soft', 1000)
820+
const softOnly = capReconciliationDeletions(soft, [], 1000, true)
821+
822+
expect(softOnly.held).toBe(false)
823+
expect(softOnly.softHeld).toBe(false)
824+
expect(softOnly.softDeleteIds).toEqual(soft)
825+
})
826+
796827
it('applies the small-corpus floor rather than the ratio', async () => {
797828
const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine')
798829

@@ -814,6 +845,28 @@ describe('capReconciliationDeletions', () => {
814845
).toBe(true)
815846
})
816847

848+
describe('steady churn', () => {
849+
it('reaches a stable state instead of ratcheting shut', async () => {
850+
const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine')
851+
852+
/**
853+
* 1,000 documents at 15% churn against a cap of 250. Under one summed cap:
854+
* sync 1 applied 150 soft; sync 2 requested 150 soft + 150 hard = 300 and
855+
* was held in full; the blocked hard deletes then accumulated forever.
856+
*/
857+
const sync1 = capReconciliationDeletions(ids('gen1', 150), [], 1000, false)
858+
expect(sync1.held).toBe(false)
859+
860+
const sync2 = capReconciliationDeletions(ids('gen2', 150), ids('gen1', 150), 1000, false)
861+
expect(sync2.held).toBe(false)
862+
expect(sync2.hardDeleteIds).toHaveLength(150)
863+
864+
const sync3 = capReconciliationDeletions(ids('gen3', 150), ids('gen2', 150), 1000, false)
865+
expect(sync3.held).toBe(false)
866+
expect(sync3.hardDeleteIds).toHaveLength(150)
867+
})
868+
})
869+
817870
describe('confirmed data-loss shapes', () => {
818871
it('holds a partial outage that returns half a 1000-document corpus', async () => {
819872
const { capReconciliationDeletions } = await import('@/lib/knowledge/connectors/sync-engine')
@@ -1341,3 +1394,72 @@ describe('buildSyncLockAcquisition', () => {
13411394
expect(acquisition.status).toBe('syncing')
13421395
})
13431396
})
1397+
1398+
describe('shouldHeartbeatSyncLock', () => {
1399+
it('beats once the interval has elapsed', async () => {
1400+
const { shouldHeartbeatSyncLock } = await import('@/lib/knowledge/connectors/sync-engine')
1401+
1402+
expect(shouldHeartbeatSyncLock(1_000, 0, 1_000)).toBe(true)
1403+
expect(shouldHeartbeatSyncLock(1_001, 0, 1_000)).toBe(true)
1404+
})
1405+
1406+
it('does not beat before the interval has elapsed', async () => {
1407+
const { shouldHeartbeatSyncLock } = await import('@/lib/knowledge/connectors/sync-engine')
1408+
1409+
expect(shouldHeartbeatSyncLock(999, 0, 1_000)).toBe(false)
1410+
expect(shouldHeartbeatSyncLock(0, 0, 1_000)).toBe(false)
1411+
})
1412+
1413+
it('defaults to an interval far below the reclaim TTL', async () => {
1414+
const { shouldHeartbeatSyncLock } = await import('@/lib/knowledge/connectors/sync-engine')
1415+
const { CONNECTOR_SYNC_STALE_LOCK_TTL_MS, SYNC_LOCK_HEARTBEAT_INTERVAL_MS } = await import(
1416+
'@/lib/knowledge/connectors/sync-limits'
1417+
)
1418+
1419+
/**
1420+
* A live run must beat many times over before the reclaim cutoff, or
1421+
* ordinary jitter reclaims a working sync — which is what made the reaper a
1422+
* one-way ratchet to `disabled` for slow in-process syncs.
1423+
*/
1424+
expect(SYNC_LOCK_HEARTBEAT_INTERVAL_MS * 4).toBeLessThan(CONNECTOR_SYNC_STALE_LOCK_TTL_MS)
1425+
expect(shouldHeartbeatSyncLock(SYNC_LOCK_HEARTBEAT_INTERVAL_MS, 0)).toBe(true)
1426+
expect(shouldHeartbeatSyncLock(SYNC_LOCK_HEARTBEAT_INTERVAL_MS - 1, 0)).toBe(false)
1427+
})
1428+
})
1429+
1430+
describe('heartbeatSyncLock', () => {
1431+
beforeEach(() => {
1432+
vi.clearAllMocks()
1433+
resetDbChainMock()
1434+
})
1435+
1436+
it('refreshes updatedAt under the run own lock guard', async () => {
1437+
const { heartbeatSyncLock } = await import('@/lib/knowledge/connectors/sync-engine')
1438+
1439+
await heartbeatSyncLock('c-1', 'run-a')
1440+
1441+
expect(dbChainMockFns.set.mock.calls[0][0]).toEqual({ updatedAt: expect.any(Date) })
1442+
1443+
// Guarded, so a beat doubles as an ownership probe rather than a blind touch.
1444+
const where = dbChainMockFns.where.mock.calls[0][0]
1445+
expect(
1446+
hasMockCondition(
1447+
where,
1448+
(node: MockCondition) =>
1449+
node.type === 'eq' &&
1450+
node.left === schemaMock.knowledgeConnector.syncLockToken &&
1451+
node.right === 'run-a'
1452+
)
1453+
).toBe(true)
1454+
})
1455+
1456+
it('reports a lost lock so the run can stop instead of racing its replacement', async () => {
1457+
const { heartbeatSyncLock } = await import('@/lib/knowledge/connectors/sync-engine')
1458+
1459+
dbChainMockFns.returning.mockResolvedValueOnce([])
1460+
expect(await heartbeatSyncLock('c-1', 'run-a')).toBe(false)
1461+
1462+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }])
1463+
expect(await heartbeatSyncLock('c-1', 'run-a')).toBe(true)
1464+
})
1465+
})

0 commit comments

Comments
 (0)