Files
HiveGrid-Wireframes/hivegrid-cloud/packages/queue/src/producers.ts
T

584 lines
19 KiB
TypeScript

import type { JobsOptions, TypedQueue } from "./types.js";
import { queueNames, type QueueName } from "./names.js";
import type {
AiJob,
BrowserAutomationJob,
CleanupJob,
ExportJob,
FilePreviewJob,
FileScanJob,
ImportJob,
NotificationJob,
PluginInvokeJob,
QueueJobDescriptor,
QueueJsonValue,
TransactionalMailJob,
WebhookDeliveryJob,
WorkflowExecutionContinueRequestedOutboxEvent,
WorkflowExecutionRequestedOutboxEvent,
WorkflowTriggerOutboxEvent,
} from "./payloads.js";
import { JOB_PRIORITY_CRITICAL, priorityFromOverride, priorityLabel, resolveJobPriorityForTenant, type PriorityOverride } from "./priority.js";
type AiJobForKind<Kind extends AiJob["kind"]> = AiJob & { kind: Kind };
type BrowserAutomationJobForKind<Kind extends BrowserAutomationJob["kind"]> = BrowserAutomationJob & { kind: Kind };
export type TransactionalMailJobInput = Omit<TransactionalMailJob, "kind">;
export type WebhookDeliveryJobInput = Omit<WebhookDeliveryJob, "kind">;
export type NotificationJobInput = Omit<NotificationJob, "kind">;
export type CleanupJobInput = Omit<CleanupJob, "kind">;
export type AiChatCompletionJobInput = Omit<AiJobForKind<"ai.chat-completion">, "kind">;
export type AiEmbeddingJobInput = Omit<AiJobForKind<"ai.embedding">, "kind">;
export type AiBrowserTaskJobInput = Omit<AiJobForKind<"ai.browser-task">, "kind">;
export type BrowserSessionRunJobInput = Omit<BrowserAutomationJobForKind<"browser.session-run">, "kind">;
export type BrowserSessionCleanupJobInput = Omit<BrowserAutomationJobForKind<"browser.session-cleanup">, "kind">;
export type BrowserUsageFlushJobInput = Omit<BrowserAutomationJobForKind<"usage.flush">, "kind">;
export type FileScanJobInput = Omit<FileScanJob, "kind">;
export type FilePreviewJobInput = Omit<FilePreviewJob, "kind">;
export type ExportJobInput = Omit<ExportJob, "kind">;
export type ImportJobInput = Omit<ImportJob, "kind">;
export type PluginInvokeJobInput = Omit<PluginInvokeJob, "kind">;
export type WorkflowTriggerOutboxEventInput = {
tenantId: string | null;
event: string;
data: QueueJsonValue;
userId: string | null;
sourceId: string;
};
export type WorkflowExecutionRequestedOutboxEventInput = {
tenantId: string | null;
workflowId: string;
executionId: string;
type: "manual" | "api";
input: QueueJsonValue;
userId: string | null;
sourceId: string;
};
export type WorkflowExecutionContinueRequestedOutboxEventInput = WorkflowExecutionRequestedOutboxEventInput;
const WORKFLOW_OUTBOX_SOURCE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,234}$/u;
function requireWorkflowOutboxText(value: string, fieldName: string, eventType: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`${eventType} outbox events require ${fieldName}.`);
}
return trimmed;
}
function requireWorkflowOutboxSourceId(value: string, eventType: string): string {
const sourceId = requireWorkflowOutboxText(value, "sourceId", eventType);
if (!WORKFLOW_OUTBOX_SOURCE_ID_PATTERN.test(sourceId)) {
throw new Error(`${eventType} outbox events require sourceId to match ${WORKFLOW_OUTBOX_SOURCE_ID_PATTERN.source}.`);
}
return sourceId;
}
export type TransactionalMailJobDescriptor = QueueJobDescriptor<typeof queueNames.email> & {
jobName: "send-transactional-email";
payload: TransactionalMailJob;
};
export type WebhookDeliveryJobDescriptor = QueueJobDescriptor<typeof queueNames.email> & {
jobName: "deliver-webhook";
payload: WebhookDeliveryJob;
};
export type NotificationJobDescriptor = QueueJobDescriptor<typeof queueNames.notifications> & {
jobName: "send-notification";
payload: NotificationJob;
};
export type CleanupJobDescriptor = QueueJobDescriptor<typeof queueNames.maintenance> & {
jobName: "run-cleanup";
payload: CleanupJob;
};
export type AiChatCompletionJobDescriptor = QueueJobDescriptor<typeof queueNames.aiChatbot> & {
jobName: "chat-completion";
payload: AiJobForKind<"ai.chat-completion">;
};
export type AiEmbeddingJobDescriptor = QueueJobDescriptor<typeof queueNames.aiChatbot> & {
jobName: "create-embedding";
payload: AiJobForKind<"ai.embedding">;
};
export type AiBrowserTaskJobDescriptor = QueueJobDescriptor<typeof queueNames.aiChatbot> & {
jobName: "browser-task";
payload: AiJobForKind<"ai.browser-task">;
};
export type BrowserSessionRunJobDescriptor = QueueJobDescriptor<typeof queueNames.browserAutomation> & {
jobName: "browser-session-run";
payload: BrowserAutomationJobForKind<"browser.session-run">;
};
export type BrowserSessionCleanupJobDescriptor = QueueJobDescriptor<typeof queueNames.browserAutomation> & {
jobName: "browser-session-cleanup";
payload: BrowserAutomationJobForKind<"browser.session-cleanup">;
};
export type BrowserUsageFlushJobDescriptor = QueueJobDescriptor<typeof queueNames.browserAutomation> & {
jobName: "usage-flush";
payload: BrowserAutomationJobForKind<"usage.flush">;
};
export type FileScanJobDescriptor = QueueJobDescriptor<typeof queueNames.fileScan> & {
jobName: "scan-file";
payload: FileScanJob;
};
export type FilePreviewJobDescriptor = QueueJobDescriptor<typeof queueNames.filePreview> & {
jobName: "generate-file-preview";
payload: FilePreviewJob;
};
export type ExportJobDescriptor = QueueJobDescriptor<typeof queueNames.exports> & {
jobName: "run-export";
payload: ExportJob;
};
export type ImportJobDescriptor = QueueJobDescriptor<typeof queueNames.imports> & {
jobName: "run-import";
payload: ImportJob;
};
export type PluginInvokeJobDescriptor = QueueJobDescriptor<typeof queueNames.pluginInvoke> & {
jobName: "invoke-plugin";
payload: PluginInvokeJob;
};
export type EmailProducerJobDescriptor = TransactionalMailJobDescriptor | WebhookDeliveryJobDescriptor;
export type AiProducerJobDescriptor = AiChatCompletionJobDescriptor | AiEmbeddingJobDescriptor | AiBrowserTaskJobDescriptor;
export type BrowserAutomationProducerJobDescriptor = BrowserSessionRunJobDescriptor | BrowserSessionCleanupJobDescriptor | BrowserUsageFlushJobDescriptor;
export type ProducerJobDescriptor =
| EmailProducerJobDescriptor
| NotificationJobDescriptor
| CleanupJobDescriptor
| AiProducerJobDescriptor
| BrowserAutomationProducerJobDescriptor
| FileScanJobDescriptor
| FilePreviewJobDescriptor
| ExportJobDescriptor
| ImportJobDescriptor
| PluginInvokeJobDescriptor;
const retryJobOptionsByQueueAndJobName: {
[Name in QueueName]: Record<QueueJobDescriptor<Name>["jobName"], Pick<JobsOptions, "attempts" | "backoff">>;
} = {
[queueNames.email]: {
"send-transactional-email": { attempts: 3, backoff: { type: "exponential", delay: 1_000 } },
"deliver-webhook": { attempts: 5, backoff: { type: "exponential", delay: 2_000 } },
},
[queueNames.notifications]: {
"send-notification": { attempts: 3, backoff: { type: "exponential", delay: 1_000 } },
},
[queueNames.maintenance]: {
"run-cleanup": { attempts: 2, backoff: { type: "fixed", delay: 5_000 } },
// Cron-scheduled grace-period scan: each tick is idempotent (the
// underlying SQL filter only finds overdue rows), but we still allow
// one retry on transient Prisma/Redis hiccups before letting the
// next 24h tick recover the run.
"dsgvo-grace-period-scan": { attempts: 2, backoff: { type: "fixed", delay: 30_000 } },
},
[queueNames.aiChatbot]: {
"chat-completion": { attempts: 3, backoff: { type: "exponential", delay: 2_000 } },
"create-embedding": { attempts: 3, backoff: { type: "exponential", delay: 2_000 } },
"browser-task": { attempts: 2, backoff: { type: "exponential", delay: 5_000 } },
},
[queueNames.browserAutomation]: {
"browser-session-run": { attempts: 2, backoff: { type: "exponential", delay: 5_000 } },
"browser-session-cleanup": { attempts: 3, backoff: { type: "fixed", delay: 10_000 } },
"usage-flush": { attempts: 3, backoff: { type: "fixed", delay: 10_000 } },
},
[queueNames.fileScan]: {
"scan-file": { attempts: 3, backoff: { type: "exponential", delay: 2_000 } },
},
[queueNames.filePreview]: {
// Preview generation is best-effort: a couple of retries cover transient
// storage/decoder hiccups, then the handler marks previewStatus=failed and
// the user can re-trigger from the detail view.
"generate-file-preview": { attempts: 3, backoff: { type: "exponential", delay: 3_000 } },
},
[queueNames.exports]: {
"run-export": { attempts: 2, backoff: { type: "exponential", delay: 10_000 } },
},
[queueNames.imports]: {
"run-import": { attempts: 2, backoff: { type: "exponential", delay: 10_000 } },
},
[queueNames.pluginInvoke]: {
"invoke-plugin": { attempts: 3, backoff: { type: "exponential", delay: 2_000 } },
},
};
export function buildRetryJobOptions(descriptor: ProducerJobDescriptor): JobsOptions {
const queueRetryOptions = retryJobOptionsByQueueAndJobName[descriptor.queueName] as Record<
ProducerJobDescriptor["jobName"],
Pick<JobsOptions, "attempts" | "backoff">
>;
return {
...queueRetryOptions[descriptor.jobName],
removeOnComplete: true,
removeOnFail: 1_000,
};
}
export function buildTransactionalMailJob(input: TransactionalMailJobInput): TransactionalMailJobDescriptor {
return {
queueName: queueNames.email,
jobName: "send-transactional-email",
payload: {
kind: "mail.transactional",
...input,
},
};
}
export function buildWebhookDeliveryJob(input: WebhookDeliveryJobInput): WebhookDeliveryJobDescriptor {
return {
queueName: queueNames.email,
jobName: "deliver-webhook",
payload: {
kind: "webhook.deliver",
...input,
},
};
}
export function buildNotificationJob(input: NotificationJobInput): NotificationJobDescriptor {
if (input.channel !== "email" && input.channel !== "in-app") {
throw new Error(`Unsupported notification channel: ${input.channel}`);
}
if (!input.tenantId?.trim()) {
throw new Error("Notification jobs require tenantId.");
}
return {
queueName: queueNames.notifications,
jobName: "send-notification",
payload: {
kind: "notification.send",
...input,
},
};
}
export function buildCleanupJob(input: CleanupJobInput): CleanupJobDescriptor {
return {
queueName: queueNames.maintenance,
jobName: "run-cleanup",
payload: {
kind: "cleanup.run",
...input,
},
};
}
export function buildAiChatCompletionJob(input: AiChatCompletionJobInput): AiChatCompletionJobDescriptor {
return {
queueName: queueNames.aiChatbot,
jobName: "chat-completion",
payload: {
kind: "ai.chat-completion",
...input,
},
};
}
export function buildAiEmbeddingJob(input: AiEmbeddingJobInput): AiEmbeddingJobDescriptor {
return {
queueName: queueNames.aiChatbot,
jobName: "create-embedding",
payload: {
kind: "ai.embedding",
...input,
},
};
}
export function buildAiBrowserTaskJob(input: AiBrowserTaskJobInput): AiBrowserTaskJobDescriptor {
return {
queueName: queueNames.aiChatbot,
jobName: "browser-task",
payload: {
kind: "ai.browser-task",
...input,
},
};
}
export function buildBrowserSessionRunJob(input: BrowserSessionRunJobInput): BrowserSessionRunJobDescriptor {
return {
queueName: queueNames.browserAutomation,
jobName: "browser-session-run",
payload: {
kind: "browser.session-run",
...input,
},
};
}
export function buildBrowserSessionCleanupJob(input: BrowserSessionCleanupJobInput): BrowserSessionCleanupJobDescriptor {
return {
queueName: queueNames.browserAutomation,
jobName: "browser-session-cleanup",
payload: {
kind: "browser.session-cleanup",
...input,
},
};
}
export function buildBrowserUsageFlushJob(input: BrowserUsageFlushJobInput): BrowserUsageFlushJobDescriptor {
return {
queueName: queueNames.browserAutomation,
jobName: "usage-flush",
payload: {
kind: "usage.flush",
...input,
},
};
}
export function buildFileScanJob(input: FileScanJobInput): FileScanJobDescriptor {
return {
queueName: queueNames.fileScan,
jobName: "scan-file",
payload: {
kind: "scan.file",
...input,
},
};
}
export function buildFilePreviewJob(input: FilePreviewJobInput): FilePreviewJobDescriptor {
if (!input.fileId?.trim()) {
throw new Error("File preview jobs require fileId.");
}
if (!input.tenantId?.trim()) {
throw new Error("File preview jobs require tenantId.");
}
return {
queueName: queueNames.filePreview,
jobName: "generate-file-preview",
payload: {
kind: "preview.file",
...input,
},
};
}
export function buildExportJob(input: ExportJobInput): ExportJobDescriptor {
return {
queueName: queueNames.exports,
jobName: "run-export",
payload: {
kind: "export.run",
...input,
},
};
}
export function buildImportJob(input: ImportJobInput): ImportJobDescriptor {
return {
queueName: queueNames.imports,
jobName: "run-import",
payload: {
kind: "import.run",
...input,
},
};
}
export function buildPluginInvokeJob(input: PluginInvokeJobInput): PluginInvokeJobDescriptor {
return {
queueName: queueNames.pluginInvoke,
jobName: "invoke-plugin",
payload: {
kind: "plugin.invoke",
...input,
},
};
}
export function buildWorkflowTriggerOutboxEvent(input: WorkflowTriggerOutboxEventInput): WorkflowTriggerOutboxEvent {
const eventType = "workflow.trigger.event";
const event = requireWorkflowOutboxText(input.event, "event", eventType);
const sourceId = requireWorkflowOutboxSourceId(input.sourceId, eventType);
return {
tenantId: input.tenantId,
type: eventType,
payload: {
event,
data: input.data,
userId: input.userId,
},
dispatchKey: `workflow-trigger:${sourceId}`,
};
}
export function buildWorkflowExecutionRequestedOutboxEvent(input: WorkflowExecutionRequestedOutboxEventInput): WorkflowExecutionRequestedOutboxEvent {
const eventType = "workflow.execution.requested";
const workflowId = requireWorkflowOutboxText(input.workflowId, "workflowId", eventType);
const executionId = requireWorkflowOutboxText(input.executionId, "executionId", eventType);
const sourceId = requireWorkflowOutboxSourceId(input.sourceId, eventType);
return {
tenantId: input.tenantId,
type: eventType,
payload: {
workflowId,
executionId,
type: input.type,
input: input.input,
userId: input.userId,
},
dispatchKey: `workflow-execution:${sourceId}`,
};
}
export function buildWorkflowExecutionContinueRequestedOutboxEvent(
input: WorkflowExecutionContinueRequestedOutboxEventInput
): WorkflowExecutionContinueRequestedOutboxEvent {
const eventType = "workflow.execution.continue.requested";
const workflowId = requireWorkflowOutboxText(input.workflowId, "workflowId", eventType);
const executionId = requireWorkflowOutboxText(input.executionId, "executionId", eventType);
const sourceId = requireWorkflowOutboxSourceId(input.sourceId, eventType);
return {
tenantId: input.tenantId,
type: eventType,
payload: {
workflowId,
executionId,
type: input.type,
input: input.input,
userId: input.userId,
},
dispatchKey: `workflow-continuation:${sourceId}`,
};
}
export type QueueEnqueueOptions = JobsOptions & {
idempotencyKey?: string;
/**
* Tenant scope for plan-based priority resolution. Pass `null` for
* internal/system jobs (cron, retention, audit) which always run at
* CRITICAL priority. Omitting `tenantId` leaves the priority untouched
* unless `priorityOverride` is set.
*/
tenantId?: string | null;
/**
* Explicit priority hint that bypasses tenant-plan lookup. Use for
* semantically critical jobs (password-reset mail, verification mail)
* regardless of plan, and for explicit low-priority batches.
*/
priorityOverride?: PriorityOverride;
};
/**
* Optional enqueue observer. Producer-host apps (api / web / worker) can
* register one during boot to forward priority/queue/jobName info to their
* Prometheus registry. Kept as a module-level setter so the queue package
* does not depend on prom-client or any app metrics bundle.
*/
export type EnqueueObserver = (event: { queueName: QueueName; jobName: string; priority: number; priorityLabel: ReturnType<typeof priorityLabel> }) => void;
let enqueueObserver: EnqueueObserver | null = null;
export function setEnqueueObserver(observer: EnqueueObserver | null): void {
enqueueObserver = observer;
}
export function getEnqueueObserver(): EnqueueObserver | null {
return enqueueObserver;
}
export function buildIdempotentJobId(descriptor: ProducerJobDescriptor, options?: QueueEnqueueOptions): string | undefined {
if (typeof options?.jobId === "string") {
return options.jobId;
}
if (typeof options?.idempotencyKey === "string") {
return options.idempotencyKey;
}
if (descriptor.payload.kind === "export.run") {
return descriptor.payload.exportJobId;
}
if (descriptor.payload.kind === "import.run") {
return descriptor.payload.importJobId;
}
const metadata = descriptor.payload.metadata;
if (metadata && typeof metadata.idempotencyKey === "string") {
return metadata.idempotencyKey;
}
return undefined;
}
async function resolvePriorityForEnqueue(options: QueueEnqueueOptions | undefined): Promise<number | undefined> {
if (!options) return undefined;
if (typeof options.priority === "number") {
// Explicit numeric priority always wins so power-users / tests can pin
// a precise value.
return options.priority;
}
if (options.priorityOverride !== undefined) {
return priorityFromOverride(options.priorityOverride);
}
if (options.tenantId !== undefined) {
if (options.tenantId === null) {
// Explicit `null` tenantId → internal job → CRITICAL. Short-circuit
// the resolver to avoid a useless feature-flag round-trip.
return JOB_PRIORITY_CRITICAL;
}
return resolveJobPriorityForTenant(options.tenantId);
}
return undefined;
}
export async function enqueueJob<Name extends QueueName>(queue: TypedQueue<Name>, descriptor: QueueJobDescriptor<Name>, options?: QueueEnqueueOptions) {
const producerDescriptor = descriptor as ProducerJobDescriptor;
const callerOptions: JobsOptions = options ? { ...options } : {};
delete (callerOptions as QueueEnqueueOptions).idempotencyKey;
delete (callerOptions as QueueEnqueueOptions).tenantId;
delete (callerOptions as QueueEnqueueOptions).priorityOverride;
const jobId = buildIdempotentJobId(producerDescriptor, options);
const idempotentCompletionRetention: Pick<JobsOptions, "removeOnComplete"> =
jobId && callerOptions.removeOnComplete === undefined ? { removeOnComplete: false } : {};
const resolvedPriority = await resolvePriorityForEnqueue(options);
const priorityOptions: Pick<JobsOptions, "priority"> = resolvedPriority !== undefined ? { priority: resolvedPriority } : {};
const enqueueOptions: JobsOptions = {
...buildRetryJobOptions(producerDescriptor),
...idempotentCompletionRetention,
...(jobId ? { jobId } : {}),
...priorityOptions,
...callerOptions,
};
if (enqueueObserver && typeof enqueueOptions.priority === "number") {
try {
enqueueObserver({
queueName: descriptor.queueName as QueueName,
jobName: descriptor.jobName,
priority: enqueueOptions.priority,
priorityLabel: priorityLabel(enqueueOptions.priority),
});
} catch {
// Observer must never block enqueue. Swallow.
}
}
return queue.add(descriptor.jobName, descriptor.payload, enqueueOptions);
}