Skip to content

Commit dd8b159

Browse files
fix(knowledge): prevent KB deletion deadlocks (#7626)
1 parent 0270458 commit dd8b159

2 files changed

Lines changed: 213 additions & 1 deletion

File tree

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/** Real PostgreSQL coverage for soft deletion racing with document publication. */
2+
import { db } from '@sim/db'
3+
import {
4+
document,
5+
embedding,
6+
knowledgeBase,
7+
knowledgeConnector,
8+
organization,
9+
user,
10+
workspace,
11+
} from '@sim/db/schema'
12+
import { generateId } from '@sim/utils/id'
13+
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
14+
import { afterEach, describe, expect, it } from 'vitest'
15+
import {
16+
createKnowledgeAclFixtureIds,
17+
seedKnowledgeAclFixture,
18+
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
19+
import { deleteKnowledgeBase } from '@/lib/knowledge/service'
20+
21+
function deferred<T>() {
22+
let resolve!: (value: T) => void
23+
let reject!: (error: unknown) => void
24+
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
25+
resolve = resolvePromise
26+
reject = rejectPromise
27+
})
28+
return { promise, resolve, reject }
29+
}
30+
31+
async function blockedBackend(blockingPid: number) {
32+
const [row] = await db.execute<{ pid: number }>(sql`
33+
SELECT pid FROM pg_stat_activity
34+
WHERE ${blockingPid} = ANY(pg_blocking_pids(pid)) AND wait_event_type = 'Lock'
35+
LIMIT 1
36+
`)
37+
return row?.pid
38+
}
39+
40+
describe('knowledge base deletion in PostgreSQL', () => {
41+
const fixtures: ReturnType<typeof createKnowledgeAclFixtureIds>[] = []
42+
43+
async function seed() {
44+
const ids = createKnowledgeAclFixtureIds()
45+
fixtures.push(ids)
46+
await seedKnowledgeAclFixture(ids)
47+
const documentId = generateId()
48+
await db.insert(document).values({
49+
id: documentId,
50+
knowledgeBaseId: ids.knowledgeBaseId,
51+
filename: 'fixture.txt',
52+
fileUrl: 'data:text/plain,fixture',
53+
fileSize: 7,
54+
mimeType: 'text/plain',
55+
processingStatus: 'processing',
56+
})
57+
return { ...ids, documentId }
58+
}
59+
60+
afterEach(async () => {
61+
for (const ids of fixtures.splice(0)) {
62+
await db.delete(workspace).where(eq(workspace.id, ids.workspaceId))
63+
await db.delete(organization).where(eq(organization.id, ids.organizationId))
64+
await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId]))
65+
}
66+
})
67+
68+
it('lets in-flight embeddings commit before archiving children and serializes competing deletes', async () => {
69+
const ids = await seed()
70+
const other = await seed()
71+
const archivedAt = new Date('2025-01-01T00:00:00.000Z')
72+
const documentLocked = deferred<number>()
73+
const publish = deferred<void>()
74+
75+
/** Match processing's document lock followed by the embedding foreign-key checks. */
76+
const processing = db.transaction(async (tx) => {
77+
await tx.execute(sql`SET LOCAL statement_timeout = '10s'`)
78+
const [backend] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`)
79+
const active = await tx
80+
.select({ id: document.id })
81+
.from(document)
82+
.innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id))
83+
.where(
84+
and(
85+
eq(document.id, ids.documentId),
86+
eq(document.processingStatus, 'processing'),
87+
isNull(document.archivedAt),
88+
isNull(document.deletedAt),
89+
isNull(knowledgeBase.deletedAt)
90+
)
91+
)
92+
.for('update', { of: document })
93+
.limit(1)
94+
expect(active).toEqual([{ id: ids.documentId }])
95+
documentLocked.resolve(backend.pid)
96+
await publish.promise
97+
await tx.insert(embedding).values({
98+
id: generateId(),
99+
knowledgeBaseId: ids.knowledgeBaseId,
100+
documentId: ids.documentId,
101+
chunkIndex: 0,
102+
chunkHash: 'fixture',
103+
content: 'fixture',
104+
contentLength: 7,
105+
tokenCount: 1,
106+
startOffset: 0,
107+
endOffset: 7,
108+
embedding: Array.from({ length: 1536 }, () => 0.01),
109+
})
110+
await tx
111+
.update(document)
112+
.set({ processingStatus: 'completed', chunkCount: 1 })
113+
.where(eq(document.id, ids.documentId))
114+
})
115+
const processingResult = Promise.allSettled([processing])
116+
void processing.catch(documentLocked.reject)
117+
let deletions: Promise<PromiseSettledResult<void>[]> | undefined
118+
119+
try {
120+
const processingPid = await documentLocked.promise
121+
const deletion = deleteKnowledgeBase(ids.knowledgeBaseId, 'delete-fixture', {
122+
archivedAt,
123+
assertedWorkspaceId: ids.workspaceId,
124+
})
125+
deletions = Promise.allSettled([deletion])
126+
127+
/** Observe the actual lock wait instead of relying on transaction scheduling delays. */
128+
await expect.poll(() => blockedBackend(processingPid), { timeout: 5000 }).toBeDefined()
129+
const deletionPid = await blockedBackend(processingPid)
130+
expect(deletionPid).toBeDefined()
131+
132+
const competingDeletion = deleteKnowledgeBase(ids.knowledgeBaseId, 'competing-delete', {
133+
archivedAt: new Date(archivedAt.getTime() + 1000),
134+
assertedWorkspaceId: ids.workspaceId,
135+
})
136+
deletions = Promise.allSettled([deletion, competingDeletion])
137+
await expect.poll(() => blockedBackend(deletionPid!), { timeout: 5000 }).toBeDefined()
138+
139+
publish.resolve()
140+
expect(await processingResult).toEqual([{ status: 'fulfilled', value: undefined }])
141+
expect(await deletions).toMatchObject([
142+
{ status: 'fulfilled', value: undefined },
143+
{ status: 'rejected', reason: { code: 'not_found' } },
144+
])
145+
} finally {
146+
publish.resolve()
147+
await processingResult
148+
await deletions
149+
}
150+
151+
const [kb] = await db
152+
.select()
153+
.from(knowledgeBase)
154+
.where(eq(knowledgeBase.id, ids.knowledgeBaseId))
155+
const [doc] = await db.select().from(document).where(eq(document.id, ids.documentId))
156+
const [connector] = await db
157+
.select()
158+
.from(knowledgeConnector)
159+
.where(eq(knowledgeConnector.id, ids.connectorId))
160+
expect(kb).toMatchObject({ deletedAt: archivedAt, updatedAt: archivedAt })
161+
expect(doc).toMatchObject({
162+
archivedAt,
163+
deletedAt: null,
164+
processingStatus: 'completed',
165+
chunkCount: 1,
166+
})
167+
expect(connector).toMatchObject({ archivedAt, deletedAt: null, status: 'paused' })
168+
expect(
169+
await db
170+
.select({ id: embedding.id })
171+
.from(embedding)
172+
.where(eq(embedding.documentId, ids.documentId))
173+
).toHaveLength(1)
174+
const [otherDoc] = await db.select().from(document).where(eq(document.id, other.documentId))
175+
expect(otherDoc.archivedAt).toBeNull()
176+
})
177+
178+
it.each(['wrong workspace', 'search index'] as const)(
179+
'preserves the %s guard without archiving children',
180+
async (guard) => {
181+
const ids = await seed()
182+
if (guard === 'search index') {
183+
await db
184+
.update(knowledgeBase)
185+
.set({ isSearchIndex: true })
186+
.where(eq(knowledgeBase.id, ids.knowledgeBaseId))
187+
}
188+
await expect(
189+
deleteKnowledgeBase(ids.knowledgeBaseId, 'guard-fixture', {
190+
assertedWorkspaceId: guard === 'wrong workspace' ? generateId() : ids.workspaceId,
191+
})
192+
).rejects.toMatchObject({ code: guard === 'wrong workspace' ? 'not_found' : 'forbidden' })
193+
const [kb] = await db
194+
.select()
195+
.from(knowledgeBase)
196+
.where(eq(knowledgeBase.id, ids.knowledgeBaseId))
197+
const [doc] = await db.select().from(document).where(eq(document.id, ids.documentId))
198+
const [connector] = await db
199+
.select()
200+
.from(knowledgeConnector)
201+
.where(eq(knowledgeConnector.id, ids.connectorId))
202+
expect(kb.deletedAt).toBeNull()
203+
expect(doc.archivedAt).toBeNull()
204+
expect(connector.archivedAt).toBeNull()
205+
}
206+
)
207+
})

apps/sim/lib/knowledge/service.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1066,6 +1066,11 @@ export async function deleteKnowledgeBase(
10661066
const now = options?.archivedAt ?? new Date()
10671067

10681068
await db.transaction(async (tx) => {
1069+
/**
1070+
* Soft deletion leaves the referenced key intact. Allow embedding inserts to
1071+
* take their foreign-key KEY SHARE lock while holding a document row lock,
1072+
* so they can finish before we archive that document without a lock cycle.
1073+
*/
10691074
const [locked] = await tx
10701075
.select({
10711076
id: knowledgeBase.id,
@@ -1084,7 +1089,7 @@ export async function deleteKnowledgeBase(
10841089
)
10851090
)
10861091
.limit(1)
1087-
.for('update')
1092+
.for('no key update')
10881093
if (!locked) throw new KnowledgeBaseNotFoundError(knowledgeBaseId)
10891094
if (locked.isSearchIndex && !options?.allowSearchIndexDelete) {
10901095
throw new KnowledgeBasePermissionError('Only workspace admins can delete the search index')

0 commit comments

Comments
 (0)