Skip to content

Commit d1e3eee

Browse files
authored
fix(knowledge): parse the stored artifact, not the document's display name (#6817)
* fix(knowledge): parse the stored artifact, not the document's display name A connector document's `filename` is a display name that deliberately disagrees with the bytes on disk: the sync engine records the source file's name (`Report.pdf`) while storing the text the connector already extracted from it under a `.txt` key, with `mimeType: 'text/plain'`. `processDocumentAsync` discards the processing filename the sync engine computes and rebuilds its input from the document row, so the parser was chosen from the display name and re-parsed extracted text as the source binary. In production that failed 1,379 SharePoint PDFs with `Invalid PDF structure.` and silently double-wrapped 364 spreadsheets — those reported `completed`, wrapping a second fake sheet around the connector's own extraction, because SheetJS accepts almost any input. Parser selection now prefers the extension of the object actually fetched, falling back to the filename/MIME path when the URL is not ours or the key carries no extension a parser claims. Both ingestion paths are honest under that rule because `fitStorageKeyName` preserves extensions through truncation: an upload keys on its original name, a connector document keys on what it stored. This layer is what covers the stuck-document retry sweep, which rebuilds its own input from the same display name — the sweep is the path that reprocesses the already-failed documents, so a fix confined to `processDocumentAsync` would have left the remediation itself broken. The defect predates the connectors that expose it: Box fetches Box-side text representations for `pdf`/`docx`/`xlsx` and stores them under the source name too, so it was latent there before SharePoint and OneDrive reached binary formats. `connectorArtifactFileName` now owns the `.txt` suffix that the parser choice depends on, so the invariant is structural instead of a convention repeated at four call sites per function. * fix(knowledge): raise the connector sync ceiling and tie it to the stale lock A 2,600-document library exhausted the 30-minute budget and the run was killed mid-listing, leaving the connector's `syncing` lock set until the scheduler reclaimed it. Raising the ceiling is not a lone constant, because reclaiming a stale lock flips the connector to `error` and frees it for another sync. A TTL at or below the run ceiling would hand the lock to a successor while the first sync is still writing — two syncs racing the same `(connectorId, externalId)` rows. The previous values, a 1800s run against a hard-coded 120-minute TTL declared in a different file, held that invariant only by coincidence. Both now derive from one another, with a test pinning the margin so the next raise cannot silently break it.
1 parent 08fe3ed commit d1e3eee

8 files changed

Lines changed: 186 additions & 22 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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'
1213

1314
export const dynamic = 'force-dynamic'
1415

@@ -39,8 +40,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3940
try {
4041
const now = new Date()
4142

42-
const STALE_SYNC_TTL_MS = 120 * 60 * 1000
43-
const staleCutoff = new Date(now.getTime() - STALE_SYNC_TTL_MS)
43+
const staleCutoff = new Date(now.getTime() - CONNECTOR_SYNC_STALE_LOCK_TTL_MS)
4444

4545
const recoveredConnectors = await db
4646
.update(knowledgeConnector)

apps/sim/background/knowledge-connector-sync.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type ConnectorSyncPayload,
66
} from '@/lib/knowledge/connectors/queue'
77
import { executeSync } from '@/lib/knowledge/connectors/sync-engine'
8+
import { CONNECTOR_SYNC_MAX_DURATION_SECONDS } from '@/lib/knowledge/connectors/sync-limits'
89

910
const logger = createLogger('TriggerKnowledgeConnectorSync')
1011

