Skip to content

Commit 8cb378f

Browse files
committed
fix(knowledge): release kept connector documents in the background so removing a large source cannot time out
1 parent 666b4d3 commit 8cb378f

26 files changed

Lines changed: 29268 additions & 236 deletions

apps/sim/ee/workspace-forking/lib/mapping/cascade.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,8 @@ export async function detectForkCascadeReferences(params: {
165165
inArray(knowledgeConnector.knowledgeBaseId, Array.from(knowledgeBaseIds)),
166166
eq(knowledgeBase.workspaceId, sourceWorkspaceId),
167167
isNull(knowledgeBase.deletedAt),
168-
isNull(knowledgeConnector.deletedAt)
168+
isNull(knowledgeConnector.deletedAt),
169+
isNull(knowledgeConnector.detachedAt)
169170
)
170171
)
171172
for (const connector of connectors) {

apps/sim/lib/billing/storage/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export {
1212
applyStorageUsageDeltasInTx,
1313
checkAndIncrementStorageUsageInTx,
1414
decrementStorageUsageForBillingContextInTx,
15+
incrementAdmittedStorageUsageForBillingContextInTx,
1516
incrementStorageUsageForBillingContextInTx,
1617
type LegacyStorageUsageDelta,
1718
maybeNotifyStorageLimitForBillingContext,

apps/sim/lib/billing/storage/tracking.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,29 @@ export async function incrementStorageUsageForBillingContextInTx(
590590
return result.updatedUsage
591591
}
592592

593+
/**
594+
* Increments one workspace and its current payer for bytes whose admission was
595+
* already decided, such as documents a connector removal accepted and a
596+
* background job now releases page by page. Never refuses: a page that could
597+
* cross the limit after admission would otherwise leave the release half done.
598+
*/
599+
export async function incrementAdmittedStorageUsageForBillingContextInTx(
600+
tx: DbOrTx,
601+
context: StorageBillingContext,
602+
bytes: number
603+
): Promise<number | undefined> {
604+
if (bytes <= 0) return undefined
605+
const result = await mutateWorkspaceStorageUsage(
606+
tx,
607+
context.workspaceId,
608+
bytes,
609+
'increment',
610+
undefined,
611+
context
612+
)
613+
return result.updatedUsage
614+
}
615+
593616
/**
594617
* Atomically check quota and increment a user's (or their org's) storage
595618
* counter inside an existing transaction, using a pre-resolved subscription.

apps/sim/lib/copilot/chat/workspace-context.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
getAccessibleOAuthCredentials,
2525
} from '@/lib/credentials/environment'
2626
import { listWorkspaceSandboxes } from '@/lib/execution/remote-sandbox/workspace-sandboxes'
27+
import { connectorIsLive } from '@/lib/knowledge/connectors/sync-lock'
2728
import { listCustomBlockSummariesForWorkspace } from '@/lib/workflows/custom-blocks/operations'
2829
import { listCustomTools } from '@/lib/workflows/custom-tools/operations'
2930
import { listSkillsForUser } from '@/lib/workflows/skills/operations'
@@ -457,13 +458,7 @@ async function buildWorkspaceMdData(
457458
connectorType: knowledgeConnector.connectorType,
458459
})
459460
.from(knowledgeConnector)
460-
.where(
461-
and(
462-
inArray(knowledgeConnector.knowledgeBaseId, kbIds),
463-
isNull(knowledgeConnector.archivedAt),
464-
isNull(knowledgeConnector.deletedAt)
465-
)
466-
)
461+
.where(and(inArray(knowledgeConnector.knowledgeBaseId, kbIds), connectorIsLive()))
467462
: []
468463
const connectorTypesByKb = new Map<string, string[]>()
469464
for (const row of connectorRows) {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { db } from '@sim/db'
2+
import { outboxEvent } from '@sim/db/schema'
3+
import { and, eq, sql } from 'drizzle-orm'
4+
import { expect } from 'vitest'
5+
import { processOutboxEventById } from '@/lib/core/outbox/service'
6+
import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler'
7+
8+
/** Runs a connector's queued removal event until it completes, as the outbox worker would. */
9+
export async function drainConnectorEvent(connectorId: string, eventType: string): Promise<void> {
10+
const [job] = await db
11+
.select()
12+
.from(outboxEvent)
13+
.where(
14+
and(
15+
eq(outboxEvent.eventType, eventType),
16+
sql`${outboxEvent.payload}->>'connectorId' = ${connectorId}`
17+
)
18+
)
19+
.limit(1)
20+
expect(job).toBeDefined()
21+
let status = await processOutboxEventById(job.id, knowledgeDocumentProcessingOutboxHandlers)
22+
for (let attempt = 0; status === 'pending' && attempt < 20; attempt++) {
23+
await db.update(outboxEvent).set({ availableAt: new Date() }).where(eq(outboxEvent.id, job.id))
24+
status = await processOutboxEventById(job.id, knowledgeDocumentProcessingOutboxHandlers)
25+
}
26+
expect(status).toBe('completed')
27+
}

apps/sim/lib/knowledge/__integration__/search-source-progress.integration.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { and, eq, inArray } from 'drizzle-orm'
1414
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
1515
import type { ConnectorDocumentFilter } from '@/lib/api/contracts/knowledge/connectors'
1616
import * as embeddings from '@/lib/embeddings'
17+
import { drainConnectorEvent } from '@/lib/knowledge/__integration__/drain-connector-event'
1718
import {
1819
createKnowledgeAclFixtureIds,
1920
seedKnowledgeAclFixture,
@@ -29,6 +30,7 @@ import {
2930
} from '@/lib/knowledge/application/documents'
3031
import { readSearchSourceProgress } from '@/lib/knowledge/application/search-source-progress'
3132
import { listSearchSources } from '@/lib/knowledge/application/search-sources'
33+
import { KNOWLEDGE_CONNECTOR_DETACH_EVENT } from '@/lib/knowledge/connectors/detachment'
3234
import { createContentSyncLease } from '@/lib/knowledge/connectors/sync-lock'
3335
import { persistSkippedDocuments } from '@/lib/knowledge/connectors/sync-persistence'
3436
import * as documentProcessor from '@/lib/knowledge/documents/document-processor'
@@ -630,6 +632,7 @@ describe('intentional skips and genuine failures across document reads', () => {
630632
input: { ...scope, deleteDocuments: false },
631633
})
632634
expect(result).toMatchObject({ documentsDeleted: 0, documentsKept: 6 })
635+
await drainConnectorEvent(fixture.connectorId, KNOWLEDGE_CONNECTOR_DETACH_EVENT)
633636
const retained = await db
634637
.select()
635638
.from(document)

apps/sim/lib/knowledge/__integration__/storage-accounting.integration.ts

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { db } from '@sim/db'
66
import {
77
document,
88
embedding,
9+
embeddingSearch,
910
knowledgeBase,
1011
knowledgeConnector,
1112
organization,
@@ -14,19 +15,19 @@ import {
1415
workspace,
1516
} from '@sim/db/schema'
1617
import { generateId } from '@sim/utils/id'
17-
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
18+
import { and, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
1819
import { afterAll, describe, expect, it, vi } from 'vitest'
19-
import { processOutboxEventById } from '@/lib/core/outbox/service'
20+
import { drainConnectorEvent } from '@/lib/knowledge/__integration__/drain-connector-event'
2021
import {
2122
createKnowledgeAclFixtureIds,
2223
seedKnowledgeAclFixture,
2324
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
2425
import { WORKSPACE_ACCESS_SCOPE } from '@/lib/knowledge/access/scope'
2526
import { SYSTEM_ACCESS_SCOPE } from '@/lib/knowledge/access/types'
2627
import { KNOWLEDGE_CONNECTOR_CLEANUP_EVENT } from '@/lib/knowledge/connectors/deletion'
28+
import { KNOWLEDGE_CONNECTOR_DETACH_EVENT } from '@/lib/knowledge/connectors/detachment'
2729
import { createContentSyncLease, SyncLockLostException } from '@/lib/knowledge/connectors/sync-lock'
2830
import { persistSkippedDocuments } from '@/lib/knowledge/connectors/sync-persistence'
29-
import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler'
3031
import {
3132
createSingleDocument,
3233
getKnowledgeDocument,
@@ -104,13 +105,23 @@ function disconnect(ids: Fixture, deleteDocuments = false) {
104105
})
105106
}
106107

108+
/** Removes the source keeping its documents, then runs the background release to completion. */
109+
async function detach(ids: Fixture) {
110+
const outcome = await disconnect(ids)
111+
if (outcome.success) await drainConnectorEvent(ids.connectorId, KNOWLEDGE_CONNECTOR_DETACH_EVENT)
112+
return outcome
113+
}
114+
107115
afterAll(async () => {
108116
for (const ids of fixtures) {
109117
await db
110118
.delete(outboxEvent)
111119
.where(
112120
and(
113-
eq(outboxEvent.eventType, KNOWLEDGE_CONNECTOR_CLEANUP_EVENT),
121+
inArray(outboxEvent.eventType, [
122+
KNOWLEDGE_CONNECTOR_CLEANUP_EVENT,
123+
KNOWLEDGE_CONNECTOR_DETACH_EVENT,
124+
]),
114125
sql`${outboxEvent.payload}->>'knowledgeBaseId' = ${ids.knowledgeBaseId}`
115126
)
116127
)
@@ -130,7 +141,7 @@ describe('knowledge document storage ledgers', () => {
130141
const transaction = db.transaction.bind(db)
131142
/** Interleave real operations at the lock boundary; every query and commit still uses PostgreSQL. */
132143
const detachBeforeLock: typeof db.transaction = async (callback, config) => {
133-
expect(await disconnect(ids)).toMatchObject({ success: true })
144+
expect(await detach(ids)).toMatchObject({ success: true })
134145
expect(await ledger(ids)).toEqual({ workspaceBytes: 37, payerBytes: 37 })
135146
return transaction(callback, config)
136147
}
@@ -167,7 +178,19 @@ describe('knowledge document storage ledgers', () => {
167178
{ success: true, documentsKept: 6, documentsDeleted: 0 },
168179
])
169180
expect(outcomes.filter((result) => !result.success)).toHaveLength(1)
181+
/** Detached documents stay attached, readable, and unbilled until the release runs. */
182+
expect(await ledger(ids)).toEqual({ workspaceBytes: 29, payerBytes: 29 })
183+
expect(
184+
await getKnowledgeDocument(ids.knowledgeBaseId, source[0].id, WORKSPACE_ACCESS_SCOPE)
185+
).not.toBeNull()
186+
await drainConnectorEvent(ids.connectorId, KNOWLEDGE_CONNECTOR_DETACH_EVENT)
170187
expect(await ledger(ids)).toEqual({ workspaceBytes: 70, payerBytes: 70 })
188+
expect(
189+
await db
190+
.select({ id: knowledgeConnector.id })
191+
.from(knowledgeConnector)
192+
.where(eq(knowledgeConnector.id, ids.connectorId))
193+
).toHaveLength(0)
171194
const retained = await db
172195
.select({ id: document.id, connectorId: document.connectorId, deletedAt: document.deletedAt })
173196
.from(document)
@@ -197,6 +220,43 @@ describe('knowledge document storage ledgers', () => {
197220
expect(await ledger(ids)).toEqual({ workspaceBytes: 0, payerBytes: 0 })
198221
})
199222

223+
it('releases a document larger than one page without a search row still naming its source', async () => {
224+
const ids = await seed()
225+
const row = sourceDocument(ids, 5)
226+
await db.insert(document).values(row)
227+
await db.insert(embedding).values(
228+
Array.from({ length: 600 }, (_, chunkIndex) => ({
229+
id: generateId(),
230+
knowledgeBaseId: ids.knowledgeBaseId,
231+
documentId: row.id,
232+
chunkIndex,
233+
chunkHash: `hash-${chunkIndex}`,
234+
content: 'test chunk',
235+
contentLength: 10,
236+
tokenCount: 2,
237+
startOffset: 0,
238+
endOffset: 10,
239+
embedding384: Array(384).fill(0.1),
240+
}))
241+
)
242+
const sourceRows = () =>
243+
db
244+
.select({ count: sql<number>`COUNT(*)::integer` })
245+
.from(embeddingSearch)
246+
.where(and(eq(embeddingSearch.documentId, row.id), isNotNull(embeddingSearch.connectorId)))
247+
expect((await sourceRows())[0].count).toBe(600)
248+
249+
expect(await detach(ids)).toEqual({ success: true, documentsKept: 1, documentsDeleted: 0 })
250+
251+
expect((await sourceRows())[0].count).toBe(0)
252+
const [released] = await db
253+
.select({ connectorId: document.connectorId })
254+
.from(document)
255+
.where(eq(document.id, row.id))
256+
expect(released.connectorId).toBeNull()
257+
expect(await ledger(ids)).toEqual({ workspaceBytes: 5, payerBytes: 5 })
258+
})
259+
200260
it('hides a source immediately and cleans bounded batches without debiting manual storage', async () => {
201261
const ids = await seed()
202262
await manualDocument(ids, 31)
@@ -240,17 +300,6 @@ describe('knowledge document storage ledgers', () => {
240300
for (const access of [SYSTEM_ACCESS_SCOPE, WORKSPACE_ACCESS_SCOPE]) {
241301
expect(await getKnowledgeDocument(ids.knowledgeBaseId, rows[0].id, access)).toBeNull()
242302
}
243-
const [job] = await db
244-
.select()
245-
.from(outboxEvent)
246-
.where(
247-
and(
248-
eq(outboxEvent.eventType, KNOWLEDGE_CONNECTOR_CLEANUP_EVENT),
249-
sql`${outboxEvent.payload}->>'connectorId' = ${ids.connectorId}`
250-
)
251-
)
252-
.limit(1)
253-
expect(job).toBeDefined()
254303
await expect(
255304
persistSkippedDocuments(
256305
ids.knowledgeBaseId,
@@ -274,17 +323,7 @@ describe('knowledge document storage ledgers', () => {
274323
createContentSyncLease(ids.connectorId, ids.lockId)
275324
)
276325
).rejects.toBeInstanceOf(SyncLockLostException)
277-
const handlers = knowledgeDocumentProcessingOutboxHandlers
278-
let status = await processOutboxEventById(job.id, handlers)
279-
expect(status).toBe('pending')
280-
for (let attempt = 0; status === 'pending' && attempt < 5; attempt++) {
281-
await db
282-
.update(outboxEvent)
283-
.set({ availableAt: new Date() })
284-
.where(eq(outboxEvent.id, job.id))
285-
status = await processOutboxEventById(job.id, handlers)
286-
}
287-
expect(status).toBe('completed')
326+
await drainConnectorEvent(ids.connectorId, KNOWLEDGE_CONNECTOR_CLEANUP_EVENT)
288327
expect(
289328
await db
290329
.select({ id: knowledgeConnector.id })
@@ -398,7 +437,7 @@ describe('knowledge document storage ledgers', () => {
398437
it('serializes ordinary uploads against source detachment without losing either charge', async () => {
399438
const ids = await seed()
400439
await db.insert(document).values([sourceDocument(ids, 37)])
401-
const [detached, manual] = await Promise.all([disconnect(ids), manualDocument(ids, 41)])
440+
const [detached, manual] = await Promise.all([detach(ids), manualDocument(ids, 41)])
402441
expect(detached).toMatchObject({ success: true })
403442
expect(await ledger(ids)).toEqual({ workspaceBytes: 78, payerBytes: 78 })
404443
const [source] = await db

apps/sim/lib/knowledge/application/connectors.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import {
7474
readConnectorPermissionSummary,
7575
} from '@/lib/knowledge/connectors/permission-config.server'
7676
import { MEMBER_OBSERVATION_STALE_AFTER_HOURS } from '@/lib/knowledge/connectors/sync-limits'
77+
import { connectorIsLive } from '@/lib/knowledge/connectors/sync-lock'
7778
import {
7879
DEFAULT_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE,
7980
MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_MUTATION_ITEMS,
@@ -525,11 +526,7 @@ export const listKnowledgeConnectors = defineAuthorizedKnowledgeUseCase({
525526
.select()
526527
.from(knowledgeConnector)
527528
.where(
528-
and(
529-
eq(knowledgeConnector.knowledgeBaseId, context.knowledgeBaseId),
530-
isNull(knowledgeConnector.archivedAt),
531-
isNull(knowledgeConnector.deletedAt)
532-
)
529+
and(eq(knowledgeConnector.knowledgeBaseId, context.knowledgeBaseId), connectorIsLive())
533530
)
534531
.orderBy(sortOrder(sortColumn), sortOrder(knowledgeConnector.id))
535532
const offset = input.offset ?? 0

apps/sim/lib/knowledge/connectors/deletion.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
All existing UI, API, and Copilot callers still enter `knowledge.connectors.delete` through its authorized application use case. Roles, scope checks, response shapes, audit attribution, and the workspace-only option to retain documents are unchanged.
44

5-
When documents are removed, a transaction locks the canonical knowledge base and connector, counts the attached documents, marks the connector deleted, invalidates both sync leases, disables scheduling, and inserts one `knowledge.connector.cleanup` outbox event. Failure rolls back both the deletion and the event. Returned deletion counts describe documents logically removed from Sim; physical deletion follows asynchronously. The keep-documents path still performs its storage quota check and detachment atomically.
5+
When documents are removed, a transaction locks the canonical knowledge base and connector, counts the attached documents, marks the connector deleted, invalidates both sync leases, disables scheduling, and inserts one `knowledge.connector.cleanup` outbox event. Failure rolls back both the deletion and the event. Returned deletion counts describe documents logically removed from Sim; physical deletion follows asynchronously.
66

77
The shared document access predicate excludes deleted connectors, including public and workspace access and internal indexing reads. Ingestion also checks connector liveness before claiming or committing document processing. Existing source-write leases reject late sync writes. Documents keep their connector reference until physical deletion, so they cannot become standalone readable or billable documents during cleanup. Restoring a knowledge base does not restore a directly deleted connector.
88

9+
When documents are kept, the same transaction admits their storage against the payer's quota, sets the connector's `detached_at` instead of `deleted_at`, invalidates both sync leases, disables scheduling, and inserts one `knowledge.connector.detach` outbox event. The request writes no document: releasing a document nulls its `connector_id`, and the projection trigger then rewrites every enabled chunk of it in both search projections, each a fresh index entry, so an in-request release timed out on any sizeable source. A detached connector fails `connectorIsLive()`, so it cannot be synced, managed, or listed, while document visibility checks only `archived_at` and `deleted_at`, so its documents stay readable throughout. The detach worker releases at most 250 projection rows per table of the next 100 documents per transaction and flips those documents, resurrecting live tombstones and zeroing legacy skipped sizes, once none of their rows still names the connector; the trigger then has nothing to rewrite. Each flipped page is billed in its own transaction, so a document is billable exactly when it no longer names a connector. When no document remains, the worker drains the connector's history and member rows and deletes it.
10+
911
The existing outbox worker runs cleanup, with 48 failure attempts and bounded continuations that do not consume that retry budget. Each transaction removes at most 1,000 chunks, 250 documents, or 1,000 sync-history/member rows. A run does at most four batches and yields after its time budget. Transactions use lock and statement timeouts. The worker verifies the connector's deletion timestamp, locks documents against late indexing commits, and commits storage cleanup intents before deleting those documents. It resolves storage ownership from the currently locked knowledge base. Already committed batches survive worker restarts. Failures remain retryable; exhausted events remain as dead letters while the source stays inaccessible.
1012

1113
After document and connector cleanup, credential grant revocation and unused-tag cleanup are retried as needed. Tag cleanup checks for existence rather than counting the entire remaining corpus. No provider credentials or document contents enter the connector cleanup payload.

0 commit comments

Comments
 (0)