Skip to content

Commit b4a172c

Browse files
authored
improvement(knowledge): finish Confluence passes past unreadable attachments, rank unauthenticated keyword search in one statement (#8975)
* improvement(knowledge): finish Confluence passes past unreadable attachments, rank unauthenticated keyword search in one statement - Confluence: a 403/404 on one parent's attachment listing is skipped with a warning instead of recorded as a listing failure, so the pass can finish and advance lastSyncAt. The page still marks the listing reconciliation-unsafe, so attachments it did not list are kept, never deleted. Missing scopes and server failures stay listing failures. - Connector counts: the member engine keeps the recorded document count on an unfinished slice once a pass has finished, the same gate the content engine uses. The count query and gate now live in sync-primitives. - Keyword search: callers with no person behind them rank through the same materialized candidate statement signed-in readers use, instead of ORDER BY ts_rank_cd over a per-chunk join. Same ACL predicate and global ordering, ties now broken by chunk id; still unbudgeted. * test(knowledge): compare single-statement keyword ranking against the two-step reference
1 parent 6abc330 commit b4a172c

8 files changed

Lines changed: 607 additions & 134 deletions

File tree

‎apps/sim/connectors/confluence/attachments.test.ts‎

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -215,41 +215,73 @@ describe('Confluence attachment listing', () => {
215215
expect(last.hasMore).toBe(false)
216216
})
217217

218-
it.each([403, 401])(
219-
'preserves parent pages and cumulative partial progress for attachment access failure %s',
218+
it('preserves parent pages and cumulative partial progress when the attachment scope is missing', async () => {
219+
fetchMock.mockImplementation(async () =>
220+
Response.json({ code: 401, message: 'Unauthorized; scope does not match' }, { status: 401 })
221+
)
222+
const listParents = vi.fn(
223+
async (cursor: string | undefined): Promise<ExternalDocumentList> => ({
224+
documents: [parent(cursor ? 'p2' : 'p1')],
225+
hasMore: !cursor,
226+
nextCursor: cursor ? undefined : 'next',
227+
})
228+
)
229+
const first = await listConfluenceAttachments({ ...INPUT, listParents })
230+
const context: Record<string, unknown> = {}
231+
const last = await listConfluenceAttachments({
232+
...INPUT,
233+
listParents,
234+
cursor: first.nextCursor,
235+
syncContext: context,
236+
})
237+
expect(first.documents.map((doc) => doc.externalId)).toEqual(['p1'])
238+
expect(last.documents.map((doc) => doc.externalId)).toEqual(['p2'])
239+
expect(last.listingFailures).toEqual({
240+
count: 2,
241+
samples: ['p1', 'p2'].map((scope) => ({
242+
scope,
243+
operation: 'confluence.attachments.list',
244+
status: 401,
245+
reasons: ['attachment_scope_mismatch'],
246+
})),
247+
})
248+
expect(last.reconciliationSafe).toBe(false)
249+
expect(context.reconciliationUnsafe).toBe(true)
250+
})
251+
252+
/**
253+
* One page whose attachments the credential cannot open must not leave every pass incomplete,
254+
* which would keep the connector from ever finishing a sync. Its attachments were not listed,
255+
* so the page still withholds deletion of anything it did not list.
256+
*/
257+
it.each([403, 404])(
258+
'skips a parent whose attachments answer %s without failing the listing or deleting what it did not list',
220259
async (status) => {
221-
fetchMock.mockImplementation(async () =>
222-
status === 401
223-
? Response.json({ code: 401, message: 'Unauthorized; scope does not match' }, { status })
224-
: new Response(null, { status })
225-
)
260+
fetchMock.mockImplementation(async (input) => {
261+
const parentId = new URL(String(input)).pathname.split('/').at(-2) ?? ''
262+
if (parentId === 'p1') return new Response(null, { status })
263+
return Response.json({ results: [file({ id: `${parentId}-file`, pageId: parentId })] })
264+
})
226265
const listParents = vi.fn(
227-
async (cursor: string | undefined): Promise<ExternalDocumentList> => ({
228-
documents: [parent(cursor ? 'p2' : 'p1')],
229-
hasMore: !cursor,
230-
nextCursor: cursor ? undefined : 'next',
266+
async (): Promise<ExternalDocumentList> => ({
267+
documents: [parent('p1'), parent('p2')],
268+
hasMore: false,
231269
})
232270
)
233-
const first = await listConfluenceAttachments({ ...INPUT, listParents })
234271
const context: Record<string, unknown> = {}
235-
const last = await listConfluenceAttachments({
272+
const result = await listConfluenceAttachments({
236273
...INPUT,
237274
listParents,
238-
cursor: first.nextCursor,
239275
syncContext: context,
240276
})
241-
expect(first.documents.map((doc) => doc.externalId)).toEqual(['p1'])
242-
expect(last.documents.map((doc) => doc.externalId)).toEqual(['p2'])
243-
expect(last.listingFailures).toEqual({
244-
count: 2,
245-
samples: ['p1', 'p2'].map((scope) => ({
246-
scope,
247-
operation: 'confluence.attachments.list',
248-
status,
249-
reasons: [status === 401 ? 'attachment_scope_mismatch' : 'attachment_access_denied'],
250-
})),
251-
})
252-
expect(last.reconciliationSafe).toBe(false)
277+
expect(result.documents.map((doc) => doc.externalId)).toEqual([
278+
'p1',
279+
'p2',
280+
'attachment:page:p2:p2-file',
281+
])
282+
expect(result.hasMore).toBe(false)
283+
expect(result.listingFailures).toBeUndefined()
284+
expect(result.reconciliationSafe).toBe(false)
253285
expect(context.reconciliationUnsafe).toBe(true)
254286
}
255287
)

‎apps/sim/connectors/confluence/attachments.ts‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ export async function listConfluenceAttachments(
378378
): Promise<ExternalDocumentList> {
379379
const state = decodeCursor(input.cursor, input.syncContext?.totalDocsFetched)
380380
const documents: ExternalDocument[] = []
381+
let skippedUnreadableParent = false
381382
if (state.parents.length === 0 && !state.parentsDone) {
382383
const parentContext = { ...input.syncContext, totalDocsFetched: state.parentsListed }
383384
const page = await input.listParents(state.parentCursor, parentContext)
@@ -413,15 +414,30 @@ export async function listConfluenceAttachments(
413414
})
414415
}
415416
if (state.serverFailureStreak >= MAX_CONSECUTIVE_SERVER_FAILURES) throw listing.error
416-
state.failures ??= { count: 0, samples: [] }
417-
state.failures.count += 1
418-
if (state.failures.samples.length < MAX_LISTING_FAILURE_SAMPLES) {
419-
state.failures.samples.push({
420-
scope: parent.id,
421-
operation: 'confluence.attachments.list',
417+
if (listing.reason === 'attachment_access_denied') {
418+
/**
419+
* The parent was listed a moment ago, so a 403 or 404 on its attachments is that one
420+
* item's restriction, not a scope the source failed to list: skipping it leaves the pass
421+
* complete. Its unlisted attachments are not evidence of deletion, so the page still
422+
* marks the listing unsafe and reconciliation keeps them.
423+
*/
424+
logger.warn('Skipping a Confluence parent whose attachments are not readable', {
425+
parentId: parent.id,
426+
parentType: parent.type,
422427
status: listing.status,
423-
reasons: [listing.reason],
424428
})
429+
skippedUnreadableParent = true
430+
} else {
431+
state.failures ??= { count: 0, samples: [] }
432+
state.failures.count += 1
433+
if (state.failures.samples.length < MAX_LISTING_FAILURE_SAMPLES) {
434+
state.failures.samples.push({
435+
scope: parent.id,
436+
operation: 'confluence.attachments.list',
437+
status: listing.status,
438+
reasons: [listing.reason],
439+
})
440+
}
425441
}
426442
state.parents.shift()
427443
state.attachmentCursor = undefined
@@ -453,7 +469,8 @@ export async function listConfluenceAttachments(
453469
state.attachmentCursor = nextCursor
454470
if (!nextCursor) state.parents.shift()
455471
}
456-
if (state.failures && input.syncContext) input.syncContext.reconciliationUnsafe = true
472+
const reconciliationUnsafe = Boolean(state.failures) || skippedUnreadableParent
473+
if (reconciliationUnsafe && input.syncContext) input.syncContext.reconciliationUnsafe = true
457474
const hasMore = state.parents.length > 0 || !state.parentsDone
458475
const nextCursor = hasMore ? CURSOR_PREFIX + JSON.stringify(state) : undefined
459476
if (nextCursor && Buffer.byteLength(nextCursor, 'utf8') > MAX_CURSOR_BYTES) {
@@ -463,7 +480,8 @@ export async function listConfluenceAttachments(
463480
documents,
464481
hasMore,
465482
nextCursor,
466-
...(state.failures ? { listingFailures: state.failures, reconciliationSafe: false } : {}),
483+
...(state.failures ? { listingFailures: state.failures } : {}),
484+
...(reconciliationUnsafe ? { reconciliationSafe: false } : {}),
467485
}
468486
}
469487

0 commit comments

Comments
 (0)