diff --git a/knowledge-fs/apps/api/src/durable-deletion-options.test.ts b/knowledge-fs/apps/api/src/durable-deletion-options.test.ts index 5f58f86b86b..da420465234 100644 --- a/knowledge-fs/apps/api/src/durable-deletion-options.test.ts +++ b/knowledge-fs/apps/api/src/durable-deletion-options.test.ts @@ -10,6 +10,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { assertApiDurableDeletionDataReadiness, createApiDurableDeletionAssembly, + writeDurableDeletionErrorLog, } from "./durable-deletion-options"; type DatabaseExecuteInput = Parameters< @@ -137,6 +138,29 @@ describe("API durable deletion assembly", () => { expect(repository.claimJobs).toHaveBeenCalledTimes(1); expect(repository.claimOutbox).toHaveBeenCalledTimes(1); }); + + it("logs durable deletion failures with job correlation and the original error", () => { + const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + writeDurableDeletionErrorLog({ + error: new Error("derived data cleanup failed"), + job: { + checkpoint: "deleting_derived_data", + executionAttempts: 10, + id: "deletion-job-1", + knowledgeSpaceId: "knowledge-space-1", + runState: "failed", + targetId: "source-1", + targetType: "source", + } as never, + }); + + expect(write).toHaveBeenCalledOnce(); + expect(write.mock.calls[0]?.[0]).toContain('"event":"knowledge_fs.durable_deletion.failed"'); + expect(write.mock.calls[0]?.[0]).toContain('"jobId":"deletion-job-1"'); + expect(write.mock.calls[0]?.[0]).toContain('"checkpoint":"deleting_derived_data"'); + expect(write.mock.calls[0]?.[0]).toContain('"errorMessage":"derived data cleanup failed"'); + }); }); function secretStoreStub(): Pick { diff --git a/knowledge-fs/apps/api/src/durable-deletion-options.ts b/knowledge-fs/apps/api/src/durable-deletion-options.ts index fbc2253d312..3d104c798b8 100644 --- a/knowledge-fs/apps/api/src/durable-deletion-options.ts +++ b/knowledge-fs/apps/api/src/durable-deletion-options.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { + type DurableDeletionJob, type DurableDeletionOutboxDispatcher, type DurableDeletionRepository, type DurableDeletionRuntime, @@ -94,6 +95,7 @@ export function createApiDurableDeletionAssembly({ maxBatchSize: 10, maxRetryDelayMs: 5 * 60_000, maxStepsPerLease: 25, + onError: writeDurableDeletionErrorLog, processor, repository, stepTimeoutMs: 5_000, @@ -124,3 +126,29 @@ export function createApiDurableDeletionAssembly({ }, }; } + +export function writeDurableDeletionErrorLog(input: { + readonly error: unknown; + readonly job?: DurableDeletionJob | undefined; +}): void { + const error = input.error instanceof Error ? input.error : undefined; + process.stderr.write( + `${JSON.stringify({ + checkpoint: input.job?.checkpoint, + errorMessage: boundedLogValue(error?.message ?? String(input.error), 16_384), + errorName: error?.name ?? "UnknownError", + errorStack: error?.stack ? boundedLogValue(error.stack, 32_768) : undefined, + event: "knowledge_fs.durable_deletion.failed", + executionAttempts: input.job?.executionAttempts, + jobId: input.job?.id, + knowledgeSpaceId: input.job?.knowledgeSpaceId, + runState: input.job?.runState, + targetId: input.job?.targetId, + targetType: input.job?.targetType, + })}\n`, + ); +} + +function boundedLogValue(value: string, limit: number): string { + return value.length <= limit ? value : `${value.slice(0, limit)}...[truncated]`; +}