Skip to content

Commit fd91c62

Browse files
authored
perf(knowledge): lock the knowledge base row FOR NO KEY UPDATE on document writes (#8909)
* perf(knowledge): lock the knowledge base row FOR NO KEY UPDATE on document writes Document uploads, document hard deletes, connector creation, and knowledge base restore locked the parent knowledge_base row FOR UPDATE. Every embedding insert takes FOR KEY SHARE on that row through its foreign key and holds it for the whole indexing transaction, and FOR UPDATE conflicts with KEY SHARE, so these writes queued behind any indexing pass in the same knowledge base (and vice versa). A hard delete could also deadlock against an indexing pass on one of its own documents. FOR NO KEY UPDATE still conflicts with itself, FOR SHARE (connector sync saves and completion), FOR UPDATE (KB moves, tag mutations), and the UPDATE/DELETE of the row, so every serialization these locks provided is kept. No path under these locks writes knowledge_base.id, the only key column (the other unique indexes are partial). This matches soft deletion and connector cleanup, which already use FOR NO KEY UPDATE. * test(knowledge): uploads and hard deletes commit while an embedding holds the KB key-share lock
1 parent 5551eb6 commit fd91c62

4 files changed

Lines changed: 89 additions & 6 deletions

File tree

‎apps/sim/lib/knowledge/__integration__/workspace-import.integration.ts‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
workspace,
1818
workspaceFiles,
1919
} from '@sim/db/schema'
20+
import { createDeferred } from '@sim/testing/helpers/deferred'
2021
import { generateId } from '@sim/utils/id'
2122
import { and, eq, inArray, sql } from 'drizzle-orm'
2223
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
@@ -50,7 +51,11 @@ import { listKnowledgeChunks } from '@/lib/knowledge/application/chunks'
5051
import { searchKnowledge } from '@/lib/knowledge/application/search'
5152
import { KNOWLEDGE_DOCUMENT_PROCESSING_OUTBOX_EVENT } from '@/lib/knowledge/documents/processing-events'
5253
import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler'
53-
import { createDocumentRecords, createSingleDocument } from '@/lib/knowledge/documents/service'
54+
import {
55+
createDocumentRecords,
56+
createSingleDocument,
57+
hardDeleteDocuments,
58+
} from '@/lib/knowledge/documents/service'
5459
import { KNOWLEDGE_STORAGE_CLEANUP_EVENT } from '@/lib/knowledge/documents/storage-cleanup-event'
5560
import { uploadKnowledgeArtifact } from '@/lib/knowledge/documents/storage-upload'
5661
import {
@@ -88,6 +93,42 @@ async function sourceFile(
8893
)
8994
}
9095

96+
/**
97+
* Runs `work` while another transaction holds `FOR KEY SHARE` on the knowledge base row, as an
98+
* in-flight embedding insert does through its foreign key, and requires it to settle well before
99+
* that holder's idle timeout would release the lock.
100+
*/
101+
async function settlesWhileKeyShareHeld<T>(knowledgeBaseId: string, work: () => Promise<T>) {
102+
const release = createDeferred<void>()
103+
const acquired = createDeferred<void>()
104+
const holding = db.transaction(async (tx) => {
105+
await tx.execute(sql`SET LOCAL idle_in_transaction_session_timeout = '15s'`)
106+
await tx
107+
.select({ id: knowledgeBase.id })
108+
.from(knowledgeBase)
109+
.where(eq(knowledgeBase.id, knowledgeBaseId))
110+
.for('key share')
111+
acquired.resolve()
112+
await release.promise
113+
})
114+
await acquired.promise
115+
const running = Promise.allSettled([work()])
116+
let settled = false
117+
void running.then(() => {
118+
settled = true
119+
})
120+
try {
121+
await expect.poll(() => settled, { interval: 5, timeout: 5000 }).toBe(true)
122+
const [outcome] = await running
123+
if (outcome.status === 'rejected') throw outcome.reason
124+
return outcome.value
125+
} finally {
126+
release.resolve()
127+
await holding
128+
await running
129+
}
130+
}
131+
91132
/** Drains real audit writes before asserting persisted rows, including duplicates from one operation. */
92133
function observeAbsenceAudits(workspaceId: string) {
93134
let submitted = 0
@@ -229,6 +270,33 @@ describe('durable workspace file import', () => {
229270
}
230271
})
231272

