Skip to content

Commit 9fada81

Browse files
committed
fix(connectors): require a lock to be held, owned, and heartbeated before sparing its log row
The previous round spared a sync-log row whose id was still the connector's lock token. That is necessary but not sufficient: the reaper's reclaim filters out archived and soft-deleted connectors, so their rows keep status syncing with an intact token indefinitely and would have been spared forever. It also spared a run that died without ever being reclaimed. Keying on connector liveness alone has the opposite gap — it cannot tell which run holds the lock, so an orphan on a connector that is immediately re-locked by a replacement is spared until a tick happens to catch the connector idle. Sparing now requires all three: the connector is locked, this row's run is the holder, and that lock is being heartbeated. An orphan can satisfy at most two, so no timing window strands one. This is a per-row liveness predicate, not a restriction of the candidate set — every stale row is still evaluated. A backlog row predating the token column fails on two counts and still drains, so the sweep remains self-healing.
1 parent 4b98668 commit 9fada81

2 files changed

Lines changed: 71 additions & 22 deletions

File tree

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

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -214,27 +214,64 @@ describe('connector sync scheduler stale-lock reaper', () => {
214214
).toBe(true)
215215
})
216216

217-
it('spares the log row of a run that still holds its connector lock', async () => {
217+
/** The `NOT EXISTS` liveness fragment the sweep's WHERE carries. */
218+
function sweepLivenessFragment(): MockSqlFragment {
219+
const where = dbChainMockFns.where.mock.calls[1][0]
220+
const fragment = flattenMockConditions(where).find(
221+
(node: MockCondition) => typeof node.toSQL === 'function'
222+
)
223+
expect(fragment).toBeDefined()
224+
return fragment as unknown as MockSqlFragment
225+
}
226+
227+
it('spares the log row of a run whose lock is still being heartbeated', async () => {
218228
await runTickRecovering(['connector-1'])
219229

220230
/**
221231
* The sweep keys on `startedAt`, which no heartbeat refreshes, so age alone
222232
* would close a legitimately long in-process run's row and record a
223-
* successful sync as failed.
233+
* successful sync as failed. Every clause is pinned: sparing requires the
234+
* connector to be locked, THIS row's run to be the holder, and that lock to
235+
* be live — an orphan can satisfy at most two.
224236
*/
225-
const where = dbChainMockFns.where.mock.calls[1][0]
226-
const liveness = flattenMockConditions(where).find(
227-
(node: MockCondition) => typeof node.toSQL === 'function'
237+
const rendered = sweepLivenessFragment().toSQL().sql.replace(/\s+/g, ' ').trim()
238+
239+
expect(rendered).toBe(
240+
"NOT EXISTS ( SELECT 1 FROM ? WHERE ? = ? AND ? = ? AND ? = 'syncing' AND ? > ? )"
228241
)
229-
expect(liveness).toBeDefined()
242+
})
230243

231-
const rendered = (liveness as unknown as MockSqlFragment).toSQL().sql
232-
expect(rendered).toContain('NOT EXISTS')
233-
expect(rendered).toContain("'syncing'")
244+
it('identifies the lock holder by token, not merely by the connector syncing', async () => {
245+
await runTickRecovering(['connector-1'])
234246

235-
const bound = (liveness as unknown as MockSqlFragment).values
236-
expect(bound).toContain(schemaMock.knowledgeConnector.syncLockToken)
237-
expect(bound).toContain(schemaMock.knowledgeConnectorSyncLog.id)
247+
/**
248+
* Without the token clause the sweep spares every `started` row on a locked
249+
* connector — including an orphan from a crashed run whose replacement now
250+
* holds the lock, which would then never drain while that connector stays
251+
* busy.
252+
*/
253+
expect(sweepLivenessFragment().values).toContain(schemaMock.knowledgeConnector.syncLockToken)
254+
})
255+
256+
it('requires the held lock to be heartbeated, not merely held', async () => {
257+
await runTickRecovering(['connector-1'])
258+
259+
/**
260+
* Without the freshness clause a run that died without being reclaimed — or
261+
* one on an archived or deleted connector, which the reclaim skips entirely
262+
* — keeps `status = 'syncing'` and its token forever, so its row is spared
263+
* forever.
264+
*/
265+
const bound = sweepLivenessFragment().values
266+
expect(bound).toContain(schemaMock.knowledgeConnector.updatedAt)
267+
268+
const cutoff = bound.find(
269+
(value): value is { value: Date } =>
270+
typeof value === 'object' &&
271+
value !== null &&
272+
(value as { value?: unknown }).value instanceof Date
273+
)
274+
expect(cutoff).toBeDefined()
238275
})
239276

240277
it('closes stale sync-log rows even when no connector was reclaimed this tick', async () => {

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

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,25 +32,37 @@ const DISPATCH_CONCURRENCY = 10
3232
const STALE_LOCK_ERROR_MESSAGE = 'Sync timed out (stale lock recovered)'
3333

3434
/**
35-
* Excludes a sync-log row whose run still demonstrably holds its connector's
36-
* lock.
35+
* Excludes a sync-log row belonging to a run that is demonstrably still alive.
3736
*
3837
* The sweep keys on `startedAt`, and nothing refreshes that — the heartbeat
3938
* renews `knowledge_connector.updatedAt`, and the log table has no equivalent
4039
* column. So a legitimately long in-process run keeps its connector lock but
4140
* would still have its log row closed as `failed` at the TTL, recording a
4241
* successful sync as a failure and losing its counters to
43-
* `loadPreviousListingObservation`. Matching the connector's `syncLockToken`
44-
* against the row's own id is exactly "this run is still the lock holder", so a
45-
* live run is spared while every orphan — reclaimed, replaced, or predating the
46-
* token column, where the token is NULL — is still swept.
42+
* `loadPreviousListingObservation`, which reads only `completed` rows.
43+
*
44+
* The heartbeat is the single source of liveness truth, so this defers to it.
45+
* Sparing requires all three of: the connector is locked, THIS row's run is the
46+
* lock holder, and that lock is being heartbeated. An orphan can satisfy at most
47+
* two, so none is ever stranded:
48+
* - reclaimed after a hard kill — connector is `error`, token cleared;
49+
* - a replacement holds the lock — the token is the successor's, not this row's;
50+
* - died without being reclaimed, including on an archived or deleted connector
51+
* the reclaim skips entirely — `updatedAt` is stale.
52+
*
53+
* This re-references the connector row, which an earlier fix deliberately moved
54+
* away from. That coupling was different: it restricted the sweep's candidate
55+
* set to *this tick's reclaims*, which made a pre-existing backlog undrainable.
56+
* This is a per-row liveness predicate — every stale row is still a candidate,
57+
* so the sweep stays self-healing.
4758
*/
48-
function runNoLongerHoldsItsLock(): SQL {
59+
function logRowNotHeldByLiveRun(staleCutoff: Date): SQL {
4960
return sql`NOT EXISTS (
5061
SELECT 1 FROM ${knowledgeConnector}
5162
WHERE ${knowledgeConnector.id} = ${knowledgeConnectorSyncLog.connectorId}
5263
AND ${knowledgeConnector.syncLockToken} = ${knowledgeConnectorSyncLog.id}
5364
AND ${knowledgeConnector.status} = 'syncing'
65+
AND ${knowledgeConnector.updatedAt} > ${sql.param(staleCutoff, knowledgeConnector.updatedAt)}
5466
)`
5567
}
5668

@@ -142,8 +154,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
142154
*
143155
* Age alone does not prove a run is dead: the in-process fallback path has
144156
* no duration cap, so a large self-hosted sync can genuinely still be
145-
* working past the TTL. `runNoLongerHoldsItsLock` is what makes this safe —
146-
* a run still holding its connector's lock is spared regardless of age.
157+
* working past the TTL. `logRowNotHeldByLiveRun` is what makes this safe —
158+
* a run whose lock is still being heartbeated is spared regardless of age.
147159
* The age predicate is also per-row on `startedAt`, so a fresh run's log row
148160
* can never be caught by it, even on a connector whose previous run is being
149161
* reclaimed in this same tick.
@@ -159,7 +171,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
159171
and(
160172
eq(knowledgeConnectorSyncLog.status, 'started'),
161173
lte(knowledgeConnectorSyncLog.startedAt, staleCutoff),
162-
runNoLongerHoldsItsLock()
174+
logRowNotHeldByLiveRun(staleCutoff)
163175
)
164176
)
165177
.returning({ id: knowledgeConnectorSyncLog.id })

0 commit comments

Comments
 (0)