Skip to content

Commit c77b070

Browse files
authored
fix(knowledge): grant member access as each batch is indexed instead of at the end of the run (#7418)
* fix(knowledge): grant member access as each batch is indexed instead of at the end of the run * perf(connectors): hydrate deferred mail and chat documents in batches instead of one at a time * fix(knowledge): grant access after dispatch, include skipped rows, and keep the grant best-effort * fix(knowledge): cast the stale-sweep cutoff so the member-sync scheduler stops failing * fix(connectors): cap assembled mail and chat text at one shared bound and advertise it as the hydration estimate * fix(google-chat): report the number of messages the capped transcript actually indexed
1 parent a9245b4 commit c77b070

12 files changed

Lines changed: 266 additions & 50 deletions

File tree

apps/sim/connectors/gmail/gmail.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/document
44
import { DEFAULT_MAX_THREADS, gmailConnectorMeta } from '@/connectors/gmail/meta'
55
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
66
import {
7+
BoundedLines,
8+
CONNECTOR_TEXT_DOCUMENT_MAX_BYTES,
79
htmlToPlainText,
810
joinTagArray,
911
parseDefaultedUnlimitedSafeInteger,
@@ -323,21 +325,17 @@ function formatThread(thread: GmailThread): {
323325
}
324326
const labelIds = [...labelIdSet]
325327

326-
const lines: string[] = []
327-
lines.push(`Subject: ${subject}`)
328-
lines.push(`From: ${from}`)
328+
const lines = new BoundedLines()
329+
lines.push(`Subject: ${subject}`, `From: ${from}`)
329330
if (to) lines.push(`To: ${to}`)
330-
lines.push(`Messages: ${messages.length}`)
331-
lines.push('')
331+
lines.push(`Messages: ${messages.length}`, '')
332332

333333
for (const msg of messages) {
334334
const msgFrom = getHeader(msg.payload, 'From') || 'Unknown'
335335
const msgDate = getHeader(msg.payload, 'Date') || ''
336336
const body = msg.payload ? extractBody(msg.payload) : ''
337337

338-
lines.push(`--- ${msgFrom} (${msgDate}) ---`)
339-
lines.push(body.trim())
340-
lines.push('')
338+
if (!lines.push(`--- ${msgFrom} (${msgDate}) ---`, body.trim(), '')) break
341339
}
342340

343341
const firstDate = firstMessage.internalDate
@@ -348,7 +346,7 @@ function formatThread(thread: GmailThread): {
348346
: undefined
349347

350348
return {
351-
content: lines.join('\n').trim(),
349+
content: lines.join().trim(),
352350
subject,
353351
metadata: {
354352
from,
@@ -412,6 +410,7 @@ function threadToStub(thread: {
412410
title: thread.snippet || 'Untitled Thread',
413411
content: '',
414412
contentDeferred: true,
413+
estimatedBytes: CONNECTOR_TEXT_DOCUMENT_MAX_BYTES,
415414
mimeType: 'text/plain',
416415
sourceUrl: threadUrl(thread.id),
417416
contentHash: `gmail:${thread.id}:${thread.historyId ?? ''}`,

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

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
SPACES_PAGE_SIZE,
1010
} from '@/connectors/google-chat/meta'
1111
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
12-
import { parseTagDate } from '@/connectors/utils'
12+
import { BoundedLines, CONNECTOR_TEXT_DOCUMENT_MAX_BYTES, parseTagDate } from '@/connectors/utils'
1313

1414
const logger = createLogger('GoogleChatConnector')
1515

@@ -218,6 +218,7 @@ function spaceToStub(
218218
title: spaceTitle(space),
219219
content: '',
220220
contentDeferred: true,
221+
estimatedBytes: CONNECTOR_TEXT_DOCUMENT_MAX_BYTES,
221222
mimeType: 'text/plain',
222223
sourceUrl: space.spaceUri,
223224
contentHash: buildContentHash(space, maxMessages, lookbackDays, syncContext),
@@ -323,37 +324,28 @@ function senderLabel(sender: ChatUser | undefined): string {
323324
* that belongs in a knowledge base is synced through the Google Drive connector
324325
* instead, which already handles size caps, OCR, and format parsing.
325326
*/
326-
function formatSpaceContent(space: Space, messages: ChatMessage[]): string {
327-
const parts: string[] = [`Space: ${spaceTitle(space)}`]
327+
function formatSpaceContent(
328+
space: Space,
329+
messages: ChatMessage[]
330+
): { content: string; messageCount: number } {
331+
const parts = new BoundedLines()
332+
parts.push(`Space: ${spaceTitle(space)}`)
328333
const description = space.spaceDetails?.description?.trim()
329334
if (description) parts.push(`Description: ${description}`)
330335
const guidelines = space.spaceDetails?.guidelines?.trim()
331336
if (guidelines) parts.push(`Guidelines: ${guidelines}`)
332337

333-
const lines: string[] = []
338+
let messageCount = 0
334339
for (const message of messages) {
335340
const text = message.text?.trim() || message.fallbackText?.trim()
336341
if (!text) continue
342+
if (messageCount === 0) parts.push('', '--- Messages ---')
337343
const timestamp = message.createTime ?? ''
338-
lines.push(`[${timestamp}] ${senderLabel(message.sender)}: ${text}`)
339-
}
340-
341-
if (lines.length > 0) {
342-
parts.push('')
343-
parts.push('--- Messages ---')
344-
parts.push(...lines)
344+
if (!parts.push(`[${timestamp}] ${senderLabel(message.sender)}: ${text}`)) break
345+
messageCount += 1
345346
}
346347

347-
return parts.join('\n')
348-
}
349-
350-
/** Number of messages that actually contributed text to the transcript. */
351-
function countIndexedMessages(messages: ChatMessage[]): number {
352-
let count = 0
353-
for (const message of messages) {
354-
if (message.text?.trim() || message.fallbackText?.trim()) count++
355-
}
356-
return count
348+
return { content: parts.join(), messageCount }
357349
}
358350

359351
export const googleChatConnector: ConnectorConfig = {
@@ -476,12 +468,12 @@ export const googleChatConnector: ConnectorConfig = {
476468
* leave a previously indexed transcript in place after the space was cleared
477469
* or `lookbackDays` was tightened past every message.
478470
*/
479-
const messageCount = countIndexedMessages(messages)
471+
const { content, messageCount } = formatSpaceContent(space, messages)
480472
const stub = spaceToStub(space, maxMessages, lookbackDays, syncContext)
481473

482474
return {
483475
...stub,
484-
content: formatSpaceContent(space, messages),
476+
content,
485477
contentDeferred: false,
486478
metadata: { ...stub.metadata, messageCount },
487479
}

apps/sim/connectors/outlook/outlook.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/document
44
import { DEFAULT_MAX_CONVERSATIONS, outlookConnectorMeta } from '@/connectors/outlook/meta'
55
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
66
import {
7+
BoundedLines,
8+
CONNECTOR_TEXT_DOCUMENT_MAX_BYTES,
79
htmlToPlainText,
810
isListingScopeUnavailableError,
911
listingRequestError,
@@ -563,24 +565,20 @@ function formatConversation(
563565
const from = formatRecipient(first.from)
564566
const to = first.toRecipients?.map(formatRecipient).join(', ') || ''
565567

566-
const lines: string[] = []
567-
lines.push(`Subject: ${subject}`)
568-
lines.push(`From: ${from}`)
568+
const lines = new BoundedLines()
569+
lines.push(`Subject: ${subject}`, `From: ${from}`)
569570
if (to) lines.push(`To: ${to}`)
570-
lines.push(`Messages: ${sorted.length}`)
571-
lines.push('')
571+
lines.push(`Messages: ${sorted.length}`, '')
572572

573573
for (const msg of sorted) {
574574
const msgFrom = formatRecipient(msg.from)
575575
const msgDate = msg.receivedDateTime || ''
576576
const body = extractBodyText(msg.body)
577577

578-
lines.push(`--- ${msgFrom} (${msgDate}) ---`)
579-
lines.push(body.trim())
580-
lines.push('')
578+
if (!lines.push(`--- ${msgFrom} (${msgDate}) ---`, body.trim(), '')) break
581579
}
582580

583-
const content = lines.join('\n').trim()
581+
const content = lines.join().trim()
584582
if (!content) return null
585583

586584
const categories = new Set<string>()
@@ -805,6 +803,7 @@ export const outlookConnector: ConnectorConfig = {
805803
title: subject,
806804
content: '',
807805
contentDeferred: true,
806+
estimatedBytes: CONNECTOR_TEXT_DOCUMENT_MAX_BYTES,
808807
mimeType: 'text/plain',
809808
sourceUrl,
810809
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/connectors/utils.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { typeformConnector } from '@/connectors/typeform/typeform'
6363
import {
6464
appendPendingMicrosoftGraphFolders,
6565
assertMicrosoftGraphNextLink,
66+
BoundedLines,
6667
ConnectorFileTooLargeError,
6768
ConnectorListingScopeUnavailableError,
6869
decodeMicrosoftGraphTraversalCursor,
@@ -1639,3 +1640,26 @@ describe('isSkippableMicrosoftGraphFolderError', () => {
16391640
expect(isSkippableMicrosoftGraphFolderError(new Error('500'), perMember, false)).toBe(false)
16401641
})
16411642
})
1643+
1644+
describe('BoundedLines', () => {
1645+
it('joins everything when the text fits', () => {
1646+
const lines = new BoundedLines(64)
1647+
expect(lines.push('Subject: hi', '')).toBe(true)
1648+
expect(lines.push('--- a ---', 'body')).toBe(true)
1649+
expect(lines.join()).toBe('Subject: hi\n\n--- a ---\nbody')
1650+
})
1651+
1652+
it('refuses a record that would cross the ceiling, whole, and says so in the output', () => {
1653+
const lines = new BoundedLines(20)
1654+
expect(lines.push('first')).toBe(true)
1655+
expect(lines.push('--- header ---', 'a long body')).toBe(false)
1656+
expect(lines.push('x')).toBe(false)
1657+
expect(lines.join()).toBe('first\n[Truncated: the indexed text reached the size limit]')
1658+
})
1659+
1660+
it('counts encoded bytes, not characters', () => {
1661+
const lines = new BoundedLines(6)
1662+
expect(lines.push('éé')).toBe(true)
1663+
expect(lines.push('é')).toBe(false)
1664+
})
1665+
})

apps/sim/connectors/utils.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,3 +795,50 @@ export function isSkippableMicrosoftGraphFolderError(
795795
): boolean {
796796
return !isRootFolder && isListingScopeUnavailableError(error) && isPerMemberListing(syncContext)
797797
}
798+
799+
/**
800+
* Ceiling for a document a connector assembles from many source records (a
801+
* mail thread, a chat transcript) rather than downloads as one file. Formatters
802+
* enforce it through `BoundedLines`, and listings advertise it through
803+
* `ExternalDocument.estimatedBytes`, so the sync engine plans hydration around
804+
* a bound it can rely on: five such documents fit its 64 MiB in-flight budget
805+
* and hydrate together instead of one at a time.
806+
*/
807+
export const CONNECTOR_TEXT_DOCUMENT_MAX_BYTES = 12 * 1024 * 1024
808+
809+
const TRUNCATION_NOTICE = '[Truncated: the indexed text reached the size limit]'
810+
811+
/**
812+
* Accumulates newline-joined text under a byte ceiling. A record is appended
813+
* whole or not at all, so a truncated document never ends mid-message, and the
814+
* output carries a notice when something was left out.
815+
*/
816+
export class BoundedLines {
817+
private readonly lines: string[] = []
818+
private bytes = 0
819+
private truncated = false
820+
821+
constructor(private readonly maxBytes = CONNECTOR_TEXT_DOCUMENT_MAX_BYTES) {}
822+
823+
/**
824+
* Appends the lines together when they fit; otherwise marks the document
825+
* truncated, appends nothing, and returns false so the caller stops.
826+
*/
827+
push(...lines: string[]): boolean {
828+
if (this.truncated) return false
829+
let size = 0
830+
for (const line of lines) size += Buffer.byteLength(line, 'utf8') + 1
831+
if (this.bytes + size > this.maxBytes) {
832+
this.truncated = true
833+
return false
834+
}
835+
this.lines.push(...lines)
836+
this.bytes += size
837+
return true
838+
}
839+
840+
/** Joins the accepted lines, ending with the truncation notice when a push was refused. */
841+
join(): string {
842+
return this.truncated ? [...this.lines, TRUNCATION_NOTICE].join('\n') : this.lines.join('\n')
843+
}
844+
}

apps/sim/lib/knowledge/connectors/member-observations.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,13 @@ export async function sweepStaleMemberObservations(now: Date): Promise<StaleMemb
446446
${MEMBER_OBSERVATION_STALE_AFTER_HOURS} * INTERVAL '1 hour',
447447
2 * ${knowledgeConnector.syncIntervalMinutes} * INTERVAL '1 minute'
448448
)`
449-
const cutoff = sql`${sql.param(now, knowledgeConnectorMember.lastStartedAt)} - ${staleWindow}`
449+
/**
450+
* The bound instant is cast: in `$now - GREATEST(...)` Postgres cannot see a
451+
* timestamp on either side and resolves the subtraction as interval
452+
* arithmetic, which makes the cutoff an interval and every comparison below
453+
* fail with "operator does not exist: timestamp > interval".
454+
*/
455+
const cutoff = sql`${sql.param(now, knowledgeConnectorMember.lastStartedAt)}::timestamp - ${staleWindow}`
450456
const staleMembers = await db
451457
.select({
452458
id: knowledgeConnectorMember.id,

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
memberFailureBackoffMs,
2727
memberNextAttemptAt,
2828
nextMemberSyncTime,
29+
persistedDocumentsByObserver,
2930
shouldListFully,
3031
} from '@/lib/knowledge/connectors/member-sync-engine'
3132
import {
@@ -272,6 +273,26 @@ describe('member sync engine decisions', () => {
272273
expect(second.retainedBytes).toBeGreaterThan(first.retainedBytes)
273274
})
274275

276+
it('grants a persisted batch to every member who listed each document, as it lands', () => {
277+
const union = new Map()
278+
admitMemberListing(union, 'm-1', [doc('a'), doc('b')], 'c-1', 0)
279+
admitMemberListing(union, 'm-2', [doc('a'), doc('c')], 'c-1', 0)
280+
281+
const byMember = persistedDocumentsByObserver(
282+
[
283+
{ externalId: 'a', documentId: 'd-a' },
284+
{ externalId: 'b', documentId: 'd-b' },
285+
{ externalId: 'zzz', documentId: 'd-z' },
286+
],
287+
union
288+
)
289+
290+
expect([...byMember.entries()]).toEqual([
291+
['m-1', ['d-a', 'd-b']],
292+
['m-2', ['d-a']],
293+
])
294+
})
295+
275296
it('counts a member once per external id even when their listing repeats it', () => {
276297
const union = new Map()
277298
const admitted = admitMemberListing(union, 'm-1', [doc('a'), doc('a')], 'c-1', 0)

0 commit comments

Comments
 (0)