273+
it.each(['single', 'bulk'] as const)(
274+
'%s admission and hard delete commit while an embedding holds the KB key-share lock',
275+
async (mode) => {
276+
const ids = await seed()
277+
const source = await sourceFile(ids, 'ordinary source bytes')
278+
const input = {
279+
filename: source.name,
280+
fileUrl: `/api/files/serve/${encodeURIComponent(source.key)}?context=workspace`,
281+
fileSize: source.size,
282+
mimeType: source.type,
283+
}
284+
const documentIds = await settlesWhileKeyShareHeld(ids.knowledgeBaseId, async () =>
285+
mode === 'single'
286+
? [(await createSingleDocument(input, ids.knowledgeBaseId, generateId(), ids.aliceId)).id]
287+
: (
288+
await createDocumentRecords([input], ids.knowledgeBaseId, generateId(), ids.aliceId)
289+
).map((created) => created.documentId)
290+
)
291+
expect(documentIds).toHaveLength(1)
292+
expect(
293+
await settlesWhileKeyShareHeld(ids.knowledgeBaseId, () =>
294+
hardDeleteDocuments(documentIds, generateId())
295+
)
296+
).toBe(1)
297+
}
298+
)
299+
232300
it('indexes the admitted snapshot after the source was deleted and temporary access would have expired', async () => {
233301
const ids = await seed()
234302
const content =

‎apps/sim/lib/knowledge/documents/service.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2546,7 +2546,15 @@ export async function createDocumentRecords(
25462546
const { returnData, storageNotification, unrecordedCount } = await db.transaction(async (tx) => {
25472547
let storageNotification: DocumentStorageNotification | null = null
25482548

2549-
await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`)
2549+
/**
2550+
* `FOR NO KEY UPDATE` still serializes this insert with other document writes,
2551+
* KB moves, and archive, but not with the `FOR KEY SHARE` that each embedding
2552+
* insert takes through its foreign key, so uploads never queue behind a
2553+
* long-running indexing transaction in the same knowledge base.
2554+
*/
2555+
await tx.execute(
2556+
sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR NO KEY UPDATE`
2557+
)
25502558

25512559
const kb = await tx
25522560
.select({
@@ -3229,7 +3237,9 @@ export async function createSingleDocument(
32293237
await claimKnowledgeUploadForAttachment(tx, options.uploadedArtifact.cleanupEventId)
32303238
}
32313239

3232-
await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`)
3240+
await tx.execute(
3241+
sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR NO KEY UPDATE`
3242+
)
32333243

32343244
const kb = await tx
32353245
.select({
@@ -4332,6 +4342,9 @@ async function hardDeleteDocumentBatch(
43324342
* Lock every parent KB in stable ID order before deleting document rows.
43334343
* Normal inserts and KB moves take the same parent lock first, so the
43344344
* workspace snapshots used for accounting cannot change mid-delete.
4345+
* `no key update` lets embedding inserts keep taking their foreign-key
4346+
* `KEY SHARE` lock, so a delete neither waits on indexing elsewhere in the
4347+
* KB nor deadlocks against an indexing pass on one of its own documents.
43354348
*/
43364349
const knowledgeBaseIds = [
43374350
...new Set(documentsToDelete.map((doc) => doc.knowledgeBaseId)),
@@ -4351,7 +4364,7 @@ async function hardDeleteDocumentBatch(
43514364
)
43524365
)
43534366
.orderBy(asc(knowledgeBase.id))
4354-
.for('update')
4367+
.for('no key update')
43554368
const lockedKnowledgeBaseById = new Map(lockedKnowledgeBases.map((kb) => [kb.id, kb]))
43564369
for (const doc of documentsToDelete) {
43574370
const lockedKb = lockedKnowledgeBaseById.get(doc.knowledgeBaseId)

‎apps/sim/lib/knowledge/orchestration/connectors.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ export async function performCreateKnowledgeConnector(
471471
try {
472472
created = await db.transaction(async (tx) => {
473473
if (owner.organizationId) await lockOrganizationSearchApproval(tx, owner.organizationId)
474-
await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${kb.id} FOR UPDATE`)
474+
await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${kb.id} FOR NO KEY UPDATE`)
475475

476476
const activeKb = await tx
477477
.select({ id: knowledgeBase.id })

‎apps/sim/lib/knowledge/service.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1136,7 +1136,9 @@ export async function restoreKnowledgeBase(
11361136
try {
11371137
await db.transaction(async (tx) => {
11381138
if (kb.organizationId) await lockOrganizationSearchApproval(tx, kb.organizationId)
1139-
await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`)
1139+
await tx.execute(
1140+
sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR NO KEY UPDATE`
1141+
)
11401142

11411143
attemptedRestoreName = await generateRestoreName(kb.name, async (candidate) => {
11421144
if (!kb.workspaceId) return false

0 commit comments

Comments
 (0)