Skip to content

Commit dd343a1

Browse files
committed
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` storage key, with `mimeType: 'text/plain'`. `processDocumentAsync` discards the `.txt` processing filename the sync engine computes and rebuilds its input from the document row, so `processDocument` chose a parser from the display name and re-parsed extracted text as the source binary. In production this failed 1,379 SharePoint PDFs with `Invalid PDF structure.` and silently double-wrapped 364 spreadsheets — those reported `completed`, with `=== Sheet: Sheet1 ===` wrapped around the connector's own `=== Sheet: <real name> ===`, 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 this rule because `fitStorageKeyName` preserves extensions through truncation: an upload keys on its original name, a connector document keys on what it stored. This also covers the stuck-document retry sweep, which builds its own input from the same display name. 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. Raises the connector sync ceiling from 30 to 60 minutes; a 2,600-document library exhausted the old budget mid-listing and was killed, leaving its `syncing` lock set until the scheduler reclaimed it. The ceiling and the stale-lock TTL now derive from one another in `sync-limits.ts`, because reclaiming a lock frees it for a second sync: a TTL at or below the run ceiling would hand the lock to a successor while the first sync is still writing. A test pins that invariant so the next raise cannot silently break it.
1 parent 1c69372 commit dd343a1

8 files changed

Lines changed: 191 additions & 7 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: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,16 @@ const STALE_PROCESSING_MINUTES = 45
6060
const RETRY_WINDOW_DAYS = 7
6161
const MAX_CONSECUTIVE_FAILURES = 10
6262

63-
/** Sanitizes a document title for use in S3 storage keys. */
63+
/**
64+
* Sanitizes a document title for use in S3 storage keys.
65+
*
66+
* The `.txt` suffix every caller appends to the result is load-bearing, not
67+
* cosmetic: a connector stores already-extracted text, while `document.filename`
68+
* keeps the source file's name for display. `resolveStoredArtifactExtension`
69+
* reads the parser choice off the storage key, so a key that carried the source
70+
* extension instead would re-parse extracted text as the original binary —
71+
* `Invalid PDF structure.` for PDFs, a silent double-wrap for spreadsheets.
72+
*/
6473
function sanitizeStorageTitle(title: string): string {
6574
return title.replace(/[^a-zA-Z0-9.-]/g, '_').slice(0, MAX_SAFE_TITLE_LENGTH)
6675
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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 flips the connector row to `error` and clears the way
13+
* for another sync. If the TTL were not greater than the run ceiling, a sync
14+
* still inside its own budget could have its lock reclaimed and then run
15+
* concurrently with its successor, both writing the same documents.
16+
*/
17+
it('reclaims a stale lock only well after a run could still be alive', () => {
18+
expect(CONNECTOR_SYNC_STALE_LOCK_TTL_MS).toBeGreaterThan(
19+
CONNECTOR_SYNC_MAX_DURATION_SECONDS * 1000
20+
)
21+
})
22+
23+
it('keeps at least a 2x margin between the run ceiling and the reclaim', () => {
24+
expect(CONNECTOR_SYNC_STALE_LOCK_TTL_MS).toBeGreaterThanOrEqual(
25+
CONNECTOR_SYNC_MAX_DURATION_SECONDS * 2 * 1000
26+
)
27+
})
28+
29+
/** A 2,600-document library exhausted the previous 1800s budget mid-listing. */
30+
it('allows a run longer than the half hour that timed out in production', () => {
31+
expect(CONNECTOR_SYNC_MAX_DURATION_SECONDS).toBeGreaterThan(1800)
32+
})
33+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Wall-clock ceiling for a single connector sync run.
3+
*
4+
* A large document library routinely needs more than the half hour this used to
5+
* allow: a 2,600-document SharePoint site exhausted the old 1800s budget and the
6+
* run was killed mid-listing, which also left the connector's `syncing` lock set
7+
* until the scheduler reclaimed it.
8+
*/
9+
export const CONNECTOR_SYNC_MAX_DURATION_SECONDS = 3600
10+
11+
/**
12+
* How long a connector may sit in `syncing` before the scheduler reclaims its lock.
13+
*
14+
* This MUST stay above {@link CONNECTOR_SYNC_MAX_DURATION_SECONDS}. Reclaiming
15+
* flips the connector row to `error` and clears the way for another sync, so a TTL
16+
* at or below the run ceiling would hand a second sync the lock while the first is
17+
* still writing documents — two concurrent syncs on one connector, racing on the
18+
* same `(connectorId, externalId)` rows.
19+
*
20+
* Deriving it from the run ceiling rather than hard-coding minutes is what keeps
21+
* that invariant true the next time the ceiling is raised.
22+
*/
23+
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: 12 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,14 @@ async function parseHttpFile(
841844
): Promise<{ content: string; metadata?: FileParseMetadata }> {
842845
const buffer = await downloadFileWithTimeout(fileUrl, userId)
843846

844-
const extension = resolveParserExtension(filename, mimeType)
847+
/**
848+
* Prefer what the storage key says we just downloaded over what the document is
849+
* *called*. The two agree for an upload; for a connector document they do not,
850+
* and trusting the display name re-parses already-extracted text as the source
851+
* binary. See `resolveStoredArtifactExtension`.
852+
*/
853+
const extension =
854+
resolveStoredArtifactExtension(fileUrl) ?? resolveParserExtension(filename, mimeType)
845855
const result = await parseBuffer(buffer, extension)
846856
return result
847857
}

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils'
1+
import {
2+
extractStorageKey,
3+
getExtensionFromMimeType,
4+
isInternalFileUrl,
5+
} from '@/lib/uploads/utils/file-utils'
26
import {
37
isAlphanumericExtension,
48
isSupportedExtension,
@@ -36,3 +40,34 @@ export function resolveParserExtension(
3640

3741
throw new Error(`Could not determine file type for ${filename || 'document'}`)
3842
}
43+
44+
/**
45+
* Extension of the object actually stored, taken from its storage key.
46+
*
47+
* A knowledge base document's `filename` is a *display* name, and for connector
48+
* documents it deliberately disagrees with the bytes on disk: the sync engine
49+
* records the source file's name (`Report.pdf`) while storing the text the
50+
* connector already extracted from it under a `.txt` key. Choosing a parser from
51+
* the display name therefore re-parses extracted text as the original binary
52+
* format — `Invalid PDF structure.` for PDFs, and for spreadsheets a silent
53+
* double-wrap, since SheetJS accepts almost anything.
54+
*
55+
* The storage key is the honest signal for both ingestion paths, because
56+
* `fitStorageKeyName` preserves a file's extension through truncation: an upload
57+
* keys on its original name (`kb/<id>-Report.pdf`) and a connector document keys
58+
* on what it stored (`kb/<id>-Report.pdf.txt`).
59+
*
60+
* Returns `undefined` — leaving the caller on the filename/MIME path — for
61+
* anything not served from our own storage, and for a key whose extension no
62+
* parser claims, so this can only ever redirect a document to a parser that
63+
* exists.
64+
*/
65+
export function resolveStoredArtifactExtension(fileUrl: string): string | undefined {
66+
if (!isInternalFileUrl(fileUrl)) return undefined
67+
68+
const key = extractStorageKey(fileUrl)
69+
const raw = key.includes('.') ? key.split('.').pop()?.toLowerCase() : undefined
70+
if (!raw || !isAlphanumericExtension(raw)) return undefined
71+
72+
return isSupportedExtension(raw) ? raw : undefined
73+
}
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)