Skip to content

Commit 712b2c4

Browse files
committed
fix(knowledge): guard the completed sync log and free a deleted run's lock
`executeSync`'s success path closed its sync-log row before `writeTerminalConnectorState` ran its ownership check, and the close was guarded only on `status = 'started'`. That guard defers to the scheduler's sweep, but the sweep is not the only writer that strands a live run: the knowledge-base-deleted writers clear the token unconditionally, a user pausing a connector flips it out of `syncing`, and the reaper's reclaim and its log-close are two statements that can commit apart. In each case the run's connector write is refused while its log row is still `started`, so the run published a `completed` row for bookkeeping that was discarded — and `loadPreviousListingObservation` reads exactly those rows as corroboration for the next run's reconciliation. The close now takes the ownership condition itself, reusing `stillHoldsSyncLock` as an EXISTS predicate so the log row and the connector row are written under the same condition and cannot disagree. Swapping the two calls was considered and rejected: a `completeSyncLog` failure would then leave a `started` row on a connector already recorded `active`, the reaper would later mark it `failed`, and a legitimate observation would be lost silently. Only the success path is guarded — a `failed` row is never read back as evidence, and both failure paths legitimately close a run whose lock is already gone. A refused close short-circuits to the superseded result the terminal write would have produced two statements later. The `ConnectorDeletedException` handler hard-deleted leftover documents and closed its log, but wrote nothing to the connector row, leaving it `syncing` with a live token. Nothing else could clear it: the reaper requires `isNull(archivedAt)` and `isNull(deletedAt)`, so the one writer able to recover a stranded lock skips exactly the rows this path creates. It now releases token and lease and makes the transition terminal, matching the two knowledge-base-deleted writers. Guarded on ownership alone rather than `stillHoldsSyncLock`, for the same reason the heartbeat is: the connector being archived is this path's precondition, so a liveness clause would reject every write the release exists to make. A no-op when the row was hard deleted rather than archived — a user-initiated connector delete removes the row outright, leaving nothing to unwedge.
1 parent d4895da commit 712b2c4

2 files changed

Lines changed: 405 additions & 10 deletions

