Skip to content

Commit 63569a2

Browse files
authored
fix(connectors): count hard-kill failures and cap deletion blast radius (#6909)
1 parent 01795e1 commit 63569a2

48 files changed

Lines changed: 25991 additions & 1710 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/copilot/feedback/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ edges:
357357

358358
const { eq } = await import('drizzle-orm')
359359
expect(dbChainMockFns.where).toHaveBeenCalled()
360-
expect(eq).toHaveBeenCalledWith('userId', 'user-123')
360+
expect(eq).toHaveBeenCalledWith('copilotFeedback.userId', 'user-123')
361361
})
362362

363363
it('should handle database errors gracefully', async () => {

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

Lines changed: 517 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 154 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import { db } from '@sim/db'
2-
import { knowledgeBase, knowledgeConnector } from '@sim/db/schema'
2+
import { knowledgeBase, knowledgeConnector, knowledgeConnectorSyncLog } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, asc, eq, inArray, isNull, lte } from 'drizzle-orm'
4+
import { and, asc, eq, inArray, isNull, lte, type SQL, sql } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { verifyCronAuth } from '@/lib/auth/internal'
77
import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attribution'
88
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
99
import { generateRequestId } from '@/lib/core/utils/request'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import { dispatchSync } from '@/lib/knowledge/connectors/queue'
12-
import { CONNECTOR_SYNC_STALE_LOCK_TTL_MS } from '@/lib/knowledge/connectors/sync-limits'
12+
import {
13+
CONNECTOR_AUTO_DISABLED_ERROR,
14+
CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES,
15+
CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES,
16+
CONNECTOR_SYNC_STALE_LOCK_TTL_MS,
17+
MAX_CONSECUTIVE_FAILURES,
18+
} from '@/lib/knowledge/connectors/sync-limits'
1319

1420
export const dynamic = 'force-dynamic'
1521

@@ -24,6 +30,99 @@ const MAX_DISPATCHES_PER_TICK = 200
2430
/** Each dispatch does a joined SELECT + conditional UPDATE against the shared pool. */
2531
const DISPATCH_CONCURRENCY = 10
2632

33+
const STALE_LOCK_ERROR_MESSAGE = 'Sync timed out (stale lock recovered)'
34+
35+
/**
36+
* How long the connector holding the lock has gone without proving it is alive.
37+
*
38+
* `sync_lock_lease_at` is written only by lock acquisition and the heartbeat, so
39+
* it is the lease; `updated_at` is the row's modification time and merely used
40+
* to double as one. Read through `COALESCE` rather than backfilled: a plain
41+
* `lease <= cutoff` is NULL-false, so a row already `syncing` when this column
42+
* shipped would never be reclaimed — strictly worse than the behaviour it
43+
* replaces. The fallback also keeps the reaper correct against any future
44+
* writer that takes the lock without opening a lease.
45+
*/
46+
function syncLockLease(): SQL {
47+
return sql`COALESCE(${knowledgeConnector.syncLockLeaseAt}, ${knowledgeConnector.updatedAt})`
48+
}
49+
50+
/**
51+
* The error a reclaimed connector reports.
52+
*
53+
* Mirrors {@link reclaimedStatus}: once the reclaim disables the connector,
54+
* {@link reclaimedNextSyncAt} sets no next attempt, so telling the operator the
55+
* sync merely timed out describes a retry that will never happen. The disabled
56+
* wording is the shared one `buildSyncFailureUpdate` writes, so the in-process
57+
* breaker and this SQL breaker cannot drift into two different messages for one
58+
* verdict.
59+
*/
60+
function reclaimedError(): SQL {
61+
return sql`CASE WHEN COALESCE(${knowledgeConnector.consecutiveFailures}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES} THEN ${CONNECTOR_AUTO_DISABLED_ERROR} ELSE ${STALE_LOCK_ERROR_MESSAGE} END`
62+
}
63+
64+
/**
65+
* Excludes a sync-log row belonging to a run that is demonstrably still alive.
66+
*
67+
* The sweep keys on `startedAt`, and nothing refreshes that — the heartbeat
68+
* renews `knowledge_connector.updatedAt`, and the log table has no equivalent
69+
* column. So a legitimately long in-process run keeps its connector lock but
70+
* would still have its log row closed as `failed` at the TTL, recording a
71+
* successful sync as a failure and losing its counters to
72+
* `loadPreviousListingObservation`, which reads only `completed` rows.
73+
*
74+
* The heartbeat is the single source of liveness truth, so this defers to it,
75+
* reading the same {@link syncLockLease} expression the reclaim predicate does.
76+
* Sparing requires all three of: the connector is locked, THIS row's run is the
77+
* lock holder, and that lock is being heartbeated. An orphan can satisfy at most
78+
* two, so none is ever stranded:
79+
* - reclaimed after a hard kill — connector is `error`, token cleared;
80+
* - a replacement holds the lock — the token is the successor's, not this row's;
81+
* - died without being reclaimed, including on an archived or deleted connector
82+
* the reclaim skips entirely — its lease is stale.
83+
*
84+
* This re-references the connector row, which an earlier fix deliberately moved
85+
* away from. That coupling was different: it restricted the sweep's candidate
86+
* set to *this tick's reclaims*, which made a pre-existing backlog undrainable.
87+
* This is a per-row liveness predicate — every stale row is still a candidate,
88+
* so the sweep stays self-healing.
89+
*/
90+
function logRowNotHeldByLiveRun(staleCutoff: Date): SQL {
91+
return sql`NOT EXISTS (
92+
SELECT 1 FROM ${knowledgeConnector}
93+
WHERE ${knowledgeConnector.id} = ${knowledgeConnectorSyncLog.connectorId}
94+
AND ${knowledgeConnector.syncLockToken} = ${knowledgeConnectorSyncLog.id}
95+
AND ${knowledgeConnector.status} = 'syncing'
96+
AND ${syncLockLease()} > ${sql.param(staleCutoff, knowledgeConnector.syncLockLeaseAt)}
97+
)`
98+
}
99+
100+
/**
101+
* The reclaimed connector's new consecutive-failure count.
102+
*
103+
* A hard kill (OOM/SIGKILL) skips `executeSync`'s `catch` and `finally`
104+
* entirely, so this reaper is the ONLY writer that ever observes that failure.
105+
* Computed in SQL rather than read-then-written because two overlapping cron
106+
* ticks reclaiming the same row would otherwise both read the same value and
107+
* write the same increment, losing one.
108+
*/
109+
function reclaimedFailureCount(): SQL {
110+
return sql`COALESCE(${knowledgeConnector.consecutiveFailures}, 0) + 1`
111+
}
112+
113+
/** Disables the connector once the reclaimed count reaches the shared threshold. */
114+
function reclaimedStatus(): SQL {
115+
return sql`CASE WHEN COALESCE(${knowledgeConnector.consecutiveFailures}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES} THEN 'disabled' ELSE 'error' END`
116+
}
117+
118+
/**
119+
* The reclaimed connector's next attempt, on the shared failure ladder
120+
* (`connectorFailureBackoffMinutes`). A disabled connector gets no next attempt.
121+
*/
122+
function reclaimedNextSyncAt(): SQL {
123+
return sql`CASE WHEN COALESCE(${knowledgeConnector.consecutiveFailures}, 0) + 1 >= ${MAX_CONSECUTIVE_FAILURES} THEN NULL ELSE now() + LEAST((COALESCE(${knowledgeConnector.consecutiveFailures}, 0) + 1) * ${CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES}, ${CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES}) * INTERVAL '1 minute' END`
124+
}
125+
27126
/**
28127
* Cron endpoint that checks for connectors due for sync and dispatches sync jobs.
29128
* Should be called every 5 minutes by an external cron service.
@@ -45,15 +144,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
45144
const recoveredConnectors = await db
46145
.update(knowledgeConnector)
47146
.set({
48-
status: 'error',
49-
lastSyncError: 'Sync timed out (stale lock recovered)',
50-
nextSyncAt: new Date(now.getTime() + 10 * 60 * 1000),
51-
updatedAt: now,
147+
status: reclaimedStatus(),
148+
lastSyncError: reclaimedError(),
149+
nextSyncAt: reclaimedNextSyncAt(),
150+
consecutiveFailures: reclaimedFailureCount(),
151+
// Releases the reclaimed run's ownership token so its terminal write can
152+
// no longer match, even before a replacement takes the lock, and closes
153+
// its lease so a re-locked row starts from a fresh one.
154+
syncLockToken: null,
155+
syncLockLeaseAt: null,
156+
updatedAt: sql`now()`,
52157
})
53158
.where(
54159
and(
55160
eq(knowledgeConnector.status, 'syncing'),
56-
lte(knowledgeConnector.updatedAt, staleCutoff),
161+
sql`${syncLockLease()} <= ${sql.param(staleCutoff, knowledgeConnector.syncLockLeaseAt)}`,
57162
isNull(knowledgeConnector.archivedAt),
58163
isNull(knowledgeConnector.deletedAt)
59164
)
@@ -67,6 +172,47 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
67172
)
68173
}
69174

175+
/**
176+
* Closes sync-log rows left `started` by a killed run. Nothing else ever
177+
* reconciles them, and `loadPreviousListingObservation` reads only
178+
* `completed` rows, so a never-closed run silently ages out the previous
179+
* observation it should have provided.
180+
*
181+
* Deliberately independent of this tick's reclaims rather than scoped to
182+
* them. A row orphaned before this shipped — or by a transient failure of
183+
* this very statement — belongs to a connector already flipped out of
184+
* `syncing`, so it would never appear in a future reclaim batch and would
185+
* stay stranded forever. Keying off the row's own `startedAt` instead makes
186+
* the sweep self-healing and lets it drain the existing backlog.
187+
*
188+
* Age alone does not prove a run is dead: the in-process fallback path has
189+
* no duration cap, so a large self-hosted sync can genuinely still be
190+
* working past the TTL. `logRowNotHeldByLiveRun` is what makes this safe —
191+
* a run whose lock is still being heartbeated is spared regardless of age.
192+
* The age predicate is also per-row on `startedAt`, so a fresh run's log row
193+
* can never be caught by it, even on a connector whose previous run is being
194+
* reclaimed in this same tick.
195+
*/
196+
const closedSyncLogs = await db
197+
.update(knowledgeConnectorSyncLog)
198+
.set({
199+
status: 'failed',
200+
completedAt: sql`now()`,
201+
errorMessage: STALE_LOCK_ERROR_MESSAGE,
202+
})
203+
.where(
204+
and(
205+
eq(knowledgeConnectorSyncLog.status, 'started'),
206+
lte(knowledgeConnectorSyncLog.startedAt, staleCutoff),
207+
logRowNotHeldByLiveRun(staleCutoff)
208+
)
209+
)
210+
.returning({ id: knowledgeConnectorSyncLog.id })
211+
212+
if (closedSyncLogs.length > 0) {
213+
logger.warn(`[${requestId}] Closed ${closedSyncLogs.length} orphaned connector sync log(s)`)
214+
}
215+
70216
const dueConnectors = await db
71217
.select({
72218
id: knowledgeConnector.id,

apps/sim/app/api/knowledge/utils.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ describe('Knowledge Utils', () => {
137137
beforeEach(() => {
138138
vi.clearAllMocks()
139139
resetDbChainMock()
140+
// The document claim gates on the row it writes back, so an unstubbed
141+
// `returning()` would abort processing before any completion write.
142+
dbChainMockFns.returning.mockResolvedValue([{ id: 'doc1' }])
140143
// `unstubGlobals: true` removes the module-scope fetch stub after the
141144
// first test in the worker; re-stub it per test.
142145
vi.stubGlobal('fetch', createEmbeddingFetchMock())

apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => {
352352
// they vanish from VFS listings and name resolution…
353353
expect(dbChainMockFns.where).toHaveBeenCalledWith({
354354
type: 'inArray',
355-
column: 'id',
355+
column: 'workspaceFiles.id',
356356
values: ['wf_dead1', 'wf_dead2'],
357357
})
358358
// …and their resource chips are dropped from the new chat.

apps/sim/app/api/mothership/chats/read/route.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ describe('POST /api/mothership/chats/read', () => {
5252
expect(orClause).toBeDefined()
5353
expect(orClause?.conditions).toEqual(
5454
expect.arrayContaining([
55-
{ type: 'isNull', column: 'lastSeenAt' },
56-
{ type: 'lt', left: 'lastSeenAt', right: 'updatedAt' },
55+
{ type: 'isNull', column: 'copilotChats.lastSeenAt' },
56+
{ type: 'lt', left: 'copilotChats.lastSeenAt', right: 'copilotChats.updatedAt' },
5757
])
5858
)
5959
})

apps/sim/app/api/resume/poll/route.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,9 @@ describe('time-pause resume admission', () => {
385385
expect(
386386
inArrayMock.mock.calls.some(
387387
([column, values]) =>
388-
column === 'id' && Array.isArray(values) && values.join(',') === 'paused-2,paused-3'
388+
column === 'pausedExecutions.id' &&
389+
Array.isArray(values) &&
390+
values.join(',') === 'paused-2,paused-3'
389391
)
390392
).toBe(true)
391393
expect(executionSnapshotFromJsonMock).toHaveBeenCalledTimes(2)
@@ -537,7 +539,7 @@ describe('time-pause resume admission', () => {
537539
[LEGACY_PAUSED_SNAPSHOT_FALLBACK_CHUNK_SIZE],
538540
])
539541
const snapshotIdBatches = inArrayMock.mock.calls
540-
.filter(([column]) => column === 'id')
542+
.filter(([column]) => column === 'pausedExecutions.id')
541543
.map(([, ids]) => ids as string[])
542544
expect(snapshotIdBatches.map((ids) => ids.length)).toEqual([10, 4, 4, 2])
543545
expect(

apps/sim/app/api/v2/knowledge/utils.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import type {
44
V2KnowledgeTaggedDocument,
55
} from '@/lib/api/contracts/v2/knowledge'
66
import { ALL_TAG_SLOTS, type AllTagSlot } from '@/lib/knowledge/constants'
7+
import {
8+
DOCUMENT_PROCESSING_STATUSES,
9+
type DocumentProcessingStatus,
10+
} from '@/lib/knowledge/documents/types'
711
import type { DocumentTagDefinition } from '@/lib/knowledge/tags/types'
812
import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types'
913
import { getUserEmailsByIds, requireResolvedUserEmail } from '@/lib/users/queries'
@@ -39,9 +43,7 @@ export function toV2DocumentTags(
3943
return tags
4044
}
4145

42-
const PROCESSING_STATUSES = ['pending', 'processing', 'completed', 'failed'] as const
43-
44-
type V2DocumentProcessingStatus = (typeof PROCESSING_STATUSES)[number]
46+
type V2DocumentProcessingStatus = DocumentProcessingStatus
4547

4648
/**
4749
* Narrows a stored processing status onto the published enum. An absent value
@@ -50,7 +52,7 @@ type V2DocumentProcessingStatus = (typeof PROCESSING_STATUSES)[number]
5052
*/
5153
function toProcessingStatus(status: string | null | undefined): V2DocumentProcessingStatus {
5254
if (status === null || status === undefined) return 'pending'
53-
const known = PROCESSING_STATUSES.find((candidate) => candidate === status)
55+
const known = DOCUMENT_PROCESSING_STATUSES.find((candidate) => candidate === status)
5456
if (!known) throw new Error(`Unexpected knowledge document processing status: ${status}`)
5557
return known
5658
}

0 commit comments

Comments
 (0)