Files
Cibello-app/apps/worker/src/index.ts
T
2026-08-05 19:21:11 +07:00

199 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "dotenv/config";
import { Queue, Worker, type Job } from "bullmq";
import IORedis from "ioredis";
import { createContext } from "./context.js";
import { processScanJob } from "./processors/scans.js";
import { processModerateRecipe } from "./processors/moderation.js";
import { processTranslateRecipe } from "./processors/translation.js";
import { processGenerateWeekPlan } from "./processors/weekplan.js";
import {
processExpiryNotifications,
processMealBoxReminders,
processMemorySync,
processOutbox,
processRetention,
processStoreNotification,
processSubscriptionSweep,
processTrainingExport,
} from "./processors/maintenance.js";
/**
* workern. Konsumerar kön "<slug>-jobs" (spec §50):
* App → signed S3 upload → API job → worker → AAMOS → result → app
*
* Jobbnamn = jobbtyp (spec §54). Deterministiska jobb rör aldrig AAMOS;
* AI-jobb går alltid via de typade kontrakten i @app/ai-contracts.
*/
import { JOB_QUEUE_NAME as QUEUE_NAME } from "@app/shared-types";
const connection = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379", {
maxRetriesPerRequest: null,
});
const ctx = createContext();
const worker = new Worker(
QUEUE_NAME,
async (job: Job) => {
const data = job.data as Record<string, unknown>;
const jobType = (data.jobType as string) ?? job.name;
log(`${jobType} (${job.id})`);
switch (jobType) {
case "ANALYZE_FRIDGE_IMAGE":
case "ANALYZE_PANTRY_IMAGE":
case "ANALYZE_MEAL_IMAGE":
case "READ_RECEIPT":
case "READ_NUTRITION_LABEL":
case "READ_EXPIRY_DATE":
return processScanJob(ctx, String(data.scanJobId));
case "MODERATE_RECIPE":
return processModerateRecipe(ctx, String(data.recipeId));
case "TRANSLATE_RECIPE":
return processTranslateRecipe(ctx, {
recipeId: String(data.recipeId),
targetLanguageTag: String(data.targetLanguageTag),
});
case "GENERATE_WEEK_PLAN":
return processGenerateWeekPlan(ctx, data as never);
case "PROCESS_STORE_NOTIFICATION":
return processStoreNotification(ctx, String(data.notificationId));
case "SEND_EXPIRY_NOTIFICATION": {
const expiry = await processExpiryNotifications(ctx);
const boxes = await processMealBoxReminders(ctx);
log(`Notiser: ${expiry} bäst före, ${boxes} matlådor`);
return;
}
case "UPDATE_USER_MEMORY": {
const updates = await processMemorySync(ctx);
log(`Minnesuppdateringar: ${updates}`);
return;
}
case "VERIFY_SUBSCRIPTION": {
const expired = await processSubscriptionSweep(ctx);
log(`Prenumerationssvep: ${expired} markerade som utgångna`);
return;
}
case "BUILD_TRAINING_SAMPLE": {
const exported = await processTrainingExport(ctx);
log(`Träningsexport: ${exported} korrigeringar (med samtycke)`);
return;
}
case "RUN_RETENTION": {
const removed = await processRetention(ctx);
log(`Retention: ${JSON.stringify(removed)}`);
return;
}
case "PUBLISH_OUTBOX": {
const published = await processOutbox(ctx);
if (published > 0) log(`Outbox: ${published} events publicerade`);
return;
}
// Deterministiska/planerade jobb som inte kräver egen processor ännu
case "NORMALIZE_PRODUCTS":
case "DEDUPLICATE_INVENTORY":
case "CALCULATE_NUTRITION":
case "GENERATE_RECIPE_OPTIONS":
case "RANK_RECIPES":
case "RUN_AI_EVALUATION":
log(`${jobType}: hanteras synkront i API:t eller aktiveras i senare fas`);
return;
default:
throw new Error(`Okänd jobbtyp: ${jobType}`);
}
},
{
connection,
concurrency: Number(process.env.WORKER_CONCURRENCY ?? 5),
},
);
worker.on("completed", (job) => log(`${job.name} (${job.id})`));
worker.on("failed", (job, err) => {
console.error(`${job?.name} (${job?.id}): ${err.message}`);
void alertWebhook(`Worker-jobb misslyckades: ${job?.name} (${job?.id}): ${err.message}`);
});
/** Larm till valfri webhook (Slack/Discord/Teams …) fire-and-forget. */
async function alertWebhook(text: string): Promise<void> {
const url = process.env.ERROR_WEBHOOK_URL;
if (!url) return;
try {
await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
signal: AbortSignal.timeout(5000),
});
} catch {
// Larmet får aldrig fälla arbetsflödet.
}
}
// --- Återkommande jobb via BullMQ Job Schedulers (spec §54) ---
const queue = new Queue(QUEUE_NAME, { connection });
async function registerRepeatableJobs() {
await queue.upsertJobScheduler(
"scheduler-outbox",
{ every: 30_000 },
{ name: "PUBLISH_OUTBOX", data: { jobType: "PUBLISH_OUTBOX" } },
);
await queue.upsertJobScheduler(
"scheduler-expiry",
{ pattern: "0 7 * * *", tz: "Europe/Stockholm" },
{ name: "SEND_EXPIRY_NOTIFICATION", data: { jobType: "SEND_EXPIRY_NOTIFICATION" } },
);
await queue.upsertJobScheduler(
"scheduler-memory",
{ pattern: "30 3 * * *", tz: "Europe/Stockholm" },
{ name: "UPDATE_USER_MEMORY", data: { jobType: "UPDATE_USER_MEMORY" } },
);
await queue.upsertJobScheduler(
"scheduler-subs",
{ pattern: "15 4 * * *", tz: "Europe/Stockholm" },
{ name: "VERIFY_SUBSCRIPTION", data: { jobType: "VERIFY_SUBSCRIPTION" } },
);
await queue.upsertJobScheduler(
"scheduler-retention",
{ pattern: "45 4 * * *", tz: "Europe/Stockholm" },
{ name: "RUN_RETENTION", data: { jobType: "RUN_RETENTION" } },
);
await queue.upsertJobScheduler(
"scheduler-training",
{ pattern: "45 2 * * 0", tz: "Europe/Stockholm" },
{ name: "BUILD_TRAINING_SAMPLE", data: { jobType: "BUILD_TRAINING_SAMPLE" } },
);
}
registerRepeatableJobs()
.then(() => log("Worker igång. Väntar på jobb …"))
.catch((err) => {
console.error("Kunde inte registrera återkommande jobb:", err);
});
const shutdown = async () => {
log("Stänger ner …");
await worker.close();
await queue.close();
connection.disconnect();
await ctx.close();
process.exit(0);
};
process.on("SIGTERM", () => void shutdown());
process.on("SIGINT", () => void shutdown());
function log(msg: string) {
console.log(`[worker ${new Date().toISOString()}] ${msg}`);
}