Skip to content

Commit 4e49225

Browse files
authored
fix(search): resume Google crawls around unavailable accounts (#7934)
* fix(search): resume Google crawls around unavailable accounts * fix(search): harden crawl completion and recovery * improvement(search): generalize connector partition progress * fix(search): clear unverifiable placeholder permissions
1 parent de19e11 commit 4e49225

51 files changed

Lines changed: 31535 additions & 117 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-build.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,11 @@ jobs:
235235
lib/knowledge/__integration__/search-source-pagination.integration.ts
236236
lib/knowledge/__integration__/search-reference-batching.integration.ts
237237
lib/knowledge/__integration__/embedding-insert-batches.integration.ts
238+
lib/knowledge/__integration__/connector-lifecycle-locks.integration.ts
239+
lib/knowledge/__integration__/connector-deferral.integration.ts
240+
lib/knowledge/__integration__/stored-document-recovery.integration.ts
241+
lib/knowledge/__integration__/connector-partition-work.integration.ts
242+
lib/knowledge/__integration__/listing-continuation.integration.ts
238243
lib/knowledge/__integration__/kb-block-search.integration.ts
239244
lib/core/outbox/service.integration.ts
240245
lib/knowledge/__integration__/connector-upload.integration.ts

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ Search schedules syncs hourly. The first sync lists every thread in scope and ca
133133

134134
**Member accounts:** later syncs use each mailbox's Gmail change history, unless the configuration has a search filter. A full relisting runs about weekly, or sooner if Gmail no longer retains the saved history.
135135

136-
**Service account:** each sync revisits the selected active mailboxes and resumes unfinished listings. If Google reports that a user's mailbox is not set up or returns a mailbox `failedPrecondition`, Sim records a warning and continues with the remaining users. The crawl stays incomplete and retries affected users on the next scheduled crawl; existing indexed mail is not deleted because a mailbox could not be read. Credential, delegation, and Directory failures still stop the crawl.
136+
**Service account:** each user's progress is saved separately, so a large mailbox or an account-specific access failure does not hold up other users. Temporary failures retry automatically. Unavailable mailboxes are revisited without deleting indexed mail solely because the mailbox could not be read. Sim continues discovering users and refreshing existing mail and permissions while unfinished work resumes. Results with stale or unverified permissions remain hidden. Credential, delegation, Directory, and scope failures still require attention.
137137

138138
Updates, removals, and access refresh in the background. Empty mailboxes and filters with no matches complete normally with zero documents. Threads exceeding indexing size limits are skipped and reconsidered when they change.
139139

apps/docs/content/docs/search/google-calendar.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Admins can request member connections from **Settings → Sources → People →
6262

6363
Open **Settings → Sources → Add source** and select **Google Calendar**. This opens **Connect Google Calendar service account**. To add another connection later, open **Google Calendar** from the Sources list and select **Connect service account**.
6464

65-
This requires a Google Workspace customer and a super administrator to authorize domain-wide delegation. Consumer Gmail accounts cannot use this path.
65+
This requires a Google Workspace customer and a super administrator to authorize domain-wide delegation. Consumer Gmail accounts cannot use this path. In the Google Admin Console, confirm **Apps → Google Workspace → Calendar → Service status** is on for the users you want to index. An active Workspace account does not necessarily have Calendar enabled.
6666

6767
<Steps>
6868
<Step>
@@ -131,7 +131,9 @@ Sim indexes event titles, descriptions, times, locations, and the selected atten
131131

132132
Cancelled events, attachment contents, meeting recordings, and transcripts are not indexed. Status entries such as working location, out of office, focus time, and birthdays, and automatically generated reservation events from Gmail are not indexed. Events Google returns only as free/busy blocks, without searchable details, are not indexed. Events outside the selected date window are excluded. Private event details that Google withholds are not available in Search; see [Google's calendar sharing rules](https://developers.google.com/workspace/calendar/api/concepts/sharing).
133133

134-
Search schedules syncs hourly. Event edits, cancellations, access changes, inactive or removed users, and events moving outside the date window are reconciled during completed background syncs. Central crawls page through each selected user and resume unfinished work before removing documents no longer listed. If an individual user's event listing returns a `403` with an explicit `forbidden` reason and no other reasons, Sim records a warning and continues with the remaining users. The crawl stays incomplete and retries affected users on the next scheduled crawl; unread calendars are not treated as empty. A `403` without a reason stops the crawl because its cause is unknown. Credential, delegation, Directory, and other provider failures still stop the crawl. The first sync may take longer, and results appear as indexing progresses; Search is not a live Calendar read.
134+
Search schedules syncs hourly. The first sync may span several background runs; results appear as indexing progresses. Central crawls save each user's progress so a large calendar or an account-specific access failure does not hold up other users. Temporary failures retry automatically. Unresolved accounts are revisited without treating unread calendars as empty; credential, delegation, Directory, and scope failures still require attention.
135+
136+
Event edits, cancellations, access changes, inactive or removed users, and events moving outside the date window are reconciled in the background. Sim refreshes existing permissions while a longer crawl continues. Results with stale or unverified permissions remain hidden. Search is not a live Calendar read.
135137

136138
## Troubleshooting
137139

apps/docs/content/docs/search/google-drive.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ Use **Sync now** to request an update, **Pause syncing** to stop scheduled syncs
151151

152152
Sim exports Docs and Slides as text and Sheets as XLSX spreadsheets. Supported uploaded files use the knowledge-base document pipeline, including PDF and Office formats. Unsupported files and oversized exports cannot be indexed; Google limits Workspace exports to 10 MB. See [Drive export formats](https://developers.google.com/workspace/drive/api/guides/ref-export-formats) and [download limits](https://developers.google.com/workspace/drive/api/guides/manage-downloads).
153153

154-
Search schedules syncs hourly. Central crawls revisit the selected users' files and permissions, including unchanged files, so permission changes and a new employee's older files are included. Unfinished crawls resume before deletion reconciliation. Content, deletions, and permissions refresh in the background; results are not a live read from Drive. **People** on the Sources settings page shows personal account connections; it does not list the central service-account credential.
154+
Search schedules syncs hourly. Central crawls save each user's progress separately, so a large Drive or an account-specific access failure does not hold up other users. Temporary failures retry automatically. Sim continues discovering users and refreshing files and permissions while unfinished work resumes. Unread files are not treated as deleted, and results with stale or unverified permissions remain hidden. Results are not a live read from Drive. **People** on the Sources settings page shows personal account connections; it does not list the central service-account credential.
155155

156156
## Troubleshooting
157157

@@ -163,6 +163,7 @@ Directory permission warnings mean Sim could not fully verify group membership.
163163
| Directory access failed | Check all four delegated scopes and the **Directory administrator email** user's administrator privileges. A normal Google OAuth credential cannot supply this central Search path. |
164164
| Missing files in a central crawl | Check **Users**, folder and file-type filters, and whether selected active Workspace users can download the file and read its permissions. Opening a file alone does not prove either. Check Sync history for errors. Files reachable only by excluded or inactive accounts are not crawled; files with unverified permissions stay hidden. |
165165
| No text could be extracted | Images and scanned PDFs use OCR, but files with no extractable text cannot be indexed. Check whether the original contains readable text; a successful download does not guarantee searchable content. |
166+
| File contains a Git LFS pointer | The uploaded file references content stored elsewhere instead of containing the actual image or PDF. Replace it in Drive with the original file content, then sync again. Retrying the unchanged pointer cannot recover the content. |
166167
| User not found or inactive | Use a primary email in the same Google Workspace customer. Aliases, external or guest accounts, suspended users, and archived users cannot be selected for crawling. |
167168
| A teammate sees no results | Confirm they have joined the Sim organization and their verified Sim email matches the Drive permission or group membership. For member accounts, finish their personal Drive connection too. |
168169
| A public or shared-link file is missing | Check **Openly shared files**. Link-only sharing does not grant Search access. A named user or group permission can still make the file searchable. |

apps/sim/connectors/google-drive/company-crawl.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,14 @@ describe('Google Drive company-wide crawl', () => {
553553
).rejects.toThrow()
554554
expect(mockFetch).not.toHaveBeenCalled()
555555
})
556+
557+
it('passes the crawl signal into the delegated token exchange', async () => {
558+
fixture({})
559+
const controller = new AbortController()
560+
const ctx = { ...context(), signal: controller.signal }
561+
await drive.listDocuments('directory-token', CONFIG, undefined, ctx)
562+
expect(ctx.getDelegatedAccessToken).toHaveBeenCalledWith('alice@corp.com', controller.signal)
563+
})
556564
})
557565

