Skip to content

Commit d5ebecd

Browse files
authored
fix(search): preserve connector failure diagnostics (#7958)
1 parent 27fde4c commit d5ebecd

12 files changed

Lines changed: 233 additions & 8 deletions

File tree

‎apps/sim/connectors/google-workspace/api-errors.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ afterEach(() => {
2121
})
2222

2323
describe('Google API diagnostics', () => {
24+
it('retains the Calendar account-status reason without retaining the response message', async () => {
25+
const error = await readGoogleApiError(failure(403, 'notACalendarUser'), 'calendar.events.list')
26+
expect(error.reasonsComplete).toBe(true)
27+
expect(error.rateLimited).toBe(false)
28+
expect(getConnectorFailureDiagnostic(error)).toMatchObject({
29+
status: 403,
30+
operation: 'calendar.events.list',
31+
reasons: ['notACalendarUser'],
32+
reasonState: 'present',
33+
})
34+
expect(JSON.stringify(error)).not.toContain(RESPONSE_SECRET)
35+
})
36+
2437
it.each([
2538
[400, 'badRequest', 'request_rejected'],
2639
[403, 'forbidden', 'authorization'],
@@ -227,6 +240,7 @@ describe('Google API retries', () => {
227240
it.each([
228241
[400, 'failedPrecondition'],
229242
[403, 'forbidden'],
243+
[403, 'notACalendarUser'],
230244
[404, 'notFound'],
231245
] as const)('does not retry or suppress %s %s', async (status, reason) => {
232246
const fetch = vi.fn().mockResolvedValueOnce(failure(status, reason))

‎apps/sim/connectors/google-workspace/api-errors.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const SAFE_REASONS = new Set([
3232
'internalError',
3333
'invalid',
3434
'invalidArgument',
35+
'notACalendarUser',
3536
'notFound',
3637
'quotaExceeded',
3738
'rateLimitExceeded',

‎apps/sim/lib/knowledge/connectors/connector-error.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,24 @@ describe('connector failure diagnostics', () => {
7373
})
7474
})
7575

76+
it.each([
77+
['canceling statement due to statement timeout', 'statement_timeout'],
78+
['canceling statement due to user request', 'user_cancel'],
79+
])('distinguishes cancellations with the same SQLSTATE: %s', (message, databaseReason) => {
80+
const error = new DrizzleQueryError(
81+
'select private_column from private_source',
82+
['private-value'],
83+
Object.assign(new Error(message), { code: '57014', detail: 'private driver detail' })
84+
)
85+
expect(getConnectorFailureDiagnostic(error)).toEqual({
86+
category: 'database',
87+
code: '57014',
88+
databaseReason,
89+
message: 'Database request failed (SQLSTATE 57014).',
90+
})
91+
expect(JSON.stringify(getConnectorFailureDiagnostic(error))).not.toContain('private')
92+
})
93+
7694
it('suppresses query text even when the driver provides no error code', () => {
7795
expect(
7896
getConnectorFailureDiagnostic(new DrizzleQueryError('select private', ['private'], null))

‎apps/sim/lib/knowledge/connectors/connector-error.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { findCause, getPostgresErrorCode } from '@sim/utils/errors'
1+
import {
2+
findCause,
3+
getPostgresCancellationReason,
4+
getPostgresErrorCode,
5+
type PostgresCancellationReason,
6+
} from '@sim/utils/errors'
27
import { DrizzleQueryError } from 'drizzle-orm/errors'
38
import { getEmbeddingAPIError } from '@/lib/embeddings/api-error'
49
import {
@@ -13,6 +18,7 @@ export interface ConnectorFailureDiagnostic {
1318
message: string
1419
status?: number
1520
code?: string
21+
databaseReason?: PostgresCancellationReason
1622
operation?: string
1723
reasons?: readonly string[]
1824
reasonState?: ConnectorSourceReasonState
@@ -57,9 +63,11 @@ function classifyFailure(error: unknown): ConnectorFailureDiagnostic | null {
5763
}
5864
}
5965
if (code && /^(?:[0-9][0-9A-Z]|F0|HV|P0|XX)[0-9A-Z]{3}$/.test(code)) {
66+
const databaseReason = getPostgresCancellationReason(error)
6067
return {
6168
category: 'database',
6269
code,
70+
...(databaseReason ? { databaseReason } : {}),
6371
message: `Database request failed (SQLSTATE ${code}).`,
6472
}
6573
}

‎apps/sim/lib/knowledge/connectors/google-company-scheduler.test.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -222,21 +222,25 @@ describe('durable Google company user scheduling', () => {
222222
expect(f.saved().cursor!.length).toBeLessThan(2048)
223223
})
224224

225-
it.each(['google_calendar', 'google_drive'])(
226-
'retains an unresolved %s user and continues other users',
227-
async (provider) => {
225+
it.each([
226+
['google_calendar', []],
227+
['google_calendar', ['notACalendarUser']],
228+
['google_drive', []],
229+
] as const)(
230+
'retains a failed %s user (%j) and continues other users',
231+
async (provider, reasons) => {
228232
mocks.directory.mockResolvedValue({ users: [user('a'), user('z')] })
229233
const f = fixture(provider)
230234
f.list.mockRejectedValueOnce(
231235
provider === 'google_drive'
232236
? new GoogleDriveApiError(403, [], 'drive.files.list', false)
233-
: new GoogleApiError('calendar.events.list', 403, [], false)
237+
: new GoogleApiError('calendar.events.list', 403, reasons, reasons.length > 0)
234238
)
235239
await f.step(4)
236240
expect(f.rows.get('a:content')).toMatchObject({
237241
complete: false,
238242
attempts: 1,
239-
failure: { status: 403, reasons: [] },
243+
failure: { status: 403, reasons },
240244
})
241245
expect(f.rows.get('z:content')?.complete).toBe(true)
242246
expect(f.saved()).toMatchObject({

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
schemaMock,
1414
} from '@sim/testing'
1515
import { generateShortId } from '@sim/utils/id'
16+
import { DrizzleQueryError } from 'drizzle-orm/errors'
1617
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1718
import * as connectorTokens from '@/lib/knowledge/connectors/access-token'
1819
import { executeSync, isConnectorRunnableStatus } from '@/lib/knowledge/connectors/sync-engine'
@@ -83,6 +84,12 @@ const { mockGetDocument, mockMapTags, mockListDocuments } = vi.hoisted(() => ({
8384
mockListDocuments: vi.fn(),
8485
}))
8586

87+
const { mockLogError } = vi.hoisted(() => ({ mockLogError: vi.fn() }))
88+
vi.mock('@sim/logger', async () => {
89+
const { createMockLogger } = await import('@sim/testing/mocks/logger.mock')
90+
return { createLogger: () => ({ ...createMockLogger(), error: mockLogError }) }
91+
})
92+
8693
vi.mock('@/lib/billing/core/billing-attribution', () => ({
8794
assertBillingAttributionOwner: vi.fn(),
8895
assertBillingAttributionSnapshot: (snapshot: unknown) => snapshot,
@@ -2840,6 +2847,46 @@ describe('executeSync heartbeats during the listing phase', () => {
28402847
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'c-1', accessMode: 'workspace' }])
28412848
}
28422849

2850+
it.each(['workspace', 'admin'] as const)(
2851+
'diagnoses a %s tombstone query failure without continuing the crawl',
2852+
async (accessMode) => {
2853+
primeSyncUpToListing()
2854+
dbChainMockFns.returning.mockReset()
2855+
dbChainMockFns.returning.mockResolvedValueOnce([{ ...CONNECTOR, accessMode }])
2856+
const error = new DrizzleQueryError(
2857+
'select private SQL',
2858+
['private bound content'],
2859+
Object.assign(new Error('canceling statement due to statement timeout'), { code: '57014' })
2860+
)
2861+
dbChainMockFns.limit
2862+
.mockResolvedValueOnce([CONNECTOR])
2863+
.mockResolvedValueOnce([{ userId: 'u-1', workspaceId: 'ws-1' }])
2864+
.mockRejectedValueOnce(error)
2865+
2866+
const result = await executeSync('c-1', {
2867+
billingAttribution: { workspaceId: 'ws-1' } as never,
2868+
})
2869+
2870+
expect(result.error).toBe('Database request failed (SQLSTATE 57014).')
2871+
expect(mockLogError).toHaveBeenCalledWith('Connector tombstone check failed', {
2872+
connectorId: 'c-1',
2873+
operation: 'document.tombstone-check',
2874+
elapsedMs: expect.any(Number),
2875+
diagnostic: {
2876+
category: 'database',
2877+
code: '57014',
2878+
databaseReason: 'statement_timeout',
2879+
message: 'Database request failed (SQLSTATE 57014).',
2880+
},
2881+
})
2882+
expect(mockListDocuments).not.toHaveBeenCalled()
2883+
expect(JSON.stringify(mockLogError.mock.calls)).not.toContain('private')
2884+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
2885+
expect.objectContaining({ status: 'error', consecutiveFailures: 1 })
2886+
)
2887+
}
2888+
)
2889+
28432890
it.each(['workspace', 'admin'] as const)(
28442891
'uses the locked source mode %s when resolving its token',
28452892
async (accessMode) => {

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,7 @@ export async function executeSync(
10501050
* that would delete a document we have no positive evidence is actually
10511051
* gone, reintroducing the exact risk this whole design exists to avoid.
10521052
*/
1053+
const tombstoneCheckStartedAt = Date.now()
10531054
const hasTombstonedDocs = await db
10541055
.select({ id: document.id })
10551056
.from(document)
@@ -1065,6 +1066,15 @@ export async function executeSync(
10651066
)
10661067
.limit(1)
10671068
.then((rows) => rows.length > 0)
1069+
.catch((error: unknown) => {
1070+
logger.error('Connector tombstone check failed', {
1071+
connectorId,
1072+
operation: 'document.tombstone-check',
1073+
elapsedMs: Date.now() - tombstoneCheckStartedAt,
1074+
diagnostic: getConnectorFailureDiagnostic(error),
1075+
})
1076+
throw error
1077+
})
10681078

10691079
/**
10701080
* Determine if this sync should be incremental. A `rehydrate` request forces a

‎apps/sim/lib/knowledge/documents/document-processing-source.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,7 @@ describe('processDocumentAsync write guards', () => {
794794
diagnostic: {
795795
category: 'database',
796796
code: '57014',
797+
databaseReason: 'statement_timeout',
797798
message: 'Database request failed (SQLSTATE 57014).',
798799
},
799800
})

‎apps/sim/lib/workspace-files/search/dispatcher.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,28 @@ describe('workspace file search dispatch deadlines', () => {
141141
})
142142
})
143143

144+
it('records the driver cancellation reason and preserves the original failure', async () => {
145+
const error = new Error('Failed query\nparams: private-content', {
146+
cause: Object.assign(new Error('canceling statement due to statement timeout'), {
147+
code: '57014',
148+
detail: 'private driver detail',
149+
}),
150+
})
151+
dbChainMockFns.execute.mockResolvedValueOnce([]).mockResolvedValueOnce([{ acquired: true }])
152+
dbChainMockFns.onConflictDoNothing.mockRejectedValueOnce(error)
153+
154+
await expect(dispatchWorkspaceFileSearchIndexJobs()).rejects.toBe(error)
155+
expect(mocks.error).toHaveBeenCalledWith('Workspace file search dispatch phase failed', {
156+
phase: 'backfill',
157+
durationMs: expect.any(Number),
158+
code: '57014',
159+
databaseReason: 'statement_timeout',
160+
error: 'Failed query',
161+
})
162+
expect(mocks.batchTrigger).not.toHaveBeenCalled()
163+
expect(JSON.stringify(mocks.error.mock.calls)).not.toContain('private')
164+
})
165+
144166
it.each([false, true])(
145167
'preserves enqueue failures when claim release fails: %s',
146168
async (releaseFails) => {

‎apps/sim/lib/workspace-files/search/dispatcher.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import {
66
workspaceFiles,
77
} from '@sim/db/schema'
88
import { createLogger } from '@sim/logger'
9-
import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors'
9+
import {
10+
getErrorMessage,
11+
getPostgresCancellationReason,
12+
getPostgresErrorCode,
13+
} from '@sim/utils/errors'
1014
import { truncate } from '@sim/utils/string'
1115
import {
1216
and,
@@ -65,10 +69,12 @@ async function runDispatchPhase<T>(phase: string, operation: () => Promise<T>):
6569
})
6670
return result
6771
} catch (error) {
72+
const databaseReason = getPostgresCancellationReason(error)
6873
logger.error('Workspace file search dispatch phase failed', {
6974
phase,
7075
durationMs: Date.now() - startedAt,
7176
code: getPostgresErrorCode(error),
77+
...(databaseReason ? { databaseReason } : {}),
7278
error: truncate(getErrorMessage(error).split('\nparams: ')[0], 500),
7379
})
7480
throw error

0 commit comments

Comments
 (0)