File tree

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

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2132,3 +2132,282 @@ describe('executeSync hard-delete reconciliation', () => {
21322132
expect(beats.length).toBeGreaterThan(1)
21332133
})
21342134
})
2135+
2136+
describe('completeSyncLog ownership guard', () => {
2137+
const RESULT = {
2138+
docsAdded: 1,
2139+
docsUpdated: 0,
2140+
docsDeleted: 0,
2141+
docsUnchanged: 0,
2142+
docsFailed: 0,
2143+
}
2144+
2145+
beforeEach(() => {
2146+
vi.clearAllMocks()
2147+
resetDbChainMock()
2148+
})
2149+
2150+
it('requires the run to still hold the connector lock when closing as completed', async () => {
2151+
const { completeSyncLog } = await import('@/lib/knowledge/connectors/sync-engine')
2152+
2153+
await completeSyncLog('log-1', 'completed', RESULT, { requireSyncLockOn: 'c-1' })
2154+
2155+
/**
2156+
* `status = 'started'` alone only defers to the sweep. A run stranded by any
2157+
* other writer — the knowledge-base-deleted writers, a user pausing the
2158+
* connector, a reclaim whose log-close committed separately — still has a
2159+
* `started` row, so without this it publishes a `completed` outcome whose
2160+
* connector bookkeeping was discarded.
2161+
*/
2162+
const outerWhere = dbChainMockFns.where.mock.calls[1][0]
2163+
expect(hasMockCondition(outerWhere, (node: MockCondition) => node.type === 'exists')).toBe(true)
2164+
2165+
// The subquery's own predicate, built before the outer where is assembled.
2166+
const subqueryWhere = dbChainMockFns.where.mock.calls[0][0]
2167+
expect(
2168+
hasMockCondition(
2169+
subqueryWhere,
2170+
(node: MockCondition) =>
2171+
node.type === 'eq' &&
2172+
node.left === schemaMock.knowledgeConnector.syncLockToken &&
2173+
node.right === 'log-1'
2174+
)
2175+
).toBe(true)
2176+
expect(
2177+
hasMockCondition(
2178+
subqueryWhere,
2179+
(node: MockCondition) =>
2180+
node.type === 'eq' &&
2181+
node.left === schemaMock.knowledgeConnector.status &&
2182+
node.right === 'syncing'
2183+
)
2184+
).toBe(true)
2185+
expect(
2186+
hasMockCondition(
2187+
subqueryWhere,
2188+
(node: MockCondition) =>
2189+
node.type === 'eq' &&
2190+
node.left === schemaMock.knowledgeConnector.id &&
2191+
node.right === 'c-1'
2192+
)
2193+
).toBe(true)
2194+
/**
2195+
* Reuses `stillHoldsSyncLock`, not ownership alone, so the log row and the
2196+
* connector row are written under exactly the same condition. Ownership-only
2197+
* would let a connector archived mid-run publish a `completed` row for a
2198+
* terminal write that was refused — the same mismatch, differently triggered.
2199+
*/
2200+
expect(
2201+
hasMockCondition(
2202+
subqueryWhere,
2203+
(node: MockCondition) =>
2204+
node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.archivedAt
2205+
)
2206+
).toBe(true)
2207+
})
2208+
2209+
it('leaves both failure closes unguarded', async () => {
2210+
const { completeSyncLog } = await import('@/lib/knowledge/connectors/sync-engine')
2211+
2212+
/**
2213+
* A `failed` row is never read back as evidence —
2214+
* `loadPreviousListingObservation` selects `status = 'completed'` — and both
2215+
* failure paths legitimately close a run whose lock is already gone. The
2216+
* deleted-connector path in particular runs on an archived row the reaper
2217+
* skips, so guarding it would strand the log row instead of closing it.
2218+
*/
2219+
await completeSyncLog('log-1', 'failed', RESULT, { errorMessage: 'boom' })
2220+
2221+
const where = dbChainMockFns.where.mock.calls[0][0]
2222+
expect(hasMockCondition(where, (node: MockCondition) => node.type === 'exists')).toBe(false)
2223+
})
2224+
2225+
it('reports whether the close landed', async () => {
2226+
const { completeSyncLog } = await import('@/lib/knowledge/connectors/sync-engine')
2227+
2228+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'log-1' }])
2229+
await expect(
2230+
completeSyncLog('log-1', 'completed', RESULT, { requireSyncLockOn: 'c-1' })
2231+
).resolves.toBe(true)
2232+
2233+
dbChainMockFns.returning.mockResolvedValueOnce([])
2234+
await expect(
2235+
completeSyncLog('log-1', 'completed', RESULT, { requireSyncLockOn: 'c-1' })
2236+
).resolves.toBe(false)
2237+
})
2238+
})
2239+
2240+
describe('executeSync terminal exits under a lost lock', () => {
2241+
const CONNECTOR = {
2242+
id: 'c-1',
2243+
knowledgeBaseId: 'kb-1',
2244+
connectorType: 'paged',
2245+
credentialId: null,
2246+
encryptedApiKey: null,
2247+
sourceConfig: {},
2248+
syncMode: 'full',
2249+
syncIntervalMinutes: 1440,
2250+
status: 'active',
2251+
lastSyncAt: null,
2252+
lastSyncDocCount: 0,
2253+
consecutiveFailures: 0,
2254+
syncLockToken: null,
2255+
}
2256+
2257+
beforeEach(() => {
2258+
vi.clearAllMocks()
2259+
resetDbChainMock()
2260+
})
2261+
2262+
afterEach(() => {
2263+
resetDbChainMock()
2264+
})
2265+
2266+
/** Queues the connector, its knowledge base, and the lock CAS. */
2267+
function primeLockedRun() {
2268+
queueTableRows(schemaMock.knowledgeConnector, [CONNECTOR])
2269+
queueTableRows(schemaMock.knowledgeBase, [{ userId: 'u-1', workspaceId: 'ws-1' }])
2270+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1' }])
2271+
}
2272+
2273+
it('skips the success state write when its guarded log close is refused', async () => {
2274+
const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine')
2275+
2276+
primeLockedRun()
2277+
// hasTombstonedDocs, existingDocs, tombstonedDocs, excludedDocs.
2278+
queueTableRows(schemaMock.document, [])
2279+
queueTableRows(schemaMock.document, [])
2280+
queueTableRows(schemaMock.document, [])
2281+
queueTableRows(schemaMock.document, [])
2282+
// The post-batch presence check: both targets are healthy, so the run
2283+
// reaches its success path rather than a deletion exit.
2284+
queueTableRows(schemaMock.knowledgeConnector, [
2285+
{ connectorArchivedAt: null, connectorDeletedAt: null, kbDeletedAt: null },
2286+
])
2287+
// Every later `.returning()` falls through to the empty default, so the
2288+
// guarded log close matches no row — the run no longer owns its outcome.
2289+
mockListDocuments.mockResolvedValue({ documents: [], hasMore: false })
2290+
2291+
const result = await executeSync('c-1', {
2292+
billingAttribution: { workspaceId: 'ws-1' } as never,
2293+
})
2294+
2295+
expect(result.error).toBe('sync_superseded')
2296+
2297+
/**
2298+
* A refused close means the run no longer owns the outcome it was about to
2299+
* publish, which is exactly what the terminal connector write would have
2300+
* rejected two statements later. Short-circuiting there keeps the reported
2301+
* outcome identical while skipping the intervening document count.
2302+
*/
2303+
expect(dbChainMockFns.set).not.toHaveBeenCalledWith(
2304+
expect.objectContaining({ status: 'active', consecutiveFailures: 0 })
2305+
)
2306+
2307+
// The success call site is the one that must ask for the guard.
2308+
expect(
2309+
dbChainMockFns.where.mock.calls.some((call) =>
2310+
hasMockCondition(call[0], (node: MockCondition) => node.type === 'exists')
2311+
)
2312+
).toBe(true)
2313+
})
2314+
2315+
it('releases the lock on a connector archived out from under the run', async () => {
2316+
const { executeSync } = await import('@/lib/knowledge/connectors/sync-engine')
2317+
const { hardDeleteDocuments } = await import('@/lib/knowledge/documents/service')
2318+
2319+
primeLockedRun()
2320+
// hasTombstonedDocs, existingDocs, tombstonedDocs, excludedDocs.
2321+
queueTableRows(schemaMock.document, [])
2322+
queueTableRows(schemaMock.document, [])
2323+
queueTableRows(schemaMock.document, [])
2324+
queueTableRows(schemaMock.document, [])
2325+
// The per-batch presence check: the connector row is archived.
2326+
queueTableRows(schemaMock.knowledgeConnector, [
2327+
{ connectorArchivedAt: new Date(), connectorDeletedAt: null, kbDeletedAt: null },
2328+
])
2329+
// The leftover-document cleanup this path performs.
2330+
queueTableRows(schemaMock.document, [])
2331+
vi.mocked(hardDeleteDocuments).mockResolvedValue(0)
2332+
2333+
mockListDocuments.mockResolvedValue({
2334+
documents: [
2335+
{
2336+
externalId: 'ext-1',
2337+
title: 'ext-1',
2338+
content: 'body',
2339+
contentHash: 'h',
2340+
mimeType: 'text/plain',
2341+
metadata: {},
2342+
},
2343+
],
2344+
hasMore: false,
2345+
})
2346+
2347+
const result = await executeSync('c-1', {
2348+
billingAttribution: { workspaceId: 'ws-1' } as never,
2349+
})
2350+
2351+
expect(result.error).toBe('Connector deleted during sync')
2352+
2353+
/**
2354+
* This exit wrote nothing to the connector row, leaving it `syncing` with a
2355+
* live token. The reaper requires `isNull(archivedAt)` and `isNull(deletedAt)`,
2356+
* so the one writer that could clear a stranded lock skips exactly the rows
2357+
* this path creates. Matches the two knowledge-base-deleted writers: release
2358+
* token and lease, and make the transition terminal.
2359+
*/
2360+
const release = dbChainMockFns.set.mock.calls.find(
2361+
(call) =>
2362+
(call[0] as Record<string, unknown> | undefined)?.lastSyncError ===
2363+
'Connector deleted during sync'
2364+
)
2365+
expect(release?.[0]).toEqual(
2366+
expect.objectContaining({
2367+
status: 'error',
2368+
nextSyncAt: null,
2369+
syncLockToken: null,
2370+
syncLockLeaseAt: null,
2371+
})
2372+
)
2373+
2374+
/**
2375+
* Guarded on ownership alone, never on {@link stillHoldsSyncLock}: the
2376+
* connector being archived is this path's precondition, so a liveness clause
2377+
* would reject every write the release exists to make.
2378+
*/
2379+
const releaseOrder =
2380+
dbChainMockFns.set.mock.invocationCallOrder[
2381+
dbChainMockFns.set.mock.calls.indexOf(release as never)
2382+
]
2383+
const releaseWhereIndex = dbChainMockFns.where.mock.invocationCallOrder.findIndex(
2384+
(order) => order > releaseOrder
2385+
)
2386+
const releaseWhere = dbChainMockFns.where.mock.calls[releaseWhereIndex][0]
2387+
expect(
2388+
hasMockCondition(
2389+
releaseWhere,
2390+
(node: MockCondition) =>
2391+
node.type === 'eq' && node.left === schemaMock.knowledgeConnector.syncLockToken
2392+
)
2393+
).toBe(true)
2394+
expect(
2395+
hasMockCondition(
2396+
releaseWhere,
2397+
(node: MockCondition) =>
2398+
node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.archivedAt
2399+
)
2400+
).toBe(false)
2401+
2402+
/**
2403+
* And this path's log close stays unguarded. Its connector is archived, so an
2404+
* ownership-guarded close would match nothing and leave the row `started`
2405+
* until the sync-log sweep mislabelled it.
2406+
*/
2407+
expect(
2408+
dbChainMockFns.where.mock.calls.some((call) =>
2409+
hasMockCondition(call[0], (node: MockCondition) => node.type === 'exists')
2410+
)
2411+
).toBe(false)
2412+
})
2413+
})

0 commit comments

Comments
 (0)