Skip to content

Commit d69c671

Browse files
committed
fix(search): persist verified Gmail size skips
1 parent 80446df commit d69c671

5 files changed

Lines changed: 249 additions & 46 deletions

File tree

apps/docs/content/docs/search/gmail.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ Search schedules syncs hourly. The first sync and large mailboxes can take longe
7676

7777
An empty mailbox or filters with no matching threads complete normally with zero documents.
7878

79+
Threads that exceed indexing size limits are skipped and reconsidered when the thread changes.
80+
7981
## Troubleshooting
8082

8183
| What you see | What to do |

apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@ import { POST } from '@/app/api/organizations/[id]/connected-accounts/[groupId]/
2020
const body = {
2121
appId: 'A123',
2222
teamId: 'T123',
23-
clientId: 'fixture-client-id',
24-
clientSecret: 'fixture-client-secret',
2523
}
2624
const context = { params: Promise.resolve({ id: 'org-a', groupId: 'group-a' }) }
2725
function request(input: unknown = body) {
@@ -43,7 +41,7 @@ beforeEach(() => {
4341
})
4442

4543
describe('organization Slack setup route', () => {
46-
it('authenticates before parsing setup secrets', async () => {
44+
it('authenticates before parsing setup input', async () => {
4745
mocks.session.mockResolvedValue(null)
4846
const response = await POST(request({}), context)
4947
expect(response.status).toBe(401)
@@ -67,6 +65,12 @@ describe('organization Slack setup route', () => {
6765
expect(mocks.execute).not.toHaveBeenCalled()
6866
})
6967

68+
it.each(['clientId', 'clientSecret'])('rejects a client-supplied OAuth %s', async (field) => {
69+
const response = await POST(request({ ...body, [field]: 'client-supplied-value' }), context)
70+
expect(response.status).toBe(400)
71+
expect(mocks.execute).not.toHaveBeenCalled()
72+
})
73+
7074
it('preserves refusal when current organization authority is insufficient', async () => {
7175
mocks.execute.mockRejectedValue(
7276
new OrchestrationError('forbidden', 'Organization admin required')

apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,14 @@ function organizationSetup() {
375375

376376
describe('organization setup entry points', () => {
377377
it('uses central mode and its own draft even when the saved draft contains member mode', async () => {
378+
mocks.credentials = [
379+
{
380+
id: 'cred-source',
381+
name: 'Indexing account',
382+
provider: 'confluence',
383+
type: 'service_account',
384+
},
385+
]
378386
useConnectorSetupStore
379387
.getState()
380388
.saveDraft('user-1:organization:org-1:kb-search:confluence:admin', {
@@ -1126,11 +1134,11 @@ describe('administrator source prerequisites in real connector dialogs', () => {
11261134
initialAccessMode='admin'
11271135
/>
11281136
)
1129-
await openCombo('Select Confluence account')
1137+
await openCombo('Select a service account')
11301138
const options = Array.from(document.querySelectorAll('[role="option"]'))
11311139
expect(
11321140
options.some((node) => node.textContent?.trim() === 'Connect Confluence account')
1133-
).toBe(true)
1141+
).toBe(false)
11341142
const serviceAccountOption = options.find(
11351143
(node) => node.textContent?.trim() === 'Add service account'
11361144
)
@@ -1358,7 +1366,7 @@ describe('administrator source prerequisites in real connector dialogs', () => {
13581366
expect(mocks.applyAccess).not.toHaveBeenCalled()
13591367
})
13601368

1361-
it('guides a member source back to saving its crawl subject without losing drafts or combining mutations', async () => {
1369+
it('guides a general knowledge-base member source back to saving its crawl subject without losing drafts or combining mutations', async () => {
13621370
const existing = connector({
13631371
connectorType: 'google_drive',
13641372
sourceConfig: { folderId: 'original-folder', _canonicalModes: { folderId: 'advanced' } },
@@ -1367,8 +1375,7 @@ describe('administrator source prerequisites in real connector dialogs', () => {
13671375
<EditConnectorModal
13681376
open
13691377
onOpenChange={vi.fn()}
1370-
knowledgeBaseId='kb-search'
1371-
isSearchIndex
1378+
knowledgeBaseId='kb-general'
13721379
connector={existing}
13731380
/>
13741381
)
@@ -1409,8 +1416,7 @@ describe('administrator source prerequisites in real connector dialogs', () => {
14091416
key='saved-settings'
14101417
open
14111418
onOpenChange={vi.fn()}
1412-
knowledgeBaseId='kb-search'
1413-
isSearchIndex
1419+
knowledgeBaseId='kb-general'
14141420
connector={connector({
14151421
...existing,
14161422
sourceConfig: mocks.update.mock.calls[0][0].updates.sourceConfig,
@@ -1423,7 +1429,7 @@ describe('administrator source prerequisites in real connector dialogs', () => {
14231429
await click(button('Apply connection method'))
14241430
expect(mocks.applyAccess).toHaveBeenCalledExactlyOnceWith(
14251431
{
1426-
knowledgeBaseId: 'kb-search',
1432+
knowledgeBaseId: 'kb-general',
14271433
connectorId: existing.id,
14281434
access: { accessMode: 'admin', credentialId: driveCredential.id },
14291435
},
@@ -1455,9 +1461,14 @@ describe('administrator source prerequisites in real connector dialogs', () => {
14551461
expect(mocks.applyAccess).not.toHaveBeenCalled()
14561462
})
14571463

1458-
it('blocks an already selected Confluence administrator transition when identity access becomes unavailable', async () => {
1464+
it('blocks a general knowledge-base Confluence administrator transition when identity access becomes unavailable', async () => {
14591465
mocks.credentials = [
1460-
{ id: 'confluence-account', name: 'Confluence indexing account', provider: 'confluence' },
1466+
{
1467+
id: 'confluence-account',
1468+
name: 'Confluence indexing account',
1469+
provider: 'confluence',
1470+
type: 'service_account',
1471+
},
14611472
]
14621473
const existing = connector({
14631474
connectorType: 'confluence',
@@ -1467,13 +1478,12 @@ describe('administrator source prerequisites in real connector dialogs', () => {
14671478
<EditConnectorModal
14681479
open
14691480
onOpenChange={vi.fn()}
1470-
knowledgeBaseId='kb-search'
1471-
isSearchIndex
1481+
knowledgeBaseId='kb-general'
14721482
connector={existing}
14731483
/>
14741484
)
14751485
await render(modal)
1476-
await click(button('Admin or service account'))
1486+
await click(button('Service account'))
14771487
await chooseCombo('Select the account to sync as', 'Confluence indexing account')
14781488
expect(button('Apply connection method')).toBeEnabled()
14791489
mocks.features.knowledgeMemberAccess = false

apps/sim/connectors/gmail/gmail.test.ts

Lines changed: 175 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,22 @@ vi.mock('@/lib/knowledge/documents/utils', () => ({
1010
VALIDATE_RETRY_OPTIONS: {},
1111
}))
1212
vi.mock('@/components/icons', () => ({ GmailIcon: () => null }))
13+
vi.mock('@/lib/knowledge/documents/service', () => ({
14+
isTriggerAvailable: () => false,
15+
processDocumentsWithQueue: vi.fn(),
16+
}))
17+
vi.mock('@/lib/knowledge/connectors/sync-persistence', () => ({
18+
addDocument: vi.fn(),
19+
persistSkippedDocuments: vi.fn(),
20+
persistSkippedRetryHashes: vi.fn(),
21+
updateDocument: vi.fn(),
22+
}))
1323

24+
import {
25+
classifyExternalDoc,
26+
mergeHydratedSkippedDocument,
27+
shouldReplaceExistingWithSkippedDocument,
28+
} from '@/lib/knowledge/connectors/sync-primitives'
1429
import { gmailConnector } from '@/connectors/gmail/gmail'
1530
import { DEFAULT_MAX_THREADS, gmailConnectorMeta } from '@/connectors/gmail/meta'
1631
import { CONNECTOR_TEXT_DOCUMENT_MAX_BYTES, PER_MEMBER_LISTING_CONTEXT } from '@/connectors/utils'
@@ -262,43 +277,51 @@ function mockExternalBodyThread(
262277
}
263278

264279
describe('Gmail full-thread response budget', () => {
265-
it('rejects an oversized Content-Length before reading the thread', async () => {
280+
it('records a versioned size skip without reading oversized Content-Length bodies', async () => {
266281
const pull = vi.fn((controller: ReadableStreamDefaultController<Uint8Array>) => {
267282
controller.enqueue(Buffer.from(JSON.stringify(threadFixture())))
268283
controller.close()
269284
})
270285
const cancel = vi.fn()
271-
mockFetchWithRetry.mockResolvedValue(
272-
new Response(new ReadableStream({ pull, cancel }, { highWaterMark: 0 }), {
273-
headers: { 'Content-Length': String(32 * 1024 * 1024 + 1) },
274-
})
286+
mockFetchWithRetry.mockImplementation(async (url: string) =>
287+
new URL(url).searchParams.get('format') === 'minimal'
288+
? Response.json({ id: 'thread-1', historyId: '10' })
289+
: new Response(new ReadableStream({ pull, cancel }, { highWaterMark: 0 }), {
290+
headers: { 'Content-Length': String(32 * 1024 * 1024 + 1) },
291+
})
275292
)
276293

277-
await expect(gmailConnector.getDocument('token', {}, 'thread-1')).rejects.toMatchObject({
278-
name: 'PayloadSizeLimitError',
279-
maxBytes: 32 * 1024 * 1024,
280-
observedBytes: 32 * 1024 * 1024 + 1,
294+
await expect(gmailConnector.getDocument('token', {}, 'thread-1')).resolves.toMatchObject({
295+
externalId: 'thread-1',
296+
contentHash: 'gmail:thread-1:10:body-v2',
297+
content: '',
298+
contentDeferred: false,
299+
skippedExistingDisposition: 'replace',
300+
skippedReason: 'File exceeds the 32MB size limit and was not indexed',
281301
})
282302
expect(pull).not.toHaveBeenCalled()
283-
expect(cancel).toHaveBeenCalledOnce()
284-
expect(mockFetchWithRetry).toHaveBeenCalledTimes(1)
303+
expect(cancel).toHaveBeenCalledTimes(2)
304+
expect(mockFetchWithRetry).toHaveBeenCalledTimes(4)
285305
})
286306

287-
it('cancels chunked oversized JSON before consuming the complete thread', async () => {
288-
let chunksRead = 0
307+
it('cancels chunked oversized JSON and skips only after verifying a stable revision', async () => {
308+
const chunksRead: number[] = []
289309
const chunk = Buffer.alloc(1024 * 1024, 'a')
290310
const cancel = vi.fn()
291-
mockFetchWithRetry.mockResolvedValue(
292-
new Response(
311+
mockFetchWithRetry.mockImplementation(async (url: string) => {
312+
if (new URL(url).searchParams.get('format') === 'minimal')
313+
return Response.json({ id: 'thread-1', historyId: '10' })
314+
const index = chunksRead.push(0) - 1
315+
return new Response(
293316
new ReadableStream(
294317
{
295318
pull(controller) {
296-
chunksRead += 1
297-
if (chunksRead === 1) {
319+
chunksRead[index] += 1
320+
if (chunksRead[index] === 1) {
298321
controller.enqueue(
299322
Buffer.from('{"id":"thread-1","historyId":"10","messages":[],"padding":"')
300323
)
301-
} else if (chunksRead <= 35) {
324+
} else if (chunksRead[index] <= 35) {
302325
controller.enqueue(chunk)
303326
} else {
304327
controller.enqueue(Buffer.from('"}'))
@@ -310,14 +333,141 @@ describe('Gmail full-thread response budget', () => {
310333
{ highWaterMark: 0 }
311334
)
312335
)
336+
})
337+
338+
await expect(gmailConnector.getDocument('token', {}, 'thread-1')).resolves.toMatchObject({
339+
content: '',
340+
skippedReason: 'File exceeds the 32MB size limit and was not indexed',
341+
contentHash: 'gmail:thread-1:10:body-v2',
342+
})
343+
expect(cancel).toHaveBeenCalledTimes(2)
344+
expect(chunksRead).toHaveLength(2)
345+
expect(chunksRead.every((count) => count < 35)).toBe(true)
346+
expect(mockFetchWithRetry).toHaveBeenCalledTimes(4)
347+
})
348+
349+
it('replaces stale content once, resumes on source change, and preserves member isolation', async () => {
350+
let historyId = '10'
351+
mockFetchWithRetry.mockImplementation(async (url: string, init?: RequestInit) => {
352+
const parsed = new URL(url)
353+
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer alice-token')
354+
if (parsed.pathname.endsWith('/threads'))
355+
return Response.json({ threads: [{ id: 'thread-1', historyId }] })
356+
if (parsed.searchParams.get('format') === 'minimal')
357+
return Response.json({ id: 'thread-1', historyId })
358+
return new Response(null, {
359+
headers: { 'Content-Length': String(32 * 1024 * 1024 + 1) },
360+
})
361+
})
362+
const context = memberContext('alice')
363+
const [stub] = (await gmailConnector.listDocuments('alice-token', {}, undefined, context))
364+
.documents
365+
const skipped = await gmailConnector.getDocument('alice-token', {}, stub.externalId, context)
366+
expect(skipped?.externalId).toBe('member:alice:thread-1')
367+
expect(shouldReplaceExistingWithSkippedDocument({ storageKey: 'old.txt' }, skipped!)).toBe(true)
368+
const merged = mergeHydratedSkippedDocument(stub, skipped!)
369+
const stored = { id: 'stored', contentHash: merged.contentHash, storageKey: null }
370+
expect(classifyExternalDoc(stub, stored)).toEqual({ type: 'unchanged' })
371+
expect(classifyExternalDoc(stub, stored, true)).toEqual({
372+
type: 'update',
373+
existingId: 'stored',
374+
})
375+
historyId = '11'
376+
const [updated] = (
377+
await gmailConnector.listDocuments('alice-token', {}, undefined, memberContext('alice'))
378+
).documents
379+
expect(classifyExternalDoc(updated, stored)).toEqual({ type: 'update', existingId: 'stored' })
380+
mockFetchWithRetry.mockClear()
381+
expect(
382+
await gmailConnector.getDocument('alice-token', {}, stub.externalId, memberContext('bob'))
383+
).toBeNull()
384+
expect(mockFetchWithRetry).not.toHaveBeenCalled()
385+
})
386+
387+
it('does not cache a size skip for a revision that changes during the bounded retry', async () => {
388+
let metadataReads = 0
389+
mockFetchWithRetry.mockImplementation(async (url: string) =>
390+
new URL(url).searchParams.get('format') === 'minimal'
391+
? Response.json({ id: 'thread-1', historyId: String(10 + metadataReads++) })
392+
: new Response(null, {
393+
headers: { 'Content-Length': String(32 * 1024 * 1024 + 1) },
394+
})
395+
)
396+
await expect(gmailConnector.getDocument('token', {}, 'thread-1')).rejects.toThrow(
397+
'Gmail thread changed while checking its size'
313398
)
399+
expect(mockFetchWithRetry).toHaveBeenCalledTimes(4)
400+
})
314401

315-
await expect(gmailConnector.getDocument('token', {}, 'thread-1')).rejects.toMatchObject({
316-
name: 'PayloadSizeLimitError',
317-
maxBytes: 32 * 1024 * 1024,
402+
it('hydrates a thread that becomes small enough during the bounded retry', async () => {
403+
let fullReads = 0
404+
mockFetchWithRetry.mockImplementation(async (url: string) => {
405+
const parsed = new URL(url)
406+
if (parsed.pathname.endsWith('/labels')) return Response.json({ labels: [] })
407+
if (parsed.searchParams.get('format') === 'minimal')
408+
return Response.json({ id: 'thread-1', historyId: '11' })
409+
if (fullReads++ === 0)
410+
return new Response(null, {
411+
headers: { 'Content-Length': String(32 * 1024 * 1024 + 1) },
412+
})
413+
return Response.json(threadFixture('11', 'A smaller current thread'))
318414
})
319-
expect(cancel).toHaveBeenCalledOnce()
320-
expect(chunksRead).toBeLessThan(35)
415+
const document = await gmailConnector.getDocument('token', {}, 'thread-1')
416+
expect(document?.content).toContain('A smaller current thread')
417+
expect(document?.skippedReason).toBeUndefined()
418+
expect(document?.contentHash).toBe('gmail:thread-1:11:body-v2')
419+
expect(fullReads).toBe(2)
420+
})
421+
422+
it.each([401, 429, 503])(
423+
'preserves metadata HTTP %s failure instead of caching a skip',
424+
async (status) => {
425+
mockFetchWithRetry.mockImplementation(async (url: string) =>
426+
new URL(url).searchParams.get('format') === 'minimal'
427+
? new Response(null, { status })
428+
: new Response(null, {
429+
headers: { 'Content-Length': String(32 * 1024 * 1024 + 1) },
430+
})
431+
)
432+
await expect(gmailConnector.getDocument('token', {}, 'thread-1')).rejects.toMatchObject({
433+
status,
434+
})
435+
expect(mockFetchWithRetry).toHaveBeenCalledTimes(2)
436+
}
437+
)
438+
439+
it.each([{}, { id: 'thread-1' }, { id: 'other-thread', historyId: '10' }])(
440+
'rejects unverified metadata instead of synthesizing a skip revision: %j',
441+
async (metadata) => {
442+
mockFetchWithRetry.mockImplementation(async (url: string) =>
443+
new URL(url).searchParams.get('format') === 'minimal'
444+
? Response.json(metadata)
445+
: new Response(null, {
446+
headers: { 'Content-Length': String(32 * 1024 * 1024 + 1) },
447+
})
448+
)
449+
await expect(gmailConnector.getDocument('token', {}, 'thread-1')).rejects.toThrow(
450+
'Gmail returned malformed thread metadata'
451+
)
452+
expect(mockFetchWithRetry).toHaveBeenCalledTimes(2)
453+
}
454+
)
455+
456+
it('returns null when the oversized thread disappears before revision verification', async () => {
457+
mockFetchWithRetry.mockImplementation(async (url: string) =>
458+
new URL(url).searchParams.get('format') === 'minimal'
459+
? new Response(null, { status: 404 })
460+
: new Response(null, {
461+
headers: { 'Content-Length': String(32 * 1024 * 1024 + 1) },
462+
})
463+
)
464+
await expect(gmailConnector.getDocument('token', {}, 'thread-1')).resolves.toBeNull()
465+
expect(mockFetchWithRetry).toHaveBeenCalledTimes(2)
466+
})
467+
468+
it('does not turn a missing response body into a permanent size skip', async () => {
469+
mockFetchWithRetry.mockResolvedValueOnce(new Response(null))
470+
await expect(gmailConnector.getDocument('token', {}, 'thread-1')).rejects.toThrow()
321471
expect(mockFetchWithRetry).toHaveBeenCalledTimes(1)
322472
})
323473

@@ -505,6 +655,8 @@ describe('Gmail separately stored message bodies', () => {
505655
content: '',
506656
contentDeferred: false,
507657
skippedReason: 'File exceeds the 12MB size limit and was not indexed',
658+
skippedExistingDisposition: 'replace',
659+
skippedRetryPolicy: 'source-change',
508660
})
509661
})
510662

0 commit comments

Comments
 (0)