@@ -39,7 +40,7 @@ export async function executeConnectorSyncJob(payload: unknown) {
3940

4041
export const knowledgeConnectorSync = task({
4142
id: 'knowledge-connector-sync',
42-
maxDuration: 1800,
43+
maxDuration: CONNECTOR_SYNC_MAX_DURATION_SECONDS,
4344
machine: 'large-2x',
4445
retry: {
4546
maxAttempts: 3,

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

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ const MAX_CONSECUTIVE_FAILURES = 10
6464
function sanitizeStorageTitle(title: string): string {
6565
return title.replace(/[^a-zA-Z0-9.-]/g, '_').slice(0, MAX_SAFE_TITLE_LENGTH)
6666
}
67+
68+
/**
69+
* Name a connector document's stored object carries.
70+
*
71+
* Connectors store already-extracted text while `document.filename` keeps the
72+
* source file's name for display, so the stored object has to declare the format
73+
* it actually holds: `resolveStoredArtifactExtension` picks the parser off this
74+
* key, and a key ending in the source extension would re-parse extracted text as
75+
* the original binary. Owning the `.txt` suffix here makes that structural rather
76+
* than a convention each call site has to remember.
77+
*/
78+
function connectorArtifactFileName(title: string): string {
79+
return `${sanitizeStorageTitle(title)}.txt`
80+
}
6781
type KnowledgeBaseLockingTx = Pick<typeof db, 'execute' | 'select'>
6882

6983
type DocOp =
@@ -1657,17 +1671,17 @@ async function addDocument(
16571671
): Promise<DocumentData> {
16581672
const documentId = generateId()
16591673
const contentBuffer = Buffer.from(extDoc.content, 'utf-8')
1660-
const safeTitle = sanitizeStorageTitle(extDoc.title)
1661-
const customKey = `kb/${buildStorageKeySegment(`${Date.now()}-${documentId}-`, `${safeTitle}.txt`)}`
1674+
const storedFileName = connectorArtifactFileName(extDoc.title)
1675+
const customKey = `kb/${buildStorageKeySegment(`${Date.now()}-${documentId}-`, storedFileName)}`
16621676

16631677
const fileInfo = await StorageService.uploadFile({
16641678
file: contentBuffer,
1665-
fileName: `${safeTitle}.txt`,
1679+
fileName: storedFileName,
16661680
contentType: 'text/plain',
16671681
context: 'knowledge-base',
16681682
customKey,
16691683
preserveKey: true,
1670-
metadata: kbOwnershipMetadata(kbOwner, `${safeTitle}.txt`),
1684+
metadata: kbOwnershipMetadata(kbOwner, storedFileName),
16711685
})
16721686

16731687
const fileUrl = `${getInternalApiBaseUrl()}${fileInfo.path}?context=knowledge-base`
@@ -1676,8 +1690,6 @@ async function addDocument(
16761690
? resolveTagMapping(connectorType, extDoc.metadata, sourceConfig)
16771691
: undefined
16781692

1679-
const processingFilename = `${safeTitle}.txt`
1680-
16811693
try {
16821694
await db.transaction(async (tx) => {
16831695
const isActive = await isKnowledgeBaseActiveInTx(tx, knowledgeBaseId)
@@ -1718,7 +1730,7 @@ async function addDocument(
17181730

17191731
return {
17201732
documentId,
1721-
filename: processingFilename,
1733+
filename: storedFileName,
17221734
fileUrl,
17231735
fileSize: contentBuffer.length,
17241736
mimeType: 'text/plain',
@@ -1746,17 +1758,17 @@ async function updateDocument(
17461758
const oldFileUrl = existingRows[0]?.fileUrl
17471759

17481760
const contentBuffer = Buffer.from(extDoc.content, 'utf-8')
1749-
const safeTitle = sanitizeStorageTitle(extDoc.title)
1750-
const customKey = `kb/${buildStorageKeySegment(`${Date.now()}-${existingDocId}-`, `${safeTitle}.txt`)}`
1761+
const storedFileName = connectorArtifactFileName(extDoc.title)
1762+
const customKey = `kb/${buildStorageKeySegment(`${Date.now()}-${existingDocId}-`, storedFileName)}`
17511763

17521764
const fileInfo = await StorageService.uploadFile({
17531765
file: contentBuffer,
1754-
fileName: `${safeTitle}.txt`,
1766+
fileName: storedFileName,
17551767
contentType: 'text/plain',
17561768
context: 'knowledge-base',
17571769
customKey,
17581770
preserveKey: true,
1759-
metadata: kbOwnershipMetadata(kbOwner, `${safeTitle}.txt`),
1771+
metadata: kbOwnershipMetadata(kbOwner, storedFileName),
17601772
})
17611773

17621774
const fileUrl = `${getInternalApiBaseUrl()}${fileInfo.path}?context=knowledge-base`
@@ -1765,8 +1777,6 @@ async function updateDocument(
17651777
? resolveTagMapping(connectorType, extDoc.metadata, sourceConfig)
17661778
: undefined
17671779

1768-
const processingFilename = `${safeTitle}.txt`
1769-
17701780
try {
17711781
await db.transaction(async (tx) => {
17721782
const isActive = await isKnowledgeBaseActiveInTx(tx, knowledgeBaseId)
@@ -1839,7 +1849,7 @@ async function updateDocument(
18391849

18401850
return {
18411851
documentId: existingDocId,
1842-
filename: processingFilename,
1852+
filename: storedFileName,
18431853
fileUrl,
18441854
fileSize: contentBuffer.length,
18451855
mimeType: 'text/plain',
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
CONNECTOR_SYNC_MAX_DURATION_SECONDS,
7+
CONNECTOR_SYNC_STALE_LOCK_TTL_MS,
8+
} from '@/lib/knowledge/connectors/sync-limits'
9+
10+
describe('connector sync limits', () => {
11+
/**
12+
* Reclaiming a stale lock frees it for another sync, so a TTL at or below the
13+
* run ceiling would start a second sync while the first is still writing. This
14+
* guards the invariant against a future hard-coded TTL, not the derivation.
15+
*/
16+
it('keeps at least a 2x margin between the run ceiling and the reclaim', () => {
17+
expect(CONNECTOR_SYNC_STALE_LOCK_TTL_MS).toBeGreaterThanOrEqual(
18+
CONNECTOR_SYNC_MAX_DURATION_SECONDS * 2 * 1000
19+
)
20+
})
21+
22+
/** A 2,600-document library exhausted the previous 1800s budget mid-listing. */
23+
it('allows a run longer than the half hour that timed out in production', () => {
24+
expect(CONNECTOR_SYNC_MAX_DURATION_SECONDS).toBeGreaterThan(1800)
25+
})
26+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Wall-clock ceiling for a single connector sync run. A large document library
3+
* needs more than the half hour this used to allow: a 2,600-document site
4+
* exhausted the old budget and was killed mid-listing, leaving its `syncing`
5+
* lock set until the scheduler reclaimed it.
6+
*/
7+
export const CONNECTOR_SYNC_MAX_DURATION_SECONDS = 3600
8+
9+
/**
10+
* How long a connector may sit in `syncing` before the scheduler reclaims its lock.
11+
*
12+
* MUST stay above {@link CONNECTOR_SYNC_MAX_DURATION_SECONDS}: reclaiming frees the
13+
* lock for another sync, so a TTL at or below the run ceiling would start a second
14+
* sync while the first is still writing, both racing the same documents.
15+
*/
16+
export const CONNECTOR_SYNC_STALE_LOCK_TTL_MS = CONNECTOR_SYNC_MAX_DURATION_SECONDS * 2 * 1000

apps/sim/lib/knowledge/documents/document-processor.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import { env, envNumber } from '@/lib/core/config/env'
1818
import { OCR_CAPABILITY, requireCapability } from '@/lib/core/config/env-capabilities'
1919
import { parseBuffer } from '@/lib/file-parsers'
2020
import type { FileParseMetadata } from '@/lib/file-parsers/types'
21-
import { resolveParserExtension } from '@/lib/knowledge/documents/parser-extension'
21+
import {
22+
resolveParserExtension,
23+
resolveStoredArtifactExtension,
24+
} from '@/lib/knowledge/documents/parser-extension'
2225
import { retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils'
2326
import {
2427
assertKnowledgeOpaqueModelInputSafe,
@@ -841,7 +844,9 @@ async function parseHttpFile(
841844
): Promise<{ content: string; metadata?: FileParseMetadata }> {
842845
const buffer = await downloadFileWithTimeout(fileUrl, userId)
843846

844-
const extension = resolveParserExtension(filename, mimeType)
847+
/** Prefer what we actually downloaded over what the document is *called*. */
848+
const extension =
849+
resolveStoredArtifactExtension(fileUrl) ?? resolveParserExtension(filename, mimeType)
845850
const result = await parseBuffer(buffer, extension)
846851
return result
847852
}

apps/sim/lib/knowledge/documents/parser-extension.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils'
1+
import {
2+
extractStorageKey,
3+
getExtensionFromMimeType,
4+
getFileExtension,
5+
isInternalFileUrl,
6+
} from '@/lib/uploads/utils/file-utils'
27
import {
38
isAlphanumericExtension,
49
isSupportedExtension,
@@ -12,8 +17,8 @@ export function resolveParserExtension(
1217
mimeType?: string,
1318
fallback?: string
1419
): string {
15-
const raw = filename.includes('.') ? filename.split('.').pop()?.toLowerCase() : undefined
16-
const filenameExtension = raw && isAlphanumericExtension(raw) ? raw : undefined
20+
const raw = getFileExtension(filename)
21+
const filenameExtension = isAlphanumericExtension(raw) ? raw : undefined
1722

1823
if (filenameExtension && isSupportedExtension(filenameExtension)) {
1924
return filenameExtension
@@ -36,3 +41,31 @@ export function resolveParserExtension(
3641

3742
throw new Error(`Could not determine file type for ${filename || 'document'}`)
3843
}
44+
45+
/**
46+
* Extension of the object actually stored, taken from its storage key.
47+
*
48+
* A knowledge base document's `filename` is a *display* name, and for connector
49+
* documents it deliberately disagrees with the bytes on disk: the sync engine
50+
* records the source file's name (`Report.pdf`) while storing the text the
51+
* connector already extracted from it under a `.txt` key. Choosing a parser from
52+
* the display name therefore re-parses extracted text as the original binary
53+
* format — `Invalid PDF structure.` for PDFs, and for spreadsheets a silent
54+
* double-wrap, since SheetJS accepts almost anything.
55+
*
56+
* The storage key is the honest signal for both ingestion paths, because
57+
* `fitStorageKeyName` preserves a file's extension through truncation: an upload
58+
* keys on its original name (`kb/<id>-Report.pdf`) and a connector document keys
59+
* on what it stored (`kb/<id>-Report.pdf.txt`).
60+
*
61+
* Falls back to `undefined` — leaving the caller on the filename/MIME path —
62+
* rather than guessing, so this can only ever redirect to a parser that exists.
63+
*/
64+
export function resolveStoredArtifactExtension(fileUrl: string): string | undefined {
65+
if (!isInternalFileUrl(fileUrl)) return undefined
66+
67+
const extension = getFileExtension(extractStorageKey(fileUrl))
68+
if (!isAlphanumericExtension(extension)) return undefined
69+
70+
return isSupportedExtension(extension) ? extension : undefined
71+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* A knowledge base document's `filename` is a display name. For connector
5+
* documents it deliberately disagrees with the stored bytes — the sync engine
6+
* records `Report.pdf` while storing the text the connector already extracted
7+
* under a `.txt` key — so choosing a parser from the display name re-parsed
8+
* extracted text as the source binary. In production that failed 1,379
9+
* SharePoint PDFs with `Invalid PDF structure.` and silently double-wrapped
10+
* every spreadsheet, which "succeeded" because SheetJS accepts almost anything.
11+
*/
12+
import { describe, expect, it } from 'vitest'
13+
import { resolveStoredArtifactExtension } from '@/lib/knowledge/documents/parser-extension'
14+
15+
const CONNECTOR_PDF_URL =
16+
'/api/files/serve/s3/kb%2F1786986883507-abc-Report.pdf.txt?context=knowledge-base'
17+
const UPLOADED_PDF_URL =
18+
'/api/files/serve/s3/kb%2F1786986883507-abc-Report.pdf?context=knowledge-base'
19+
20+
describe('resolveStoredArtifactExtension', () => {
21+
it('reports txt for a connector document whose display name is a PDF', () => {
22+
expect(resolveStoredArtifactExtension(CONNECTOR_PDF_URL)).toBe('txt')
23+
})
24+
25+
it('reports txt for a connector spreadsheet, which SheetJS would otherwise re-wrap', () => {
26+
expect(
27+
resolveStoredArtifactExtension(
28+
'/api/files/serve/s3/kb%2F1-abc-Vendor_Spend.xlsx.txt?context=knowledge-base'
29+
)
30+
).toBe('txt')
31+
})
32+
33+
it('leaves an uploaded document on its real extension', () => {
34+
expect(resolveStoredArtifactExtension(UPLOADED_PDF_URL)).toBe('pdf')
35+
})
36+
37+
it('handles the blob and gcs storage prefixes', () => {
38+
expect(resolveStoredArtifactExtension('/api/files/serve/blob/kb%2F1-a-x.docx')).toBe('docx')
39+
expect(resolveStoredArtifactExtension('/api/files/serve/gcs/kb%2F1-a-x.csv')).toBe('csv')
40+
})
41+
42+
it('ignores URLs that are not served from our own storage', () => {
43+
expect(resolveStoredArtifactExtension('https://example.com/files/Report.pdf')).toBeUndefined()
44+
expect(resolveStoredArtifactExtension('data:application/pdf;base64,AAAA')).toBeUndefined()
45+
})
46+
47+
/**
48+
* `fitStorageKeyName` drops the extension when it cannot fit, and a key may
49+
* carry no extension at all. Returning undefined puts the caller back on the
50+
* filename/MIME path rather than guessing.
51+
*/
52+
it('returns undefined when the key carries no usable extension', () => {
53+
expect(resolveStoredArtifactExtension('/api/files/serve/s3/kb%2F1-a-Report')).toBeUndefined()
54+
expect(resolveStoredArtifactExtension('/api/files/serve/s3/kb%2F1-a-Report.')).toBeUndefined()
55+
})
56+
57+
/**
58+
* Only ever redirects to a parser that exists — an unknown suffix falls back
59+
* instead of routing the document at a parser that cannot handle it.
60+
*/
61+
it('returns undefined for an extension no parser claims', () => {
62+
expect(
63+
resolveStoredArtifactExtension('/api/files/serve/s3/kb%2F1-a-archive.zip')
64+
).toBeUndefined()
65+
expect(
66+
resolveStoredArtifactExtension('/api/files/serve/s3/kb%2F1-a-Report.v2.final')
67+
).toBeUndefined()
68+
})
69+
70+
it('is case-insensitive', () => {
71+
expect(resolveStoredArtifactExtension('/api/files/serve/s3/kb%2F1-a-Report.PDF')).toBe('pdf')
72+
})
73+
})

0 commit comments

Comments
 (0)