Skip to content

Commit 10ff622

Browse files
authored
fix(connectors): treat a zero-byte source file as nothing to index (#6848)
Observed in production after connectors began delivering source files: a zero-byte PDF was stored and shipped to OCR, which answered `400 Bad Request`. That bills an external call to discover the file was empty and reports it as an API fault rather than as what it is. Before source files existed, an empty file produced empty extracted text and was dropped at the empty-content check, so this was a regression. The emptiness rule now lives in one place, `hasIndexablePayload`, used by the sync engine's classify and hydrate gates and by both connectors' `getDocument`. It previously existed twice — the connectors asked whether a source file was present while the sync engine asked the same question a second way — and a source file with no bytes satisfied both.
1 parent dafa4da commit 10ff622

5 files changed

Lines changed: 53 additions & 9 deletions

File tree

apps/sim/connectors/onedrive/onedrive.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ConnectorFileTooLargeError,
99
connectorFileExtension,
1010
extractConnectorText,
11+
hasIndexablePayload,
1112
isIndexableConnectorFile,
1213
isSkippedDocument,
1314
markSkipped,
@@ -383,7 +384,7 @@ export const onedriveConnector: ConnectorConfig = {
383384

384385
try {
385386
const payload = await fetchFilePayload(accessToken, item.id, item.name)
386-
if (!payload.sourceFile && !payload.content.trim()) return null
387+
if (!hasIndexablePayload(payload)) return null
387388

388389
const stub = fileToStub(item)
389390
return { ...stub, ...payload, contentDeferred: false }

apps/sim/connectors/sharepoint/sharepoint.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ConnectorFileTooLargeError,
99
connectorFileExtension,
1010
extractConnectorText,
11+
hasIndexablePayload,
1112
isIndexableConnectorFile,
1213
isSkippedDocument,
1314
markSkipped,
@@ -931,7 +932,7 @@ export const sharepointConnector: ConnectorConfig = {
931932

932933
try {
933934
const payload = await fetchFilePayload(accessToken, driveId, item.id, item.name)
934-
if (!payload.sourceFile && !payload.content.trim()) return null
935+
if (!hasIndexablePayload(payload)) return null
935936

936937
const stub = itemToStub(item, siteName ?? siteUrl)
937938
return { ...stub, ...payload, contentDeferred: false }

apps/sim/connectors/utils.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { typeformConnector } from '@/connectors/typeform/typeform'
6363
import {
6464
ConnectorFileTooLargeError,
6565
extractConnectorText,
66+
hasIndexablePayload,
6667
htmlToPlainText,
6768
isIndexableConnectorFile,
6869
isSkippedDocument,
@@ -1482,3 +1483,33 @@ describe('pipelineParsedMimeType', () => {
14821483
expect(pipelineParsedMimeType('README')).toBeUndefined()
14831484
})
14841485
})
1486+
1487+
describe('hasIndexablePayload', () => {
1488+
const bytes = (value: string) => ({
1489+
bytes: Buffer.from(value),
1490+
fileName: 'Report.pdf',
1491+
mimeType: 'application/pdf',
1492+
})
1493+
1494+
it('accepts a source file with bytes', () => {
1495+
expect(hasIndexablePayload({ content: '', sourceFile: bytes('%PDF') })).toBe(true)
1496+
})
1497+
1498+
it('accepts extracted text', () => {
1499+
expect(hasIndexablePayload({ content: 'notes' })).toBe(true)
1500+
})
1501+
1502+
/**
1503+
* Observed in production: a zero-byte PDF was stored and shipped to OCR, which
1504+
* answered `400 Bad Request` — an external call billed to discover the file was
1505+
* empty, reported as an API fault rather than as an empty file. Before source
1506+
* files existed this was dropped at the empty-content check.
1507+
*/
1508+
it('rejects a zero-byte source file rather than sending it to OCR', () => {
1509+
expect(hasIndexablePayload({ content: '', sourceFile: bytes('') })).toBe(false)
1510+
})
1511+
1512+
it('rejects blank text', () => {
1513+
expect(hasIndexablePayload({ content: ' ' })).toBe(false)
1514+
})
1515+
})

apps/sim/connectors/utils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,21 @@ export function isIndexableConnectorFile(fileName: string): boolean {
226226
return extension !== undefined && CONNECTOR_INDEXABLE_EXTENSIONS.has(extension)
227227
}
228228

229+
/**
230+
* Whether a document carries anything worth indexing.
231+
*
232+
* A source file has to have bytes. A zero-byte file is not payload: it produces an
233+
* empty stored object, and for a PDF that reaches OCR as an empty request and comes
234+
* back as an opaque `400 Bad Request` — billing an external call to learn the file
235+
* was empty, and reporting it as an API fault rather than as what it is.
236+
*/
237+
export function hasIndexablePayload(
238+
doc: Pick<ExternalDocument, 'content' | 'sourceFile'>
239+
): boolean {
240+
if (doc.sourceFile) return doc.sourceFile.bytes.length > 0
241+
return doc.content.trim().length > 0
242+
}
243+
229244
/**
230245
* MIME type to store a file under when the shared pipeline should parse it, or
231246
* `undefined` when the connector should decode it as text itself.

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

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import type {
3333
ExternalDocument,
3434
SyncResult,
3535
} from '@/connectors/types'
36+
import { hasIndexablePayload } from '@/connectors/utils'
3637

3738
const logger = createLogger('ConnectorSyncEngine')
3839

@@ -157,7 +158,7 @@ export function classifyExternalDoc(
157158
if (extDoc.skippedReason) {
158159
return existing ? { type: 'unchanged' } : { type: 'skip' }
159160
}
160-
if (!hasPayload(extDoc) && !extDoc.contentDeferred) {
161+
if (!hasIndexablePayload(extDoc) && !extDoc.contentDeferred) {
161162
return { type: 'drop' }
162163
}
163164
if (!existing) {
@@ -203,11 +204,6 @@ export function mergeHydratedDocument(
203204
}
204205
}
205206

206-
/** Whether a document carries anything to index — extracted text or the source file. */
207-
function hasPayload(extDoc: Pick<ExternalDocument, 'content' | 'sourceFile'>): boolean {
208-
return extDoc.sourceFile !== undefined || extDoc.content.trim().length > 0
209-
}
210-
211207
/** Estimated source bytes for a pending op, taken from its listing metadata. */
212208
function estimateOpSizeBytes(op: DocOp): number {
213209
// Skip ops load no content (just a row insert), so they do not count against the
@@ -1093,7 +1089,7 @@ export async function executeSync(
10931089
}
10941090
return null
10951091
}
1096-
if (!fullDoc || !hasPayload(fullDoc)) {
1092+
if (!fullDoc || !hasIndexablePayload(fullDoc)) {
10971093
// An empty re-fetch leaves an already-indexed update as last-known-good; count
10981094
// it as unchanged so the totals still reconcile with documents seen. Not a
10991095
// verified refresh, though — see failedExternalIds below.

0 commit comments

Comments
 (0)