Skip to content

Commit 5bb486a

Browse files
committed
fix(knowledge): keep a failed dispatch from stopping a live import, audit each import once, and close review gaps
- a dispatch never settles the import itself; a retry or the stale-import janitor does - projected audit entries take an idempotency key, recorded through recordAuditOnce, so a retried completion audits the import exactly once - a cancel that races completion stops the record the completion wrote - refuse empty rebuilt chunk-only documents; knowledge_base_import.user_id references the user
1 parent 7fb91e3 commit 5bb486a

8 files changed

Lines changed: 86 additions & 57 deletions

File tree

‎apps/sim/lib/core/application/authorized-workspace-use-case.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import { type AuditActionType, type AuditResourceTypeValue, recordAudit } from '@sim/audit'
1+
import {
2+
type AuditActionType,
3+
type AuditResourceTypeValue,
4+
recordAudit,
5+
recordAuditOnce,
6+
} from '@sim/audit'
27
import type { Principal, PrincipalAuditAttribution } from '@sim/auth/principal'
38
import { resolvePrincipalAuditAttribution } from '@sim/auth/principal'
9+
import { createLogger } from '@sim/logger'
410
import { runDeclaredContentOperation } from '@/lib/core/application/content-delivery'
511
import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application/operation'
612
import {
@@ -18,6 +24,8 @@ import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
1824
import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types'
1925
import type { ResourcePolicyBinding } from '@/lib/resource-policies/registry'
2026

27+
const logger = createLogger('WorkspaceUseCaseAudit')
28+
2129
export interface WorkspaceUseCaseAuditEntry {
2230
/** Canonical affected workspace; null keeps an organization event outside the authorization workspace. */
2331
workspaceId?: string | null
@@ -27,6 +35,11 @@ export interface WorkspaceUseCaseAuditEntry {
2735
resourceName?: string
2836
description?: string
2937
metadata?: Record<string, unknown>
38+
/**
39+
* Records the entry at most once for this key, for an operation a caller may
40+
* retry after losing its response: each retry projects the same entry again.
41+
*/
42+
idempotencyKey?: string
3043
}
3144

3245
export interface AuthorizedWorkspaceUseCaseContext<
@@ -106,8 +119,8 @@ export function recordProjectedUseCaseAuditEntries(
106119
organizationId?: string
107120
): void {
108121
const attribution: PrincipalAuditAttribution = resolvePrincipalAuditAttribution(principal)
109-
for (const entry of entries) {
110-
recordAudit({
122+
for (const { idempotencyKey, ...entry } of entries) {
123+
const params = {
111124
workspaceId: entry.workspaceId === undefined ? workspaceId : entry.workspaceId,
112125
actorId: attribution.actorId,
113126
actorName: attribution.actorName,
@@ -123,6 +136,13 @@ export function recordProjectedUseCaseAuditEntries(
123136
actor: attribution.actor,
124137
},
125138
request,
139+
}
140+
if (!idempotencyKey) {
141+
recordAudit(params)
142+
continue
143+
}
144+
recordAuditOnce(idempotencyKey, params).catch((error) => {
145+
logger.error('Failed to record audit log', { error, action: params.action })
126146
})
127147
}
128148
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,19 @@ describe('knowledge base import requests', () => {
661661
.from(knowledgeBase)
662662
.where(eq(knowledgeBase.name, 'Redispatched import'))
663663
).toEqual([{ id: created.import.id }])
664+
await expect
665+
.poll(async () =>
666+
db
667+
.select({ resourceId: auditLog.resourceId })
668+
.from(auditLog)
669+
.where(
670+
and(
671+
eq(auditLog.workspaceId, ids.workspaceId),
672+
eq(auditLog.resourceId, created.import.id)
673+
)
674+
)
675+
)
676+
.toEqual([{ resourceId: created.import.id }])
664677
})
665678

666679
it('refuses a bundle that is not a zip archive before creating an upload', async () => {

‎apps/sim/lib/knowledge/application/imports.ts‎

Lines changed: 28 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import {
2424
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
2525
import {
2626
findKnowledgeImport,
27-
finishKnowledgeImport,
2827
getKnowledgeImport,
2928
insertKnowledgeImport,
3029
KNOWLEDGE_IMPORT_STORAGE_CONTEXT,
@@ -37,7 +36,6 @@ import {
3736
runKnowledgeImport,
3837
} from '@/lib/knowledge/transfer/import-runner'
3938
import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions'
40-
import { deleteFile } from '@/lib/uploads/core/storage-service'
4139
import { requestOrigin } from '@/lib/uploads/upload-session/application'
4240
import {
4341
abortUploadSession,
@@ -101,11 +99,6 @@ interface KnowledgeImportResult {
10199
import: KnowledgeImportResource
102100
}
103101

104-
interface CompleteKnowledgeImportResult extends KnowledgeImportResult {
105-
/** The base this completion started importing, or `null` when the import was already started. */
106-
startedKnowledgeBaseId: string | null
107-
}
108-
109102
interface KnowledgeImportRecordContext extends KnowledgeWorkspaceContext {
110103
record: KnowledgeImportRecord
111104
}
@@ -270,33 +263,13 @@ function startKnowledgeImport(payload: KnowledgeImportPayload): Promise<void> {
270263
})
271264
}
272265

273-
/** Starts the runner; a dispatch that fails settles the import and drops its bundle. */
274-
async function dispatchKnowledgeImport(payload: KnowledgeImportPayload): Promise<void> {
275-
try {
276-
await startKnowledgeImport(payload)
277-
} catch (error) {
278-
await finishKnowledgeImport(payload.importId, {
279-
status: 'failed',
280-
error: 'The import could not be started',
281-
})
282-
await deleteFile({ key: payload.sourceKey, context: KNOWLEDGE_IMPORT_STORAGE_CONTEXT }).catch(
283-
(cleanupError: unknown) => {
284-
logger.error('Failed to delete a knowledge bundle after dispatch failure', {
285-
importId: payload.importId,
286-
error: getErrorMessage(cleanupError, 'Unknown error'),
287-
})
288-
}
289-
)
290-
throw error
291-
}
292-
}
293-
294266
/**
295267
* Dispatches again an import whose record no worker has claimed: its first
296-
* dispatch was lost with the request that wrote the record, or is still
297-
* queued. Either way a second run is safe, since only one worker can claim it,
298-
* and a failure here leaves the import to the stale-import janitor rather than
299-
* failing one that may already be on its way.
268+
* dispatch failed or was lost with the request that wrote the record, or is
269+
* still queued. Either way a second run is safe, since only one worker can
270+
* claim it. A dispatch never settles the import itself, since another one may
271+
* already be on its way; one that never runs is failed by the stale-import
272+
* janitor, which also deletes its bundle.
300273
*/
301274
async function redispatchUnclaimedImport(
302275
principal: Principal,
@@ -399,11 +372,11 @@ export const completeKnowledgeImport = defineAuthorizedKnowledgeUseCase({
399372
principal: Principal
400373
input: KnowledgeImportUploadInput
401374
}) => resolveUploadContext(principal, input),
402-
async execute({ principal, context }): Promise<CompleteKnowledgeImportResult> {
375+
async execute({ principal, context }): Promise<KnowledgeImportResult> {
403376
const existing = await findStartedImport(context)
404377
if (existing) {
405378
await redispatchUnclaimedImport(principal, context, existing)
406-
return { import: fromRecord(existing), startedKnowledgeBaseId: null }
379+
return { import: fromRecord(existing) }
407380
}
408381
let started: KnowledgeImportPayload | null
409382
try {
@@ -427,11 +400,11 @@ export const completeKnowledgeImport = defineAuthorizedKnowledgeUseCase({
427400
// A concurrent completion holds the session; report the import it started, if any.
428401
const raced =
429402
asOrchestrationError(error)?.code === 'conflict' ? await findStartedImport(context) : null
430-
if (raced) return { import: fromRecord(raced), startedKnowledgeBaseId: null }
403+
if (raced) return { import: fromRecord(raced) }
431404
throw error
432405
}
433406
if (started) {
434-
await dispatchKnowledgeImport(started)
407+
await startKnowledgeImport(started)
435408
logger.info('Started knowledge import', {
436409
importId: started.importId,
437410
workspaceId: context.workspaceId,
@@ -442,20 +415,21 @@ export const completeKnowledgeImport = defineAuthorizedKnowledgeUseCase({
442415
importId: context.upload.id,
443416
assertedWorkspaceId: context.workspaceId,
444417
})
445-
return { import: fromRecord(record), startedKnowledgeBaseId: started?.importId ?? null }
418+
return { import: fromRecord(record) }
446419
},
447-
/** Recorded once per import: a repeated completion only reads back the one already started. */
448-
projectAudit: ({ context, result }) =>
449-
result.startedKnowledgeBaseId
450-
? {
451-
action: AuditAction.KNOWLEDGE_BASE_IMPORTED,
452-
resourceType: AuditResourceType.KNOWLEDGE_BASE,
453-
resourceId: result.startedKnowledgeBaseId,
454-
resourceName: importedName(context.upload) ?? context.upload.fileName,
455-
description: `Imported a knowledge base from bundle "${context.upload.fileName}"`,
456-
metadata: { workspaceId: context.workspaceId, importId: result.import.id },
457-
}
458-
: [],
420+
/**
421+
* Keyed by the import, so the completion that started it and any retry of it
422+
* record one event between them. The base takes the import's id.
423+
*/
424+
projectAudit: ({ context, result }) => ({
425+
action: AuditAction.KNOWLEDGE_BASE_IMPORTED,
426+
resourceType: AuditResourceType.KNOWLEDGE_BASE,
427+
resourceId: result.import.id,
428+
resourceName: importedName(context.upload) ?? context.upload.fileName,
429+
description: `Imported a knowledge base from bundle "${context.upload.fileName}"`,
430+
metadata: { workspaceId: context.workspaceId, importId: result.import.id },
431+
idempotencyKey: `knowledge-import:${result.import.id}`,
432+
}),
459433
})
460434

461435
/**
@@ -473,6 +447,10 @@ export const cancelKnowledgeImport = defineAuthorizedKnowledgeUseCase({
473447
const started = await findStartedImport(context)
474448
if (started) return { import: fromRecord(await stopKnowledgeImport(started)) }
475449
assertUploadSessionAuthBinding(context.upload, principal)
476-
return { import: fromUpload(await abortUploadSession(context.upload), context.workspaceId) }
450+
const aborted = await abortUploadSession(context.upload)
451+
// A completion finalizing alongside this abort may have written the record first.
452+
const late = await findStartedImport(context)
453+
if (late) return { import: fromRecord(await stopKnowledgeImport(late)) }
454+
return { import: fromUpload(aborted, context.workspaceId) }
477455
},
478456
})

‎apps/sim/lib/knowledge/transfer/import-runner.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ import {
4040
} from '@/lib/knowledge/transfer/import-jobs'
4141
import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/file-manager'
4242
import { deleteFile, downloadFileStream } from '@/lib/uploads/core/storage-service'
43-
import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types'
43+
import {
44+
EMPTY_KNOWLEDGE_DOCUMENT_MESSAGE,
45+
MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE,
46+
} from '@/lib/uploads/shared/types'
4447
import { getFileExtension } from '@/lib/uploads/utils/file'
4548
import { isSupportedExtension, validateFileType } from '@/lib/uploads/utils/validation'
4649

@@ -166,6 +169,9 @@ async function importDocument(
166169
}
167170
} else if (prepared) {
168171
const bytes = Buffer.from(prepared.chunks.map((chunk) => chunk.text).join('\n\n'))
172+
if (bytes.byteLength === 0) {
173+
throw new OrchestrationError('validation', EMPTY_KNOWLEDGE_DOCUMENT_MESSAGE)
174+
}
169175
if (bytes.byteLength > MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE) {
170176
throw new OrchestrationError(
171177
'payload_too_large',

‎packages/db/migrations/0436_knowledge_base_import.sql‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ CREATE TABLE "knowledge_base_import" (
1919
);
2020
--> statement-breakpoint
2121
ALTER TABLE "knowledge_base_import" ADD CONSTRAINT "knowledge_base_import_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
22+
ALTER TABLE "knowledge_base_import" ADD CONSTRAINT "knowledge_base_import_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
2223
ALTER TABLE "knowledge_base_import" ADD CONSTRAINT "knowledge_base_import_knowledge_base_id_knowledge_base_id_fk" FOREIGN KEY ("knowledge_base_id") REFERENCES "public"."knowledge_base"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
2324
CREATE INDEX "knowledge_base_import_watchdog_idx" ON "knowledge_base_import" USING btree ("status","updated_at");--> statement-breakpoint
2425
CREATE INDEX "knowledge_base_import_knowledge_base_id_idx" ON "knowledge_base_import" USING btree ("knowledge_base_id");

‎packages/db/migrations/meta/0436_snapshot.json‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"id": "eabb7aa2-0cfb-4872-aa0c-38733757019b",
2+
"id": "a7a19a4e-8207-4fbf-918c-f5ae171bd9cc",
33
"prevId": "2a4095de-e341-4264-becd-1714d26404f5",
44
"version": "7",
55
"dialect": "postgresql",
@@ -10223,6 +10223,15 @@
1022310223
"onDelete": "cascade",
1022410224
"onUpdate": "no action"
1022510225
},
10226+
"knowledge_base_import_user_id_user_id_fk": {
10227+
"name": "knowledge_base_import_user_id_user_id_fk",
10228+
"tableFrom": "knowledge_base_import",
10229+
"tableTo": "user",
10230+
"columnsFrom": ["user_id"],
10231+
"columnsTo": ["id"],
10232+
"onDelete": "cascade",
10233+
"onUpdate": "no action"
10234+
},
1022610235
"knowledge_base_import_knowledge_base_id_knowledge_base_id_fk": {
1022710236
"name": "knowledge_base_import_knowledge_base_id_knowledge_base_id_fk",
1022810237
"tableFrom": "knowledge_base_import",

‎packages/db/migrations/meta/_journal.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3050,7 +3050,7 @@
30503050
{
30513051
"idx": 436,
30523052
"version": "7",
3053-
"when": 1792719631197,
3053+
"when": 1792720602032,
30543054
"tag": "0436_knowledge_base_import",
30553055
"breakpoints": true
30563056
}

‎packages/db/schema.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3865,7 +3865,9 @@ export const knowledgeBaseImport = pgTable(
38653865
workspaceId: text('workspace_id')
38663866
.notNull()
38673867
.references(() => workspace.id, { onDelete: 'cascade' }),
3868-
userId: text('user_id').notNull(),
3868+
userId: text('user_id')
3869+
.notNull()
3870+
.references(() => user.id, { onDelete: 'cascade' }),
38693871
/** The uploaded archive, deleted once the import settles. */
38703872
sourceKey: text('source_key').notNull(),
38713873
knowledgeBaseId: text('knowledge_base_id').references(() => knowledgeBase.id, {

0 commit comments

Comments
 (0)