Skip to content

Commit f4ec7f3

Browse files
committed
fix(knowledge): keep a Google Workspace user's visible documents until a missing service outlasts propagation
Google applies service and organizational-unit changes within 24 hours, so a missing mailbox or notACalendarUser can be transient for a user whose documents are already indexed, and a mid-listing answer does not prove the whole account lacks the service. - The scheduler skips a service-not-enabled user only on their first provider page, and only when readers see none of their documents or the condition was first observed at least 24 hours ago. Otherwise it is a retained failure, as before, whose first observation is kept in the partition failure; after 24 hours a mid-listing user restarts from their first page. - Permission passes skip on the first page, since a retained failure refreshes nothing. - Gmail Directory enumeration keeps scheduling a user without a mailbox while readers still see their mail; the crawl reports the missing mailbox to the scheduler instead of completing the user. - Visibility is read through doc_acl_gin_idx for the user's token behind an OFFSET 0 fence, bounded by that user's grants.
1 parent 47d94a5 commit f4ec7f3

9 files changed

Lines changed: 226 additions & 54 deletions

File tree

‎apps/sim/connectors/google-workspace/company-crawl.test.ts‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
33
import { GoogleApiError, readGoogleApiError } from '@/connectors/google-workspace/api-errors'
44
import {
5+
GoogleWorkspaceMailboxNotSetup,
56
getGoogleWorkspaceDocument,
67
InvalidGoogleWorkspaceCursor,
78
listGoogleWorkspaceDocuments,
9+
serviceNotEnabledFailure,
810
validateGoogleWorkspaceConfig,
911
} from '@/connectors/google-workspace/company-crawl'
1012
import type { ConnectorConfig, ExternalDocument } from '@/connectors/types'
@@ -483,18 +485,18 @@ describe('Google Workspace per-user central crawl', () => {
483485
}
484486
)
485487

