Skip to content

Commit a2cd9eb

Browse files
committed
fix(knowledge): expose safe recovery diagnostics
1 parent 74eec09 commit a2cd9eb

4 files changed

Lines changed: 38 additions & 9 deletions

File tree

apps/sim/app/api/webhooks/outbox/process/route.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import { enterpriseIssuanceOutboxHandlers } from '@/lib/billing/enterprise-provi
1010
import { membershipBillingOutboxHandlers } from '@/lib/billing/organizations/membership-reconciliation'
1111
import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers'
1212
import { processOutboxEvents } from '@/lib/core/outbox/service'
13+
import { DeadlineExceededError } from '@/lib/core/utils/deadline'
1314
import { generateRequestId } from '@/lib/core/utils/request'
1415
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1516
import { directGrantOutboxHandlers } from '@/lib/invitations/direct-grant'
1617
import { slackSearchOutboxHandlers } from '@/lib/knowledge/application/slack-search/outbox'
18+
import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error'
1719
import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler'
1820
import { recoverKnowledgeDocumentProcessing } from '@/lib/knowledge/documents/processing-recovery'
1921
import { organizationResourceCleanupOutboxHandlers } from '@/lib/organizations/resource-cleanup'
@@ -66,8 +68,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
6668
if (Date.now() - startedAt < 770_000) {
6769
recoveredDocuments = await recoverKnowledgeDocumentProcessing()
6870
}
69-
} catch {
70-
logger.error('Stored document recovery failed', { requestId })
71+
} catch (error) {
72+
logger.error('Stored document recovery failed', {
73+
requestId,
74+
error: getConnectorFailureDiagnostic(error) ?? {
75+
category: error instanceof DeadlineExceededError ? 'deadline' : 'internal',
76+
message:
77+
error instanceof DeadlineExceededError
78+
? error.message
79+
: 'Unexpected stored-document recovery failure',
80+
},
81+
})
7182
}
7283

7384
// Reap fork background-work rows stuck `processing` past their TTL (worker crash /

apps/sim/lib/core/utils/deadline.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/** @vitest-environment node */
22
import { afterEach, describe, expect, it, vi } from 'vitest'
3-
import { withinDeadline } from '@/lib/core/utils/deadline'
3+
import { DeadlineExceededError, withinDeadline } from '@/lib/core/utils/deadline'
44

55
afterEach(() => vi.useRealTimers())
66

@@ -17,7 +17,7 @@ describe('bounded asynchronous operations', () => {
1717
signal.throwIfAborted()
1818
mutate()
1919
}, Date.now() + 100)
20-
const rejection = expect(operation).rejects.toThrow('Operation deadline expired')
20+
const rejection = expect(operation).rejects.toBeInstanceOf(DeadlineExceededError)
2121
await vi.advanceTimersByTimeAsync(100)
2222
await rejection
2323
resolve()
@@ -26,6 +26,14 @@ describe('bounded asynchronous operations', () => {
2626
expect(vi.getTimerCount()).toBe(0)
2727
})
2828

29+
it('rejects an expired deadline before starting dependency work', async () => {
30+
const dependency = vi.fn()
31+
await expect(withinDeadline(dependency, Date.now() - 1)).rejects.toBeInstanceOf(
32+
DeadlineExceededError
33+
)
34+
expect(dependency).not.toHaveBeenCalled()
35+
})
36+
2937
it('clears its timer after successful completion and propagates caller cancellation', async () => {
3038
vi.useFakeTimers()
3139
await expect(withinDeadline(async () => 7, Date.now() + 100)).resolves.toBe(7)

apps/sim/lib/core/utils/deadline.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
/** Identifies deadline expiry without exposing arbitrary dependency error messages. */
2+
export class DeadlineExceededError extends Error {
3+
constructor() {
4+
super('Operation deadline expired')
5+
this.name = 'DeadlineExceededError'
6+
}
7+
}
8+
19
/** Cancels the caller's wait even when a storage client's connection or command queue stalls. */
210
export async function withinDeadline<T>(
311
operation: (signal: AbortSignal) => Promise<T>,
@@ -6,7 +14,7 @@ export async function withinDeadline<T>(
614
): Promise<T> {
715
signal?.throwIfAborted()
816
const remainingMs = deadlineAt - Date.now()
9-
if (remainingMs <= 0) throw new Error('Operation deadline expired')
17+
if (remainingMs <= 0) throw new DeadlineExceededError()
1018
const controller = new AbortController()
1119
const abort = () => controller.abort(signal?.reason)
1220
signal?.addEventListener('abort', abort, { once: true })
@@ -17,7 +25,7 @@ export async function withinDeadline<T>(
1725
const rejectAborted = () => rejectWait(controller.signal.reason)
1826
controller.signal.addEventListener('abort', rejectAborted, { once: true })
1927
const timer = setTimeout(() => {
20-
controller.abort(new Error('Operation deadline expired'))
28+
controller.abort(new DeadlineExceededError())
2129
}, remainingMs)
2230
try {
2331
return await Promise.race([operation(controller.signal), aborted])

apps/sim/lib/knowledge/connectors/listing-checkpoint.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,11 @@ export function beginListingCheckpoint(input: {
7474
}
7575

7676
/**
77-
* Persists one provider page only after its documents and observations land.
78-
* A crash replays at most that page; EOF is durable so reconciliation can resume
79-
* independently. Runtime caches and access tokens never enter the checkpoint.
77+
* Pins an optional current-page replay cursor before processing, without advancing
78+
* the listed count or marking completion. Advances the cursor and persists EOF only
79+
* after the page's documents and observations land. A crash replays at most that page;
80+
* durable EOF lets reconciliation resume independently. Runtime caches and access
81+
* tokens never enter the checkpoint.
8082
*/
8183
export async function runResumableListing(input: {
8284
connectorConfig: Pick<ConnectorConfig, 'listDocuments' | 'isListingCursorInvalidError'>

0 commit comments

Comments
 (0)