Skip to content

Commit d6203c3

Browse files
committed
improvement(search): bound indexing and simplify source recovery
1 parent b3eb2ca commit d6203c3

80 files changed

Lines changed: 30793 additions & 731 deletions

File tree

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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,16 @@ jobs:
153153
lib/table/rows/secret-provenance.postgres.test.ts
154154
lib/memory/message-provenance.postgres.test.ts
155155
156+
- name: Verify Search dispatch, progress, and pagination in PostgreSQL
157+
working-directory: apps/sim
158+
env:
159+
KNOWLEDGE_ACL_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
160+
run: >-
161+
bunx vitest run --mode integration
162+
lib/knowledge/__integration__/document-dispatch.integration.ts
163+
lib/knowledge/__integration__/search-source-progress.integration.ts
164+
lib/knowledge/__integration__/search-source-pagination.integration.ts
165+
156166
test-build:
157167
name: Lint and Test
158168
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }}

‎apps/docs/content/docs/platform/self-hosting/background-jobs.mdx‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Scheduled workflows, polling triggers, and the cron endpoints that
55

66
import { Callout } from 'fumadocs-ui/components/callout'
77

8-
A large part of Sim runs on a schedule rather than in response to a user request: scheduled workflows, every polling trigger, connector syncs, the outbox, data drains, and retention. All of it is driven by **HTTP endpoints that something external must call on a timer**.
8+
A large part of Sim runs on a schedule rather than in response to a user request: scheduled workflows, every polling trigger, connector syncs, the outbox, data drains, and retention. The default self-hosted scheduler calls **HTTP endpoints on a timer**. Deployments using Trigger.dev also run the document-indexing tasks described below.
99

1010
Both deployments ship a scheduler and enable it by default: Kubernetes as CronJobs, Docker Compose as a `cron` service. Both authenticate with `CRON_SECRET`, and both use the same schedules.
1111

@@ -146,3 +146,16 @@ Scheduled execution volume is bounded per app instance by:
146146
</Callout>
147147

148148
Raise the schedule limit only alongside memory headroom: concurrent executions run in the app process, so throughput is bounded by the pod's memory before it is bounded by this number.
149+
150+
## Trigger.dev document indexing
151+
152+
If Trigger.dev is enabled, document indexing uses a durable dispatch queue. Large imports wait in the database, and organizations share the existing worker budget. Temporary provider-capacity waits retry later without keeping a worker occupied. Default self-hosted installs continue to process documents in the app.
153+
154+
When upgrading a deployment that uses Trigger.dev:
155+
156+
1. Apply the database migrations before starting the new workers or application.
157+
2. Deploy the Trigger tasks from the same revision before deploying the application. New workers accept existing queued jobs as well as the new compact document references.
158+
3. Confirm the application and workers use the same database and Trigger environment. Verify that the `knowledge-document-dispatch-schedule` task is enabled; it runs every minute to resume delayed work and recover missed dispatch notifications.
159+
4. Sync a small source and confirm its documents become searchable. A successful sync only confirms the source was fetched; indexing can still be running.
160+
161+
Enqueue and completion notifications normally start the next work promptly. The minute schedule provides recovery when a notification is missed; it does not repeatedly download documents. Keep the updated workers running until their durable queue has drained before rolling them back.

‎apps/docs/content/docs/search/index.mdx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ Workspace Search remains separate. Workspace admins add sources through **Search
9595
3. Ask a teammate with different source access to repeat the search. Documents restricted to you should not appear for them.
9696
4. Change or remove a test document's access in the source and check again after the next completed content and permission refresh.
9797