486-
it('skips an explicitly unprovisioned Gmail mailbox without a token or a listing failure', async () => {
488+
it('leaves an explicitly unprovisioned Gmail mailbox to the scheduler before requesting a token', async () => {
487489
directory([USER('alice', undefined, { isMailboxSetup: false }), USER('bob')])
488490
const ctx: Record<string, unknown> = context()
489-
const first = await list(ctx)
490-
expect(first).toMatchObject({ documents: [], hasMore: true })
491-
expect(first.listingFailures).toBeUndefined()
492-
expect(first.reconciliationSafe).toBeUndefined()
491+
const error = await list(ctx).catch((caught: unknown) => caught)
492+
expect(error).toBeInstanceOf(GoogleWorkspaceMailboxNotSetup)
493+
expect(serviceNotEnabledFailure(error)).toEqual({
494+
operation: 'directory.users.get',
495+
reasons: ['mailboxNotSetup'],
496+
})
493497
expect(ctx.reconciliationUnsafe).toBeUndefined()
494498
expect(ctx.getDelegatedAccessToken).not.toHaveBeenCalled()
495-
const second = await list(context(), first.nextCursor)
496-
expect(second.documents[0].acl).toEqual(['u:bob@corp.com'])
497-
expect(second.listingFailures).toBeUndefined()
499+
expect(listUserDocuments).not.toHaveBeenCalled()
498500
})
499501

500502
it('does not use Gmail mailbox eligibility for Calendar', async () => {

‎apps/sim/connectors/google-workspace/company-crawl.ts‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -161,30 +161,43 @@ function ownerDocument(document: ExternalDocument, access: DelegatedUser): Exter
161161
return { ...document, acl: [`u:${access.user.email}`] }
162162
}
163163

164+
/** A Directory user without a Gmail mailbox; the user scheduler decides whether to skip them. */
165+
export class GoogleWorkspaceMailboxNotSetup extends Error {
166+
constructor() {
167+
super('Google Workspace user has no Gmail mailbox')
168+
this.name = 'GoogleWorkspaceMailboxNotSetup'
169+
}
170+
}
171+
164172
/**
165-
* Calendar answers an account without the Calendar service with exactly this reason. That is a
166-
* standing property of the account, not a listing failure, so the user scheduler skips it.
173+
* Evidence that a user lacks the service itself: no Gmail mailbox, or a Calendar list 403 whose
174+
* only reason is `notACalendarUser`. Admin changes to a user's services can take up to a day to
175+
* settle, so the user scheduler, which can see indexed documents, decides when this is a skip.
167176
*/
168-
export function isGoogleWorkspaceServiceNotEnabled(error: unknown): boolean {
169-
return (
170-
error instanceof GoogleApiError &&
177+
export function serviceNotEnabledFailure(
178+
error: unknown
179+
): Omit<ExternalListingFailures['samples'][number], 'scope'> | null {
180+
if (error instanceof GoogleWorkspaceMailboxNotSetup)
181+
return { operation: 'directory.users.get', reasons: ['mailboxNotSetup'] }
182+
return error instanceof GoogleApiError &&
171183
error.reasonsComplete &&
172184
error.status === 403 &&
173185
error.diagnostic?.operation === 'calendar.events.list' &&
174186
error.diagnostic.reasons.length === 1 &&
175187
error.diagnostic.reasons[0] === 'notACalendarUser'
176-
)
188+
? { operation: 'calendar.events.list', status: 403, reasons: ['notACalendarUser'] }
189+
: null
177190
}
178191

179-
/** Isolates narrow user-list failures; a missing Calendar service propagates for the scheduler to skip. */
192+
/** Isolates narrow user-list failures; a missing Calendar service propagates to the scheduler. */
180193
function userListingFailure(
181194
error: unknown,
182195
provider: GoogleWorkspaceProvider
183196
): Omit<ExternalListingFailures['samples'][number], 'scope'> | null {
184197
if (!(error instanceof GoogleApiError) || !error.diagnostic || !error.reasonsComplete) return null
185198
const reasons = error.diagnostic.reasons
186199
const isolated =
187-
!isGoogleWorkspaceServiceNotEnabled(error) &&
200+
!serviceNotEnabledFailure(error) &&
188201
(provider === 'gmail'
189202
? error.diagnostic.operation === 'gmail.threads.list' &&
190203
error.status === 400 &&
@@ -333,8 +346,8 @@ export async function listGoogleWorkspaceDocuments(
333346
})
334347
return emptyPage(advance())
335348
}
336-
/** Directory refresh stops scheduling users without a mailbox; a partition queued earlier just completes. */
337-
if (provider === 'gmail' && user.isMailboxSetup === false) return emptyPage(advance())
349+
if (provider === 'gmail' && user.isMailboxSetup === false)
350+
throw new GoogleWorkspaceMailboxNotSetup()
338351
const access: PageAccess = {
339352
provider,
340353
user,

‎apps/sim/connectors/listing-failures.ts‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@ import { CONNECTOR_SOURCE_REASON_STATES } from '@/connectors/source-error'
44
export const MAX_LISTING_FAILURE_SAMPLES = 10
55

66
/** Persist only bounded scope identifiers and provider-owned codes, never raw errors or content. */
7+
export const listingFailureSampleSchema = z.object({
8+
scope: z.string().min(1).max(254),
9+
operation: z.string().min(1).max(96),
10+
status: z.number().int().min(100).max(599).optional(),
11+
reasons: z.array(z.string().min(1).max(64)).max(16),
12+
reasonState: z.enum(CONNECTOR_SOURCE_REASON_STATES).optional(),
13+
/** When a standing account condition was first observed, bounding how long it is retained. */
14+
since: z.string().datetime().optional(),
15+
})
16+
717
export const listingFailuresSchema = z.object({
818
count: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
9-
samples: z
10-
.array(
11-
z.object({
12-
scope: z.string().min(1).max(254),
13-
operation: z.string().min(1).max(96),
14-
status: z.number().int().min(100).max(599).optional(),
15-
reasons: z.array(z.string().min(1).max(64)).max(16),
16-
reasonState: z.enum(CONNECTOR_SOURCE_REASON_STATES).optional(),
17-
})
18-
)
19-
.max(MAX_LISTING_FAILURE_SAMPLES),
19+
samples: z.array(listingFailureSampleSchema).max(MAX_LISTING_FAILURE_SAMPLES),
2020
})

‎apps/sim/connectors/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ export interface ExternalListingFailures {
205205
status?: number
206206
reasons: string[]
207207
reasonState?: ConnectorSourceReasonState
208+
/** When a standing account condition was first observed, bounding how long it is retained. */
209+
since?: string
208210
}[]
209211
}
210212

‎apps/sim/lib/knowledge/connectors/google-company-scheduler.test.ts‎

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import type {
1515
} from '@/lib/knowledge/connectors/partition-work'
1616
import { GoogleDriveApiError } from '@/connectors/google-drive/google-drive-errors'
1717
import { GoogleApiError } from '@/connectors/google-workspace/api-errors'
18-
import { listGoogleWorkspaceDocuments } from '@/connectors/google-workspace/company-crawl'
18+
import {
19+
GoogleWorkspaceMailboxNotSetup,
20+
listGoogleWorkspaceDocuments,
21+
} from '@/connectors/google-workspace/company-crawl'
1922
import { googleCompanyUserContextSchema } from '@/connectors/google-workspace/company-work'
2023
import type { GoogleWorkspaceUser } from '@/connectors/google-workspace/users'
2124
import { ConnectorSourceError } from '@/connectors/source-error'
@@ -142,10 +145,12 @@ function fixture(provider = 'google_calendar', syncIntervalMinutes = 60) {
142145
documents: [document],
143146
hasMore: false,
144147
}))
148+
const visible = vi.fn(async (_user: GoogleWorkspaceUser) => false)
145149
let scheduler = createGoogleCompanyScheduler({
146150
provider,
147151
store,
148152
listDocuments: list,
153+
hasVisibleDocuments: visible,
149154
syncIntervalMinutes,
150155
isListingCursorInvalidError: (error) => error === expiredCursor,
151156
now: () => clock,
@@ -182,6 +187,7 @@ function fixture(provider = 'google_calendar', syncIntervalMinutes = 60) {
182187
return {
183188
rows,
184189
list,
190+
visible,
185191
process,
186192
step,
187193
saved: () => saved,
@@ -196,6 +202,7 @@ function fixture(provider = 'google_calendar', syncIntervalMinutes = 60) {
196202
provider,
197203
store,
198204
listDocuments: list,
205+
hasVisibleDocuments: visible,
199206
syncIntervalMinutes,
200207
isListingCursorInvalidError: (error) => error === expiredCursor,
201208
now: () => clock,
@@ -324,6 +331,8 @@ describe('durable Google company user scheduling', () => {
324331
it('refreshes permissions for a user without the Calendar service at the permission cadence', async () => {
325332
mocks.directory.mockResolvedValue({ users: [user('a'), user('z')] })
326333
const f = fixture()
334+
/** Skipping a permission pass hides nothing a retained failure would keep visible. */
335+
f.visible.mockResolvedValue(true)
327336
f.list.mockResolvedValue({ documents: [document], hasMore: true, nextCursor: 'next-page' })
328337
await f.step(2)
329338
f.advance(13 * 60 * 60 * 1000)
@@ -342,22 +351,101 @@ describe('durable Google company user scheduling', () => {
342351
})
343352

344353
it.each([
345-
['gmail', false],
346-
['google_calendar', true],
354+
['gmail', false, false],
355+
['gmail', true, true],
356+
['google_calendar', false, true],
347357
] as const)(
348-
'for %s, schedules a Directory user without a mailbox: %s',
349-
async (provider, scheduled) => {
358+
'for %s with visible documents %s, schedules a Directory user without a mailbox: %s',
359+
async (provider, hasVisibleDocuments, scheduled) => {
350360
mocks.directory.mockResolvedValue({
351361
users: [user('a', { isMailboxSetup: false }), user('z')],
352362
})
353363
const f = fixture(provider)
364+
f.visible.mockResolvedValue(hasVisibleDocuments)
354365
await f.step(4)
355366
expect(f.rows.has('a:content')).toBe(scheduled)
356367
expect(f.rows.get('z:content')?.complete).toBe(true)
357368
expect(f.saved()).toMatchObject({ unsafe: false, listingFailures: null })
358369
}
359370
)
360371

372+
it.each([
373+
{
374+
provider: 'google_calendar',
375+
error: () => new GoogleApiError('calendar.events.list', 403, ['notACalendarUser']),
376+
reason: { operation: 'calendar.events.list', status: 403, reasons: ['notACalendarUser'] },
377+
},
378+
{
379+
provider: 'gmail',
380+
error: () => new GoogleWorkspaceMailboxNotSetup(),
381+
reason: { operation: 'directory.users.get', reasons: ['mailboxNotSetup'] },
382+
},
383+
])(
384+
'retains a $provider user whose documents readers still see until the condition outlasts propagation',
385+
async ({ provider, error, reason }) => {
386+
mocks.directory.mockResolvedValue({ users: [user('a')] })
387+
const f = fixture(provider, 15)
388+
f.visible.mockResolvedValue(true)
389+
f.list.mockImplementation(async () => {
390+
throw error()
391+
})
392+
await f.step(5)
393+
const retained = {
394+
complete: false,
395+
failure: { scope: 'a@fixture.test', ...reason, since: '2026-09-17T00:00:00.000Z' },
396+
}
397+
expect(f.rows.get('a:content')).toMatchObject({ ...retained, attempts: 1 })
398+
expect(f.visible).toHaveBeenCalledWith(expect.objectContaining({ id: 'a' }))
399+
expect(f.saved()).toMatchObject({
400+
complete: false,
401+
unsafe: true,
402+
listingFailures: { count: 1 },
403+
})
404+
405+
f.advance(23 * 60 * 60 * 1000)
406+
f.restart()
407+
await f.step(5)
408+
expect(f.rows.get('a:content')).toMatchObject({ ...retained, attempts: 2 })
409+
expect(f.visible).toHaveBeenCalledOnce()
410+
expect(f.saved().complete).toBe(false)
411+
412+
f.advance(60 * 60 * 1000)
413+
f.restart()
414+
await f.step(5)
415+
expect(f.rows.get('a:content')).toMatchObject({ complete: true, attempts: 0 })
416+
expect(f.rows.get('a:content')?.failure).toBeUndefined()
417+
expect(f.saved()).toMatchObject({ complete: true, listingFailures: null })
418+
}
419+
)
420+
421+
it('retains a service-not-enabled answer after the first page and restarts the user once it persists', async () => {
422+
mocks.directory.mockResolvedValue({ users: [user('a')] })
423+
const f = fixture()
424+
f.list
425+
.mockResolvedValueOnce({ documents: [document], hasMore: true, nextCursor: 'page-2' })
426+
.mockRejectedValue(new GoogleApiError('calendar.events.list', 403, ['notACalendarUser']))
427+
await f.step(5)
428+
expect(f.list.mock.calls.at(-1)?.[2]).toBe('page-2')
429+
expect(f.rows.get('a:content')).toMatchObject({
430+
complete: false,
431+
cursor: 'page-2',
432+
failure: { reasons: ['notACalendarUser'], since: '2026-09-17T00:00:00.000Z' },
433+
})
434+
expect(f.visible).not.toHaveBeenCalled()
435+
436+
f.advance(25 * 60 * 60 * 1000)
437+
f.restart()
438+
await f.step(5)
439+
expect(f.rows.get('a:content')).toMatchObject({ complete: false })
440+
expect(f.rows.get('a:content')?.cursor).toContain('google-workspace:v1:')
441+
442+
f.advance(60 * 60 * 1000)
443+
f.restart()
444+
await f.step(5)
445+
expect(f.rows.get('a:content')).toMatchObject({ complete: true })
446+
expect(f.rows.get('a:content')?.failure).toBeUndefined()
447+
})
448+
361449
it('picks up a Gmail user on the Directory refresh after their mailbox is provisioned', async () => {
362450
mocks.directory
363451
.mockResolvedValueOnce({ users: [user('a', { isMailboxSetup: false }), user('z')] })

0 commit comments

Comments
 (0)