1
33import { Worker, type Job } from "bullmq";
34import type { Redis } from "ioredis";
35import { z } from "zod";
36import type { PrismaClient, File } from "@prisma/client";
37import { QUEUES } from "../../queues.js";
38import { getLogger } from "../../logger.js";
39import type { WorkerEnv } from "../../config.js";
40import type { S3Client } from "../../lib/s3.js";
41import { detectMimeType } from "@factory/storage";
42import { asJson } from "../../lib/prisma.js";
43import { withJobOtel } from "../../lib/job-otel.js";
44import { audit } from "../../lib/notify.js";
45import { assertTransition } from "./status.js";
46import { classifyError, FatalError, RetryableError } from "./errors.js";
47import { createTempFile, removeTempFile } from "./temp.js";
48import { scanStep, type ScanOutcome } from "./steps/scan.js";
49import { metadataStep } from "./steps/metadata.js";
50import { thumbnailsStep } from "./steps/thumbnails.js";
51import { finalizeStep, type FileRowLike } from "./steps/finalize.js";
52import type { FilePipelineContext, StepName, ThumbsResult } from "./types.js";
53
54const log = getLogger("files");
55
56
57export const processFileJobSchema = z.object({
58 fileId: z.string().min(1),
59 version: z.number().int().positive().default(1),
60 workspaceId: z.string().min(1).optional()
61});
62
63export type ProcessFileJob = z.infer<typeof processFileJobSchema>;
64
65
66export interface FilesPipelineDeps {
67 prisma: PrismaClient;
68 s3: S3Client;
69
70 s3Thumbs: S3Client;
71 redis: Redis;
72 env: WorkerEnv;
73}
74
75
76export type PipelineRunResult =
77 | { kind: "ready" }
78 | { kind: "virus" }
79 | { kind: "failed" }
80 | { kind: "noop"; reason: "missing_row" | "terminal" };
81
82
96export async function runFilesPipeline(
97 deps: FilesPipelineDeps,
98 data: ProcessFileJob
99): Promise<PipelineRunResult> {
100 const { prisma, s3, s3Thumbs, redis, env } = deps;
101 const started = Date.now();
102 const { fileId, version } = data;
103 log.info({ fileId, version }, "processing file");
104
105 const row = (await prisma.file.findUnique({ where: { id: fileId } })) as
106 | File
107 | null;
108 if (!row) {
109
110 log.warn({ fileId }, "file row missing — discarding");
111 return { kind: "noop", reason: "missing_row" };
112 }
113 const prevStatus = row.status;
114
115
116
117
118 if (prevStatus === "READY" || prevStatus === "VIRUS_DETECTED") {
119 log.info({ fileId, prevStatus }, "file terminal — no-op re-run");
120 return { kind: "noop", reason: "terminal" };
121 }
122
123 const ctx: FilePipelineContext = {
124 job: { fileId, version, workspaceId: data.workspaceId ?? row.workspaceId },
125 prisma,
126 s3,
127 s3Thumbs,
128 redis,
129 env,
130 temp: { path: "", size: 0 },
131 stepTimings: new Map<string, number>(),
132 warnings: [],
133 extracted: { sniffedMime: null },
134 sha256: ""
135 };
136 const mark = (name: StepName, ms: number): void => {
137 ctx.stepTimings.set(name, ms);
138 log.info({ fileId, step: name, ms }, `file:${fileId}:step:${name}`);
139 };
140
141
142
143 const guard = async (step: string, fn: () => Promise<void>): Promise<void> => {
144 const t0 = Date.now();
145 try {
146 await fn();
147 mark(step as StepName, Date.now() - t0);
148 } catch (err) {
149 const classified = classifyError(err);
150 if (classified instanceof FatalError) {
151 log.error(
152 { fileId, step, code: classified.code, err: classified.message },
153 "pipeline step failed FATAL — marking FAILED (no retry)"
154 );
155 await markFailed(ctx, row, `${step}: ${classified.message}`, classified.code);
156 throw new FatalHandledMarker(classified.code, step);
157 }
158 log.warn(
159 { fileId, step, err: classified.message },
160 "pipeline step failed RETRYABLE — backing off"
161 );
162 throw classified;
163 }
164 };
165
166 try {
167
168 if (prevStatus === "UPLOADING") {
169 assertTransition("UPLOADING", "SCANNING");
170 await prisma.file.update({ where: { id: fileId }, data: { status: "SCANNING" } });
171 }
172
173
174 await guard("download", async () => {
175 const path = await createTempFile(env, env.FILES_TEMP_FREE_MIN_BYTES);
176 const { size, sha256 } = await s3.downloadTo(row.key, path);
177 ctx.temp = { path, size };
178 ctx.sha256 = sha256;
179 });
180
181
182 let scan: ScanOutcome | undefined;
183 await guard("scan", async () => {
184 scan = await scanStep(ctx);
185 });
186 const scanOutcome = scan!;
187 if (scanOutcome.infected) {
188
189 assertTransition(prevStatus, "VIRUS_DETECTED");
190 await finalizeStep(ctx, {
191 outcome: { kind: "virus", scan: scanOutcome.report },
192 prevStatus,
193 file: row,
194 finalMime: row.mimeType,
195 size: ctx.temp.size
196 });
197 return { kind: "virus" };
198 }
199
200
201 let sniffedMime: string | null = null;
202 await guard("metadata", async () => {
203 sniffedMime = await sniff(ctx);
204 const finalMime = sniffedMime ?? row.mimeType;
205 if (!sniffedMime) {
206 ctx.warnings.push("mime: no magic-byte signal — keeping declared type");
207 }
208 await metadataStep(ctx, finalMime);
209 });
210 const finalMime = sniffedMime ?? row.mimeType;
211
212
213 let thumbs: ThumbsResult | undefined;
214 await guard("thumbnails", async () => {
215 thumbs = await thumbnailsStep(ctx, finalMime);
216 });
217
218
219 assertTransition(prevStatus, "READY");
220 await finalizeStep(ctx, {
221 outcome: {
222 kind: "ready",
223 extracted: ctx.extracted,
224 thumbs: thumbs!,
225 scan: scanOutcome.report
226 },
227 prevStatus,
228 file: row,
229 finalMime,
230 size: ctx.temp.size
231 });
232 await stampDuration(ctx, row, Date.now() - started);
233 log.info(
234 { fileId, size: ctx.temp.size, ms: Date.now() - started },
235 "file processed READY"
236 );
237 return { kind: "ready" };
238 } catch (err) {
239
240
241 if (err instanceof FatalHandledMarker) {
242 return { kind: "failed" };
243 }
244
245 throw err;
246 } finally {
247
248
249 await removeTempFile(ctx.temp.path);
250 }
251}
252
253export function createFilesWorker(opts: {
254 connection: Redis;
255 prisma: PrismaClient;
256 env: WorkerEnv;
257 s3: S3Client;
258
259 s3Thumbs: S3Client;
260 concurrency?: number;
261}): Worker {
262 const { connection, prisma, env, s3, s3Thumbs } = opts;
263
264 return new Worker(
265 QUEUES.files,
266 withJobOtel(QUEUES.files, async (job: Job) => {
267 const data = processFileJobSchema.parse(job.data);
268 try {
269 const result = await runFilesPipeline(
270 { prisma, s3, s3Thumbs, redis: connection, env },
271 data
272 );
273
274
275
276 if (result.kind === "failed" || result.kind === "noop") {
277 job.discard();
278 }
279 } catch (err) {
280
281 if (err instanceof RetryableError) throw err;
282
283
284
285
286 throw new RetryableError(`unhandled pipeline error: ${(err as Error).message}`, err);
287 }
288 }),
289 {
290 connection,
291
292
293 concurrency: opts.concurrency ?? env.FILES_WORKER_CONCURRENCY
294
295
296
297
298
299 }
300 );
301}
302
303
309class FatalHandledMarker extends Error {
310 constructor(code: string, step: string) {
311 super(`fatal (handled) ${step} ${code}`);
312 this.name = "FatalHandledMarker";
313 }
314}
315
316
317async function sniff(ctx: FilePipelineContext): Promise<string | null> {
318 const { open } = await import("node:fs/promises");
319 const fd = await open(ctx.temp.path, "r");
320 try {
321 const buf = Buffer.alloc(16 * 1024);
322 const { bytesRead } = await fd.read(buf, 0, buf.length, 0);
323 if (bytesRead === 0) return null;
324 const det = detectMimeType(buf.subarray(0, bytesRead));
325 return det.confidence === "unknown" ? null : det.mime;
326 } finally {
327 await fd.close();
328 }
329}
330
331
332async function markFailed(
333 ctx: FilePipelineContext,
334 row: File,
335 error: string,
336 code: string
337): Promise<void> {
338 try {
339 const cur = (await ctx.prisma.file.findUnique({ where: { id: row.id } })) as File | null;
340 if (!cur) return;
341
342
343 assertTransition(cur.status, "FAILED");
344 const meta = (cur.metadata as Record<string, unknown> | null) ?? {};
345 const pipeline = {
346 scannedAt: new Date().toISOString(),
347 sha256: ctx.sha256 || undefined,
348 durationMs: 0,
349 steps: [...ctx.stepTimings.entries()].map(([name, ms]) => ({ name, ms })),
350 error,
351 ...(ctx.warnings.length ? { warnings: [...ctx.warnings] } : {})
352 };
353 await ctx.prisma.file.update({
354 where: { id: row.id },
355 data: { status: "FAILED", metadata: asJson({ ...meta, pipeline }) }
356 });
357 await audit(ctx.prisma, {
358 workspaceId: row.workspaceId,
359 resourceType: "file",
360 resourceId: row.id,
361 action: "file.process_failed",
362 metadata: { code, error, key: row.key }
363 });
364 } catch (err) {
365 log.error({ fileId: row.id, err: (err as Error).message }, "markFailed failed (row may be inconsistent)");
366 }
367}
368
369
370async function stampDuration(
371 ctx: FilePipelineContext,
372 row: File,
373 totalMs: number
374): Promise<void> {
375 const cur = (await ctx.prisma.file.findUnique({ where: { id: row.id } })) as File | null;
376 if (!cur) return;
377 const meta = (cur.metadata as Record<string, unknown> | null) ?? {};
378 const pipeline = (meta.pipeline as Record<string, unknown> | undefined) ?? {};
379 await ctx.prisma.file.update({
380 where: { id: row.id },
381 data: { metadata: asJson({ ...meta, pipeline: { ...pipeline, durationMs: totalMs } }) }
382 });
383}
384
385
386export type { FileRowLike };
387export { RetryableError, FatalError };
388