Skip to content

Commit 0bc2d54

Browse files
authored
improvement(knowledge): instrument the search source overview and bound its probes (#7957)
* improvement(knowledge): instrument the search source overview and bound its probes The source overview backs the Sim Search indexing banner and source filter, but it emitted no telemetry, so a slow request could only be observed as a duration on the route. It now shares the search diagnostics trace, timing availability resolution, the provider read, and each per-batch indexing and searchable probe, and records how many access batches and live source proofs a request consumed. The access batch generator is instrumented in the same trace, separating the connector discovery query from the live source proof that reaches the provider over the network, so a slow batch says which half was slow for every caller. The searchable probe also stops repeating: one searchable document is the whole answer, yet the bounded existence query ran again for every remaining batch. * fix(knowledge): report the free first access predicate separately The batch counter started at one to include the ordinary predicate, which is yielded without a connector query or a live source proof, while the field documented every batch as costing both. The count now says what it measures, and it is recorded before the first yield so a caller that stops consuming there still reports the predicate it used.
1 parent d5ebecd commit 0bc2d54

6 files changed

Lines changed: 329 additions & 131 deletions

File tree

‎apps/sim/lib/knowledge/application/search-diagnostics.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AuthorizingUseCase } from '@/lib/core/application'
2+
import type { ResourceOwner } from '@/lib/core/resource-scope'
23
import type { knowledgeOperations } from '@/lib/knowledge/application/operations'
34
import type { SearchKnowledgeInput } from '@/lib/knowledge/application/search'
45
import {
@@ -43,3 +44,29 @@ export function instrumentSearchUseCase<
4344
),
4445
}
4546
}
47+
48+
/**
49+
* Times the source overview, whose cost is access batching and live source proof rather than
50+
* retrieval. It shares the search trace so one log line explains a slow Sim Search surface.
51+
*/
52+
export function instrumentSourceOverviewUseCase<I extends ResourceOwner, R>(
53+
useCase: AuthorizingUseCase<typeof knowledgeOperations.readSearchSourceOverview, I, R>
54+
): AuthorizingUseCase<typeof knowledgeOperations.readSearchSourceOverview, I, R> {
55+
return {
56+
...useCase,
57+
execute: (args) =>
58+
withSearchDiagnostics(
59+
{
60+
operation: 'read_search_source_overview',
61+
principalKind: args.principal.kind,
62+
/** Derived without `resourceScopeFromOwner`, which throws before authorization runs. */
63+
scopeKind: args.input.organizationId
64+
? 'organization'
65+
: args.input.workspaceId
66+
? 'workspace'
67+
: undefined,
68+
},
69+
() => measureSearchStage('source_overview', () => useCase.execute(args))
70+
),
71+
}
72+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/** @vitest-environment node */
2+
import { document, knowledgeConnector, member } from '@sim/db/schema'
3+
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
4+
import { sql } from 'drizzle-orm'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({
8+
context: vi.fn(),
9+
availability: vi.fn(),
10+
provider: vi.fn(),
11+
batches: vi.fn(),
12+
}))
13+
vi.mock('@/lib/knowledge/application/contexts', () => ({
14+
resolveKnowledgeOwnerContext: mocks.context,
15+
}))
16+
vi.mock('@/lib/knowledge/access/availability', () => ({
17+
resolveKnowledgeAccessAvailability: mocks.availability,
18+
}))
19+
vi.mock('@/lib/knowledge/access/scope', () => ({
20+
createKnowledgeAccessProvider: mocks.provider,
21+
}))
22+
vi.mock('@/lib/knowledge/read-access', () => ({
23+
knowledgeReadAccessBatches: mocks.batches,
24+
}))
25+
26+
import { readSearchSourceOverview } from '@/lib/knowledge/application/search-source-overview'
27+
28+
const principal = { kind: 'session', userId: 'reader', sessionId: 'session' } as const
29+
const input = { organizationId: 'org-1', workspaceId: null }
30+
31+
/** Authorization reads the caller's membership with its own single-row bound, before any probe. */
32+
const AUTHORIZATION_SINGLE_ROW_READS = 1
33+
34+
/** Every other single-row read in this use case is the searchable existence probe. */
35+
const searchableProbeCount = () =>
36+
dbChainMockFns.limit.mock.calls.filter(([rows]) => rows === 1).length -
37+
AUTHORIZATION_SINGLE_ROW_READS
38+
39+
function yieldBatches(count: number) {
40+
mocks.batches.mockImplementation(async function* () {
41+
for (let index = 0; index < count; index += 1) yield sql`batch-${sql.raw(String(index))}`
42+
})
43+
}
44+
45+
beforeEach(() => {
46+
vi.clearAllMocks()
47+
resetDbChainMock()
48+
mocks.context.mockResolvedValue(input)
49+
mocks.availability.mockResolvedValue({ memberScoped: true, sourceMirrored: false })
50+
mocks.provider.mockReturnValue({ get: async () => ({ kind: 'user' }) })
51+
})
52+
53+
describe('readSearchSourceOverview', () => {
54+
it('stops probing for a searchable document once one batch has found one', async () => {
55+
yieldBatches(3)
56+
queueTableRows(member, [{ role: 'owner' }])
57+
queueTableRows(knowledgeConnector, [{ connectorType: 'gmail' }])
58+
queueTableRows(document, [{ id: 'doc-1' }])
59+
60+
const result = await readSearchSourceOverview.execute({ principal, input })
61+
62+
expect(result.hasSearchableDocuments).toBe(true)
63+
expect(searchableProbeCount()).toBe(1)
64+
})
65+
66+
it('keeps probing every batch while no searchable document has been found', async () => {
67+
yieldBatches(3)
68+
queueTableRows(member, [{ role: 'owner' }])
69+
queueTableRows(knowledgeConnector, [{ connectorType: 'gmail' }])
70+
71+
const result = await readSearchSourceOverview.execute({ principal, input })
72+
73+
expect(result.hasSearchableDocuments).toBe(false)
74+
expect(searchableProbeCount()).toBe(3)
75+
})
76+
})

