Skip to content

Commit ebeafe5

Browse files
committed
perf(connectors): hydrate deferred mail and chat documents in batches instead of one at a time
1 parent ff0469e commit ebeafe5

6 files changed

Lines changed: 51 additions & 1 deletion

File tree

‎apps/sim/connectors/gmail/gmail.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,13 @@ async function resolveLabelNames(
398398
.filter((name) => !name.startsWith('CATEGORY_') && name !== 'UNREAD')
399399
}
400400

401+
/**
402+
* What a hydrated thread retains: message bodies as text, never attachments,
403+
* so a generous ceiling for a long thread is still small. Lets a batch of
404+
* threads hydrate together instead of one at a time.
405+
*/
406+
const ESTIMATED_THREAD_BYTES = 256 * 1024
407+
401408
/**
402409
* Creates a lightweight document stub from a thread list entry.
403410
* Uses metadata-based contentHash for change detection without downloading content.
@@ -412,6 +419,7 @@ function threadToStub(thread: {
412419
title: thread.snippet || 'Untitled Thread',
413420
content: '',
414421
contentDeferred: true,
422+
estimatedBytes: ESTIMATED_THREAD_BYTES,
415423
mimeType: 'text/plain',
416424
sourceUrl: threadUrl(thread.id),
417425
contentHash: `gmail:${thread.id}:${thread.historyId ?? ''}`,

‎apps/sim/connectors/google-chat/google-chat.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import { parseTagDate } from '@/connectors/utils'
1313

1414
const logger = createLogger('GoogleChatConnector')
1515

16+
/** A hydrated space retains a bounded window of message text; lets spaces hydrate in batches. */
17+
const ESTIMATED_SPACE_BYTES = 1024 * 1024
18+
1619
const CHAT_API_BASE = 'https://chat.googleapis.com/v1'
1720
const MS_PER_DAY = 24 * 60 * 60 * 1000
1821

@@ -218,6 +221,7 @@ function spaceToStub(
218221
title: spaceTitle(space),
219222
content: '',
220223
contentDeferred: true,
224+
estimatedBytes: ESTIMATED_SPACE_BYTES,
221225
mimeType: 'text/plain',
222226
sourceUrl: space.spaceUri,
223227
contentHash: buildContentHash(space, maxMessages, lookbackDays, syncContext),

‎apps/sim/connectors/outlook/outlook.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import {
1313

1414
const logger = createLogger('OutlookConnector')
1515

16+
/** A hydrated conversation retains message bodies as text, never attachments; lets threads hydrate in batches. */
17+
const ESTIMATED_CONVERSATION_BYTES = 256 * 1024
18+
1619
const GRAPH_API_BASE = 'https://graph.microsoft.com/v1.0/me'
1720
const MESSAGES_PER_PAGE = 50
1821
/**
@@ -805,6 +808,7 @@ export const outlookConnector: ConnectorConfig = {
805808
title: subject,
806809
content: '',
807810
contentDeferred: true,
811+
estimatedBytes: ESTIMATED_CONVERSATION_BYTES,
808812
mimeType: 'text/plain',
809813
sourceUrl,
810814
contentHash: `outlook:${convId}:${lastDate}`,

‎apps/sim/connectors/types.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ export interface ExternalDocument {
6363
skippedRetryContentHash?: string
6464
/** When true, content is empty and will be fetched via getDocument for new/changed docs only */
6565
contentDeferred?: boolean
66+
/**
67+
* How large the deferred content is expected to be, in bytes, when the
68+
* listing cannot know exactly. Bounds how many deferred documents hydrate
69+
* at once: without it a deferred document is assumed to be as large as the
70+
* whole in-flight budget and hydrates alone, which turns a mailbox crawl
71+
* into one thread at a time.
72+
*/
73+
estimatedBytes?: number
6674
/**
6775
* When set, the document was intentionally not indexed (e.g. it exceeds the
6876
* connector's size limit). The sync engine records it as a `failed` document

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,32 @@ describe('chunkOpsByByteBudget', () => {
10281028
expect(chunks.map((c) => c.length)).toEqual([1, 1])
10291029
})
10301030

1031+
it('hydrates deferred documents together when the listing estimates their size', async () => {
1032+
const { chunkOpsByByteBudget } = await import('@/lib/knowledge/connectors/sync-primitives')
1033+
const deferred = (estimatedBytes?: number) => ({
1034+
type: 'add' as const,
1035+
extDoc: {
1036+
externalId: `d-${generateShortId()}`,
1037+
title: 'f',
1038+
content: '',
1039+
contentDeferred: true,
1040+
contentHash: 'h',
1041+
mimeType: 'text/plain',
1042+
...(estimatedBytes != null ? { estimatedBytes } : {}),
1043+
},
1044+
})
1045+
// Without an estimate each unknown download is assumed to fill the budget and runs alone.
1046+
expect(chunkOpsByByteBudget([deferred(), deferred(), deferred()], 64 * MB, 5)).toHaveLength(3)
1047+
// A mail thread that says it is small shares a batch with its neighbours.
1048+
expect(
1049+
chunkOpsByByteBudget(
1050+
[deferred(256 * 1024), deferred(256 * 1024), deferred(256 * 1024)],
1051+
64 * MB,
1052+
5
1053+
)
1054+
).toHaveLength(1)
1055+
})
1056+
10311057
it('treats skip ops as zero bytes so they do not consume the budget', async () => {
10321058
const { chunkOpsByByteBudget } = await import('@/lib/knowledge/connectors/sync-primitives')
10331059
const chunks = chunkOpsByByteBudget(

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@ function estimateOpSizeBytes(op: DocOp): number {
503503
if (op.type === 'skip') return 0
504504
if (op.extDoc.sourceFile?.bytes) return op.extDoc.sourceFile.bytes.byteLength
505505
if (op.extDoc.content) return Buffer.byteLength(op.extDoc.content)
506-
const size = op.extDoc.metadata?.fileSize ?? op.extDoc.metadata?.size
506+
const size = op.extDoc.estimatedBytes ?? op.extDoc.metadata?.fileSize ?? op.extDoc.metadata?.size
507507
return typeof size === 'number' && Number.isFinite(size) && size > 0
508508
? size
509509
: DEFAULT_OP_SIZE_BYTES

0 commit comments

Comments
 (0)