98-
Search runs background syncs on an hourly schedule. Large sources, provider limits, and indexing queues can delay completion. Results are indexed copies, so edits and access changes are not fetched live for every query. Admins can inspect errors and progress under **Manage**.
98+
Search runs background syncs on an hourly schedule. Large sources, provider limits, and indexing queues can delay completion. Results are indexed copies, so edits and access changes are not fetched live for every query.
99+
100+
## If indexing needs attention
101+
102+
A completed sync means the source was checked; some documents may still be indexing. The source row shows how many documents you can search and whether indexing failed for any documents you can access.
103+
104+
As an admin, open **Manage → Settings → Documents → Failed** to inspect those files. Select **Retry indexing** beside a file to try again. **Exclude** removes a file from search; use the **Excluded** tab and **Restore** to include it again. Fix a disconnected account or source configuration before retrying a sync that needs attention.
105+
106+
Temporary indexing-capacity waits retry automatically in the background. If automatic retries are exhausted, the document appears as failed so an admin can retry it. Other searchable documents remain available while this work finishes.
99107

100108
These guides cover permission-aware Search sources. For a general knowledge base used by workflows, see [Knowledge-base connectors](/knowledgebase/connectors); its workspace access settings are a separate choice.

‎apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ export const GET = defineInternalJsonRoute({
2525
knowledgeBaseId: params.id,
2626
connectorId: params.connectorId,
2727
includeExcluded: query.includeExcluded,
28+
failedOnly: query.failedOnly,
2829
limit: query.limit,
2930
offset: query.offset,
3031
}),
3132
useCase: listKnowledgeConnectorDocuments,
32-
present: ({ documents, counts }) => ({
33+
present: ({ documents, counts, hasMore }) => ({
3334
success: true as const,
3435
data: {
3536
documents: documents.map((document) => ({
@@ -38,6 +39,7 @@ export const GET = defineInternalJsonRoute({
3839
uploadedAt: document.uploadedAt.toISOString(),
3940
})),
4041
counts,
42+
hasMore,
4143
},
4244
}),
4345
})

‎apps/sim/app/api/knowledge/migrated-routes.test.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,8 @@ describe('migrated internal Knowledge routes', () => {
299299
processingStatus: 'completed',
300300
},
301301
],
302-
counts: { active: 1, excluded: 0 },
302+
counts: { active: 1, excluded: 0, failed: 0 },
303+
hasMore: false,
303304
})
304305
const params = Promise.resolve({ id: 'knowledge-1', connectorId: 'connector-1' })
305306
const listResponse = await listConnectorDocuments(
@@ -314,12 +315,18 @@ describe('migrated internal Knowledge routes', () => {
314315
documents: [
315316
expect.objectContaining({ id: 'document-1', uploadedAt: '2026-01-01T00:00:00.000Z' }),
316317
],
317-
counts: { active: 1, excluded: 0 },
318+
counts: { active: 1, excluded: 0, failed: 0 },
319+
hasMore: false,
318320
},
319321
})
320322
expect(mocks.listConnectorDocuments).toHaveBeenLastCalledWith(
321323
expect.objectContaining({
322-
input: expect.objectContaining({ includeExcluded: true, limit: 25, offset: 50 }),
324+
input: expect.objectContaining({
325+
includeExcluded: true,
326+
failedOnly: false,
327+
limit: 25,
328+
offset: 50,
329+
}),
323330
})
324331
)
325332

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { readSearchSourceOverviewContract } from '@/lib/api/contracts/knowledge/connectors'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
8+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
9+
import { readSearchSourceOverview } from '@/lib/knowledge/application/search-source-overview'
10+
11+
export const GET = defineInternalJsonRoute({
12+
contract: readSearchSourceOverviewContract,
13+
auth: internalSessionAuth,
14+
operation: knowledgeOperations.readSearchSourceOverview,
15+
rateLimit: internalRateLimits.none({
16+
reason: 'Bounded provider existence probes for source setup and indexing progress',
17+
}),
18+
errorPolicy: internalKnowledgeErrorPolicies.connectors,
19+
mapInput: ({ query }) => query,
20+
useCase: readSearchSourceOverview,
21+
present: (overview) => ({ success: true as const, data: overview }),
22+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
23+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { readSearchSourceProgressContract } from '@/lib/api/contracts/knowledge/connectors'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
8+
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
9+
import { readSearchSourceProgress } from '@/lib/knowledge/application/search-source-progress'
10+
11+
export const POST = defineInternalJsonRoute({
12+
contract: readSearchSourceProgressContract,
13+
auth: internalSessionAuth,
14+
operation: knowledgeOperations.readSearchSourceProgress,
15+
rateLimit: internalRateLimits.none({
16+
reason: 'Bounded viewer-authorized indexing progress polling',
17+
}),
18+
errorPolicy: internalKnowledgeErrorPolicies.connectors,
19+
mapInput: ({ body }) => body,
20+
useCase: readSearchSourceProgress,
21+
present: ({ sources }) => ({ success: true as const, data: sources }),
22+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
23+
})

‎apps/sim/app/api/knowledge/sim-search/sources/route.test.ts‎

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { authMockFns, createMockRequest } from '@sim/testing'
33
import { beforeEach, describe, expect, it, vi } from 'vitest'
44

5-
const mocks = vi.hoisted(() => ({ execute: vi.fn(), connect: vi.fn() }))
5+
const mocks = vi.hoisted(() => ({ execute: vi.fn(), connect: vi.fn(), overview: vi.fn() }))
66
vi.mock('@/lib/knowledge/application/sim-search', () => ({
77
connectSimSearchConnector: {
88
operation: { id: 'knowledge.simSearch.connect' },
@@ -12,6 +12,12 @@ vi.mock('@/lib/knowledge/application/sim-search', () => ({
1212
vi.mock('@/lib/knowledge/application/search-sources', () => ({
1313
listSearchSources: { operation: { id: 'knowledge.search.sources.list' }, execute: mocks.execute },
1414
}))
15+
vi.mock('@/lib/knowledge/application/search-source-overview', () => ({
16+
readSearchSourceOverview: {
17+
operation: { id: 'knowledge.search.sources.overview' },
18+
execute: mocks.overview,
19+
},
20+
}))
1521
vi.mock('@/lib/knowledge/application/search', () => ({
1622
KnowledgeSearchProvenanceUnavailableError: class extends Error {},
1723
}))
@@ -21,6 +27,7 @@ vi.mock('@/lib/knowledge/application/upload-sessions', () => ({
2127

2228
import { NoWorkspaceAccessError } from '@/lib/core/application/workspace-authorization'
2329
import { POST as connectSource } from '@/app/api/knowledge/sim-search/connect/route'
30+
import { GET as getOverview } from '@/app/api/knowledge/sim-search/sources/overview/route'
2431
import { GET } from '@/app/api/knowledge/sim-search/sources/route'
2532

2633
const WORKSPACE_ID = '7d28e5e2-fb03-4118-9c52-4ab77ccff369'
@@ -36,6 +43,7 @@ const source = {
3643
lastSyncAt: null,
3744
hasSyncError: false,
3845
viewerDocumentCount: 0,
46+
viewerFailedDocumentCount: 0,
3947
viewerEmailVerified: true,
4048
connectionRequired: false,
4149
viewerMembership: null,
@@ -47,7 +55,7 @@ beforeEach(() => {
4755
user: { id: 'reader' },
4856
session: { id: 'session' },
4957
})
50-
mocks.execute.mockResolvedValue({ sources: [source] })
58+
mocks.execute.mockResolvedValue({ sources: [source], nextCursor: null })
5159
})
5260

5361
describe('GET Search sources', () => {
@@ -93,6 +101,7 @@ describe('GET Search sources', () => {
93101

94102
it('passes the authenticated subject into the registered operation and projects only the contract fields', async () => {
95103
mocks.execute.mockResolvedValue({
104+
nextCursor: null,
96105
sources: [
97106
{
98107
...source,
@@ -113,8 +122,8 @@ describe('GET Search sources', () => {
113122
expect(response.status).toBe(200)
114123
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
115124
const body = await response.json()
116-
expect(body).toMatchObject({ success: true, data: [source] })
117-
expect(body.data[0]).toEqual(source)
125+
expect(body).toMatchObject({ success: true, data: { sources: [source], nextCursor: null } })
126+
expect(body.data.sources[0]).toEqual(source)
118127
expect(mocks.execute).toHaveBeenCalledWith(
119128
expect.objectContaining({
120129
principal: { kind: 'session', userId: 'reader', sessionId: 'session' },
@@ -153,3 +162,73 @@ describe('GET Search sources', () => {
153162
expect(body).not.toHaveProperty('data')
154163
})
155164
})
165+
166+
describe('Search pagination boundary', () => {
167+
it('forwards bounded source filters and opaque cursors to the authorized operation', async () => {
168+
const response = await GET(
169+
createMockRequest(
170+
'GET',
171+
undefined,
172+
{},
173+
`http://localhost/api/knowledge/sim-search/sources?workspaceId=${WORKSPACE_ID}&search=Handbook&mine=true&cursor=opaque`
174+
)
175+
)
176+
expect(response.status).toBe(200)
177+
expect(mocks.execute).toHaveBeenCalledWith(
178+
expect.objectContaining({
179+
input: { workspaceId: WORKSPACE_ID, search: 'Handbook', mine: true, cursor: 'opaque' },
180+
})
181+
)
182+
})
183+
it.each([`search=${'x'.repeat(201)}`, `cursor=${'x'.repeat(1025)}`])(
184+
'rejects an oversized filter or cursor before source reads',
185+
async (filter) => {
186+
const response = await GET(
187+
createMockRequest(
188+
'GET',
189+
undefined,
190+
{},
191+
`http://localhost/api/knowledge/sim-search/sources?workspaceId=${WORKSPACE_ID}&${filter}`
192+
)
193+
)
194+
expect(response.status).toBe(400)
195+
expect(mocks.execute).not.toHaveBeenCalled()
196+
}
197+
)
198+
it('serves only the bounded provider overview through the registered operation', async () => {
199+
mocks.overview.mockResolvedValue({
200+
providers: [{ connectorType: 'google_drive', isSyncing: true }],
201+
hasSearchableDocuments: false,
202+
credentials: 'private',
203+
})
204+
const response = await getOverview(
205+
createMockRequest(
206+
'GET',
207+
undefined,
208+
{},
209+
`http://localhost/api/knowledge/sim-search/sources/overview?workspaceId=${WORKSPACE_ID}`
210+
)
211+
)
212+
expect(response.status).toBe(200)
213+
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
214+
expect(await response.json()).toEqual({
215+
success: true,
216+
data: {
217+
providers: [{ connectorType: 'google_drive', isSyncing: true }],
218+
hasSearchableDocuments: false,
219+
},
220+
})
221+
expect(mocks.overview).toHaveBeenCalledWith(
222+
expect.objectContaining({
223+
principal: { kind: 'session', userId: 'reader', sessionId: 'session' },
224+
input: { workspaceId: WORKSPACE_ID },
225+
})
226+
)
227+
})
228+
it('authenticates before parsing the overview scope', async () => {
229+
authMockFns.mockGetSession.mockResolvedValue(null)
230+
const response = await getOverview(createMockRequest('GET'))
231+
expect(response.status).toBe(401)
232+
expect(mocks.overview).not.toHaveBeenCalled()
233+
})
234+
})

‎apps/sim/app/api/knowledge/sim-search/sources/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ export const GET = defineInternalJsonRoute({
1818
errorPolicy: internalKnowledgeErrorPolicies.connectors,
1919
mapInput: ({ query }) => query,
2020
useCase: listSearchSources,
21-
present: ({ sources }) => ({ success: true as const, data: sources }),
21+
present: (page) => ({ success: true as const, data: page }),
2222
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
2323
})

0 commit comments

Comments
 (0)