‎apps/sim/lib/knowledge/application/search-source-overview.ts‎

Lines changed: 133 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope'
99
import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case'
1010
import { resolveKnowledgeOwnerContext } from '@/lib/knowledge/application/contexts'
1111
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
12+
import { instrumentSourceOverviewUseCase } from '@/lib/knowledge/application/search-diagnostics'
1213
import { MAX_SEARCH_SOURCE_PROVIDER_TYPES } from '@/lib/knowledge/constants'
1314
import { knowledgeReadAccessBatches } from '@/lib/knowledge/read-access'
15+
import { annotateSearchDiagnostics, measureSearchStage } from '@/lib/knowledge/search/diagnostics'
1416
import { searchIntegrationAccessCondition } from '@/lib/knowledge/search/integration-policy'
1517
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
1618

@@ -54,120 +56,144 @@ export async function listConfiguredSearchProviderTypes(owner: ResourceOwner): P
5456
}
5557

5658
/** Provider-level existence probes keep setup cards independent of the loaded source pages. */
57-
export const readSearchSourceOverview = defineAuthorizedKnowledgeUseCase({
58-
operation: knowledgeOperations.readSearchSourceOverview,
59-
resolveContext: ({ input }: { input: ResourceOwner }) => resolveKnowledgeOwnerContext(input),
60-
async execute({ principal, context }): Promise<SearchSourceOverview> {
61-
const availability = await resolveKnowledgeAccessAvailability(context)
62-
const access = createKnowledgeAccessProvider(principal, context)
63-
const providerTypes = searchProviderTypes()
64-
const configured = configuredSearchSourceCondition(context, providerTypes)
65-
const available = or(
66-
availability.memberScoped ? eq(knowledgeConnector.accessMode, 'members') : undefined,
67-
availability.sourceMirrored
68-
? and(
69-
eq(knowledgeConnector.accessMode, 'admin'),
70-
inArray(
71-
knowledgeConnector.connectorType,
72-
providerTypes.filter(
73-
(type) =>
74-
availability.memberScoped || !CONNECTOR_META_REGISTRY[type].requiresMemberIdentity
59+
export const readSearchSourceOverview = instrumentSourceOverviewUseCase(
60+
defineAuthorizedKnowledgeUseCase({
61+
operation: knowledgeOperations.readSearchSourceOverview,
62+
resolveContext: ({ input }: { input: ResourceOwner }) => resolveKnowledgeOwnerContext(input),
63+
async execute({ principal, context }): Promise<SearchSourceOverview> {
64+
const availability = await measureSearchStage('source_overview.availability', () =>
65+
resolveKnowledgeAccessAvailability(context)
66+
)
67+
const access = createKnowledgeAccessProvider(principal, context)
68+
const providerTypes = searchProviderTypes()
69+
const configured = configuredSearchSourceCondition(context, providerTypes)
70+
const available = or(
71+
availability.memberScoped ? eq(knowledgeConnector.accessMode, 'members') : undefined,
72+
availability.sourceMirrored
73+
? and(
74+
eq(knowledgeConnector.accessMode, 'admin'),
75+
inArray(
76+
knowledgeConnector.connectorType,
77+
providerTypes.filter(
78+
(type) =>
79+
availability.memberScoped ||
80+
!CONNECTOR_META_REGISTRY[type].requiresMemberIdentity
81+
)
7582
)
7683
)
77-
)
78-
: undefined
79-
)
80-
const syncingEnabled = and(
81-
available,
82-
searchIntegrationAccessCondition(),
83-
notInArray(knowledgeConnector.status, ['paused', 'disabled']),
84-
or(
85-
eq(knowledgeConnector.accessMode, 'admin'),
86-
notInArray(knowledgeConnector.memberSyncStatus, ['disabled'])
84+
: undefined
85+
)
86+
const syncingEnabled = and(
87+
available,
88+
searchIntegrationAccessCondition(),
89+
notInArray(knowledgeConnector.status, ['paused', 'disabled']),
90+
or(
91+
eq(knowledgeConnector.accessMode, 'admin'),
92+
notInArray(knowledgeConnector.memberSyncStatus, ['disabled'])
93+
)
8794
)
88-
)
89-
const documentConditions = and(
90-
eq(document.connectorId, knowledgeConnector.id),
91-
eq(document.knowledgeBaseId, knowledgeConnector.knowledgeBaseId),
92-
eq(document.enabled, true),
93-
eq(document.userExcluded, false),
94-
isNull(document.archivedAt),
95-
isNull(document.deletedAt)
96-
)
97-
const providers = await configuredProvidersQuery()
98-
.where(configured)
99-
.limit(MAX_SEARCH_SOURCE_PROVIDER_TYPES)
100-
const indexingTypes = new Set<string>()
101-
let hasSearchableDocuments = false
102-
for await (const accessCondition of knowledgeReadAccessBatches(access, [
103-
configured,
104-
available,
105-
documentConditions,
106-
])) {
107-
const readableDocument = and(documentConditions, accessCondition)
108-
const [indexing, searchable] = await Promise.all([
109-
availability.memberScoped || availability.sourceMirrored
110-
? configuredProvidersQuery()
111-
.where(
112-
and(
113-
configured,
114-
syncingEnabled,
115-
or(
116-
inArray(knowledgeConnector.status, ['pending', 'syncing']),
117-
and(
118-
eq(knowledgeConnector.accessMode, 'members'),
119-
inArray(knowledgeConnector.memberSyncStatus, ['pending', 'running'])
120-
),
121-
exists(
122-
db
123-
.select({ id: document.id })
124-
.from(document)
125-
.where(
95+
const documentConditions = and(
96+
eq(document.connectorId, knowledgeConnector.id),
97+
eq(document.knowledgeBaseId, knowledgeConnector.knowledgeBaseId),
98+
eq(document.enabled, true),
99+
eq(document.userExcluded, false),
100+
isNull(document.archivedAt),
101+
isNull(document.deletedAt)
102+
)
103+
const providers = await measureSearchStage('source_overview.providers', () =>
104+
configuredProvidersQuery().where(configured).limit(MAX_SEARCH_SOURCE_PROVIDER_TYPES)
105+
)
106+
annotateSearchDiagnostics({ configuredProviderCount: providers.length })
107+
const indexingTypes = new Set<string>()
108+
let searchableProbes = 0
109+
let hasSearchableDocuments = false
110+
for await (const accessCondition of knowledgeReadAccessBatches(access, [
111+
configured,
112+
available,
113+
documentConditions,
114+
])) {
115+
const readableDocument = and(documentConditions, accessCondition)
116+
const probesSources: boolean = availability.memberScoped || availability.sourceMirrored
117+
/** One searchable document is the whole answer, so later batches skip the probe entirely. */
118+
const probesSearchable: boolean = probesSources && !hasSearchableDocuments
119+
if (probesSearchable) searchableProbes += 1
120+
/** Annotated so the searchable probe's guard does not infer through its own result. */
121+
const [indexing, searchable]: [{ connectorType: string }[], { id: string }[]] =
122+
await Promise.all([
123+
probesSources
124+
? measureSearchStage('source_overview.indexing', () =>
125+
configuredProvidersQuery()
126+
.where(
127+
and(
128+
configured,
129+
syncingEnabled,
130+
or(
131+
inArray(knowledgeConnector.status, ['pending', 'syncing']),
126132
and(
127-
readableDocument,
128-
inArray(document.processingStatus, ['pending', 'processing'])
133+
eq(knowledgeConnector.accessMode, 'members'),
134+
inArray(knowledgeConnector.memberSyncStatus, ['pending', 'running'])
135+
),
136+
exists(
137+
db
138+
.select({ id: document.id })
139+
.from(document)
140+
.where(
141+
and(
142+
readableDocument,
143+
inArray(document.processingStatus, ['pending', 'processing'])
144+
)
145+
)
129146
)
130147
)
148+
)
131149
)
132-
)
150+
.limit(MAX_SEARCH_SOURCE_PROVIDER_TYPES)
133151
)
134-
)
135-
.limit(MAX_SEARCH_SOURCE_PROVIDER_TYPES)
136-
: [],
137-
availability.memberScoped || availability.sourceMirrored
138-
? db
139-
.select({ id: document.id })
140-
.from(document)
141-
.innerJoin(knowledgeConnector, eq(knowledgeConnector.id, document.connectorId))
142-
.innerJoin(knowledgeBase, eq(knowledgeBase.id, knowledgeConnector.knowledgeBaseId))
143-
.where(
144-
and(
145-
configured,
146-
available,
147-
readableDocument,
148-
eq(document.processingStatus, 'completed'),
149-
exists(
150-
db
151-
.select({ id: embedding.id })
152-
.from(embedding)
153-
.where(
154-
and(eq(embedding.documentId, document.id), eq(embedding.enabled, true))
152+
: [],
153+
probesSearchable
154+
? measureSearchStage('source_overview.searchable', () =>
155+
db
156+
.select({ id: document.id })
157+
.from(document)
158+
.innerJoin(knowledgeConnector, eq(knowledgeConnector.id, document.connectorId))
159+
.innerJoin(
160+
knowledgeBase,
161+
eq(knowledgeBase.id, knowledgeConnector.knowledgeBaseId)
162+
)
163+
.where(
164+
and(
165+
configured,
166+
available,
167+
readableDocument,
168+
eq(document.processingStatus, 'completed'),
169+
exists(
170+
db
171+
.select({ id: embedding.id })
172+
.from(embedding)
173+
.where(
174+
and(
175+
eq(embedding.documentId, document.id),
176+
eq(embedding.enabled, true)
177+
)
178+
)
179+
)
155180
)
156-
)
181+
)
182+
.limit(1)
157183
)
158-
)
159-
.limit(1)
160-
: [],
161-
])
162-
for (const provider of indexing) indexingTypes.add(provider.connectorType)
163-
hasSearchableDocuments ||= searchable.length > 0
164-
}
165-
return {
166-
providers: providers.map(({ connectorType }) => ({
167-
connectorType,
168-
isSyncing: indexingTypes.has(connectorType),
169-
})),
170-
hasSearchableDocuments,
171-
}
172-
},
173-
})
184+
: [],
185+
])
186+
for (const provider of indexing) indexingTypes.add(provider.connectorType)
187+
hasSearchableDocuments ||= searchable.length > 0
188+
}
189+
annotateSearchDiagnostics({ searchableProbeCount: searchableProbes })
190+
return {
191+
providers: providers.map(({ connectorType }) => ({
192+
connectorType,
193+
isSyncing: indexingTypes.has(connectorType),
194+
})),
195+
hasSearchableDocuments,
196+
}
197+
},
198+
})
199+
)

0 commit comments

Comments
 (0)