558566
describe('Company-wide setup validation', () => {

apps/sim/connectors/google-drive/company-crawl.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import {
66
GOOGLE_WORKSPACE_DRIVES_PAGE_SIZE,
77
listGoogleWorkspaceDrives,
88
} from '@/connectors/google-drive/workspace-drives'
9+
import {
10+
type GoogleCompanyCursorAdapter,
11+
googleCompanyUserContextSchema,
12+
} from '@/connectors/google-workspace/company-work'
913
import {
1014
GOOGLE_WORKSPACE_USERS_PAGE_SIZE,
1115
getGoogleWorkspaceUser,
@@ -18,15 +22,7 @@ import { parseOptionalUnlimitedSafeInteger } from '@/connectors/utils'
1822
const CURSOR_PREFIX = 'gdrive-company:v1:'
1923
const MAX_CURSOR_BYTES = 384 * 1024
2024
const cursorSchema = z.object({
21-
users: z
22-
.array(
23-
z.object({
24-
id: z.string().min(1).max(256),
25-
email: z.string().email().max(254),
26-
customerId: z.string().min(1).max(256),
27-
})
28-
)
29-
.max(GOOGLE_WORKSPACE_USERS_PAGE_SIZE),
25+
users: z.array(googleCompanyUserContextSchema).max(GOOGLE_WORKSPACE_USERS_PAGE_SIZE),
3026
nextUsersPageToken: z.string().min(1).max(8192).optional(),
3127
scope: z.discriminatedUnion('kind', [
3228
z.object({
@@ -52,7 +48,7 @@ const cursorSchema = z.object({
5248
})
5349
type CompanyCursor = z.infer<typeof cursorSchema>
5450

55-
type DelegatedTokenResolver = (subject: string) => Promise<string>
51+
type DelegatedTokenResolver = (subject: string, signal?: AbortSignal) => Promise<string>
5652

5753
function delegatedTokenResolver(syncContext: Record<string, unknown>): DelegatedTokenResolver {
5854
if (typeof syncContext.getDelegatedAccessToken !== 'function') {
@@ -88,6 +84,21 @@ function writeCursor(cursor: CompanyCursor): string {
8884
return serialized
8985
}
9086

87+
/** A single-user cursor preserves shared-drive traversal as well as the user's own Drive page. */
88+
export const googleDriveCompanyCursorAdapter: GoogleCompanyCursorAdapter = {
89+
seed: (user) => writeCursor({ users: [user], scope: { kind: 'user' } }),
90+
resume: (cursor) => {
91+
const state = readCursor(cursor)
92+
return state.users.map((user, index) => ({
93+
user,
94+
cursor: writeCursor({
95+
users: [user],
96+
scope: index === 0 ? state.scope : { kind: 'user' },
97+
}),
98+
}))
99+
},
100+
}
101+
91102
/** Positive caps can stop before later users; central sources must finish their selected corpus. */
92103
function validateCompanyConfig(sourceConfig: Record<string, unknown>): string[] {
93104
if (
@@ -197,7 +208,7 @@ export async function listGoogleCompanyDocuments(input: {
197208
}
198209

199210
signal?.throwIfAborted()
200-
const userToken = await resolveToken(user.email)
211+
const userToken = await (signal ? resolveToken(user.email, signal) : resolveToken(user.email))
201212
const nextDrives = async (pageToken?: string): Promise<CompanyCursor> => {
202213
const drives = await listGoogleWorkspaceDrives(userToken, pageToken, signal)
203214
if (drives.driveIds.length > 0) {

apps/sim/connectors/google-drive/google-drive-errors.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import {
1313
ConnectorSourceError,
1414
type ConnectorSourceFailureCategory,
15+
type ConnectorSourceReasonState,
1516
} from '@/connectors/source-error'
1617

1718
const GOOGLE_ERROR_REASON_MAX_COUNT = 16
@@ -110,7 +111,8 @@ export class GoogleDriveApiError extends ConnectorSourceError {
110111
status: number,
111112
normalizedReasons: readonly string[],
112113
operation = 'drive.request',
113-
reasonsComplete = true
114+
reasonsComplete = true,
115+
reasonState?: ConnectorSourceReasonState
114116
) {
115117
const diagnosticReasons = safeGoogleErrorReasons(normalizedReasons).slice(
116118
0,
@@ -122,7 +124,7 @@ export class GoogleDriveApiError extends ConnectorSourceError {
122124
`Google Drive API request failed with HTTP ${status}${reasonSuffix}`,
123125
status,
124126
diagnosticCategory(kind, status),
125-
{ operation, reasons: diagnosticReasons }
127+
{ operation, reasons: diagnosticReasons, ...(reasonState ? { reasonState } : {}) }
126128
)
127129
this.name = 'GoogleDriveApiError'
128130
this.reasons = diagnosticReasons
@@ -144,7 +146,13 @@ export async function readGoogleDriveApiError(
144146
operation = 'drive.request'
145147
): Promise<GoogleDriveApiError> {
146148
const details = await readGoogleErrorDetails(response)
147-
return new GoogleDriveApiError(response.status, details.reasons, operation, details.complete)
149+
return new GoogleDriveApiError(
150+
response.status,
151+
details.reasons,
152+
operation,
153+
details.complete,
154+
details.reasonState
155+
)
148156
}
149157

150158
/**

apps/sim/connectors/google-workspace/api-errors.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describe('Google API diagnostics', () => {
3232
new Error('outer private detail', { cause: error })
3333
)
3434
expect(diagnostic).toMatchObject({ status, category, operation: OPERATION, reasons: [reason] })
35+
expect(diagnostic?.reasonState).toBe('present')
3536
expect(JSON.stringify(diagnostic)).not.toContain(RESPONSE_SECRET)
3637
expect(JSON.stringify(diagnostic)).not.toContain('outer private detail')
3738
})
@@ -40,6 +41,7 @@ describe('Google API diagnostics', () => {
4041
const error = await readGoogleApiError(failure(403, RESPONSE_SECRET), OPERATION)
4142
expect(error.diagnostic?.reasons).toEqual([])
4243
expect(error.reasonsComplete).toBe(false)
44+
expect(getConnectorFailureDiagnostic(error)?.reasonState).toBe('filtered')
4345
expect(JSON.stringify(error)).not.toContain(RESPONSE_SECRET)
4446
})
4547

@@ -82,11 +84,13 @@ describe('Google API diagnostics', () => {
8284
)
8385
expect(absent.reasonsComplete).toBe(true)
8486
expect(absent.diagnostic?.reasons).toEqual([])
87+
expect(getConnectorFailureDiagnostic(absent)?.reasonState).toBe('absent')
8588
const malformed = await readGoogleApiError(
8689
Response.json({ error: { errors: [{ reason: 123 }] } }, { status: 403 }),
8790
'calendar.events.list'
8891
)
8992
expect(malformed.reasonsComplete).toBe(false)
93+
expect(getConnectorFailureDiagnostic(malformed)?.reasonState).toBe('malformed')
9094
const mixed = await readGoogleApiError(
9195
Response.json(
9296
{ error: { errors: [{ reason: 'forbidden' }, { reason: RESPONSE_SECRET }] } },
@@ -96,6 +100,7 @@ describe('Google API diagnostics', () => {
96100
)
97101
expect(mixed.diagnostic?.reasons).toEqual(['forbidden'])
98102
expect(mixed.reasonsComplete).toBe(false)
103+
expect(getConnectorFailureDiagnostic(mixed)?.reasonState).toBe('filtered')
99104
expect(JSON.stringify([absent, malformed, mixed])).not.toContain(RESPONSE_SECRET)
100105
})
101106

@@ -120,8 +125,61 @@ describe('Google API diagnostics', () => {
120125
)
121126
expect(error.diagnostic?.reasons).toEqual(['forbidden'])
122127
expect(error.reasonsComplete).toBe(false)
128+
expect(getConnectorFailureDiagnostic(error)?.reasonState).toBe('malformed')
123129
expect(JSON.stringify(error)).not.toContain(RESPONSE_SECRET)
124130
})
131+
132+
it('distinguishes an unreadable body from a malformed response', async () => {
133+
const unreadable = new Response(
134+
new ReadableStream({
135+
start(controller) {
136+
controller.error(new Error(RESPONSE_SECRET))
137+
},
138+
}),
139+
{ status: 403 }
140+
)
141+
const error = await readGoogleApiError(unreadable, OPERATION)
142+
expect(getConnectorFailureDiagnostic(error)?.reasonState).toBe('unreadable')
143+
expect(error.reasonsComplete).toBe(false)
144+
expect(JSON.stringify(error)).not.toContain(RESPONSE_SECRET)
145+
const malformed = await readGoogleApiError(new Response('not-json', { status: 403 }), OPERATION)
146+
expect(getConnectorFailureDiagnostic(malformed)?.reasonState).toBe('malformed')
147+
})
148+
149+
it('retains classification evidence beyond the diagnostic reason limit', async () => {
150+
const reasons = [
151+
'accessNotConfigured',
152+
'appNotAuthorizedToFile',
153+
'authError',
154+
'badRequest',
155+
'cannotDownloadFile',
156+
'cannotExportFile',
157+
'domainPolicy',
158+
'download_restricted_for_revision',
159+
'exportSizeLimitExceeded',
160+
'failedPrecondition',
161+
'fileNotDownloadable',
162+
'fileNotExportable',
163+
'forbidden',
164+
'insufficientFilePermissions',
165+
'insufficientPermissions',
166+
'invalid',
167+
'rateLimitExceeded',
168+
]
169+
const error = await readGoogleApiError(
170+
Response.json(
171+
{
172+
error: { errors: reasons.map((reason) => ({ reason })) },
173+
},
174+
{ status: 403 }
175+
),
176+
OPERATION
177+
)
178+
expect(error.rateLimited).toBe(true)
179+
expect(error.reasonsComplete).toBe(false)
180+
expect(error.diagnostic?.reasonState).toBe('filtered')
181+
expect(error.diagnostic?.reasons).toHaveLength(16)
182+
})
125183
})
126184

127185
describe('Google API retries', () => {

0 commit comments

Comments
 (0)