Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,58 @@
|
||||
# @toolctrl/queue
|
||||
|
||||
BullMQ-oriented queue contracts and producer helpers for web/API enqueue paths and the opt-in worker runtime.
|
||||
|
||||
This package defines stable queue names, typed payloads, job descriptors, producer helpers and small BullMQ factory helpers. Processors live in `apps/worker`; queues only run when the worker is enabled and the related runtime flags/services are configured.
|
||||
|
||||
## Queues
|
||||
|
||||
| Queue | Purpose |
|
||||
| ----------------------------- | --------------------------------------------------------------- |
|
||||
| `toolctrl.email` | Transactional email and webhook delivery jobs. |
|
||||
| `toolctrl.notifications` | User notification delivery jobs. |
|
||||
| `toolctrl.maintenance` | Background cleanup and retention jobs. |
|
||||
| `toolctrl.ai-chatbot` | Contract for admin-managed AI chat completion jobs. |
|
||||
| `toolctrl.browser-automation` | Contract for browser session run, cleanup and usage flush jobs. |
|
||||
| `toolctrl.file-scan` | Storage file scan jobs. |
|
||||
| `toolctrl.exports` | Export job execution. |
|
||||
| `toolctrl.imports` | Import job execution. |
|
||||
| `toolctrl.plugin-invoke` | Isolated third-party plugin invocation jobs. |
|
||||
|
||||
## AI/Browser Jobs
|
||||
|
||||
Stable job names:
|
||||
|
||||
- `chat-completion`
|
||||
- `browser-session-run`
|
||||
- `browser-session-cleanup`
|
||||
- `usage-flush`
|
||||
- `scan-file`
|
||||
- `run-export`
|
||||
- `run-import`
|
||||
|
||||
These are typed contracts and descriptors. Concrete processing is owned by `apps/worker` handlers and may still be disabled until the matching service flag or provider configuration is present.
|
||||
|
||||
## Redis
|
||||
|
||||
Queue workers resolve Redis from `BULLMQ_REDIS_URL` first. `REDIS_URL` is fallback only when `BULLMQ_REDIS_URL` is absent, so new deployments should prefer the BullMQ-specific variable and keep generic platform Redis bindings optional.
|
||||
|
||||
Supported URL schemes:
|
||||
|
||||
- `redis://localhost:6379/0`
|
||||
- `rediss://user:password@example.redis:6379/0`
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { createQueue, queueNames, resolveRedisConnectionOptions } from "@toolctrl/queue";
|
||||
|
||||
const emailQueue = createQueue(queueNames.email, {
|
||||
connection: resolveRedisConnectionOptions({ required: true }),
|
||||
});
|
||||
|
||||
await emailQueue.add("send-email", {
|
||||
kind: "mail.transactional",
|
||||
recipientEmail: "user@example.com",
|
||||
templateId: "welcome",
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@toolctrl/queue",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./runtime": "./src/runtime.cjs"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm run typecheck",
|
||||
"test": "vitest run src test",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"bullmq": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.0",
|
||||
"typescript": "^5.3.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { parseRedisUrl, resolveRedisConnectionOptions, resolveRedisConnectionOptionsFromProcessEnv } from "./connection.js";
|
||||
|
||||
const PASSWORD_REDIS_URL = "redis://user:super-secret-password@redis.example.com:6380/2";
|
||||
|
||||
function expectRedacted(error: unknown) {
|
||||
const serialized = JSON.stringify(error, Object.getOwnPropertyNames(error));
|
||||
|
||||
expect(serialized).not.toContain(PASSWORD_REDIS_URL);
|
||||
expect(serialized).not.toContain("super-secret-password");
|
||||
}
|
||||
|
||||
describe("resolveRedisConnectionOptions", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("prefers BULLMQ_REDIS_URL over REDIS_URL", () => {
|
||||
expect(
|
||||
resolveRedisConnectionOptions({
|
||||
env: {
|
||||
BULLMQ_REDIS_URL: "rediss://bullmq.example.com:6380/1",
|
||||
REDIS_URL: "redis://redis.example.com:6379/0",
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
host: "bullmq.example.com",
|
||||
port: 6380,
|
||||
db: 1,
|
||||
tls: {},
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to REDIS_URL when BULLMQ_REDIS_URL is absent", () => {
|
||||
expect(resolveRedisConnectionOptions({ env: { REDIS_URL: "redis://redis.example.com:6381/3" } })).toMatchObject({
|
||||
host: "redis.example.com",
|
||||
port: 6381,
|
||||
db: 3,
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns undefined for missing optional Redis configuration", () => {
|
||||
expect(resolveRedisConnectionOptions({ env: {} })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("throws a key-only error when required Redis configuration is missing", () => {
|
||||
expect(() => resolveRedisConnectionOptions({ env: {}, required: true })).toThrowError(
|
||||
"Set BULLMQ_REDIS_URL or REDIS_URL before starting queue workers."
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid Redis URLs and protocols without leaking URL values", () => {
|
||||
let invalidUrlError: unknown;
|
||||
try {
|
||||
resolveRedisConnectionOptions({ env: { BULLMQ_REDIS_URL: "redis://:super-secret-password@" }, required: true });
|
||||
} catch (error) {
|
||||
invalidUrlError = error;
|
||||
}
|
||||
|
||||
expect(invalidUrlError).toMatchObject({ message: "BULLMQ_REDIS_URL must be a valid Redis URL." });
|
||||
expectRedacted(invalidUrlError);
|
||||
|
||||
let invalidProtocolError: unknown;
|
||||
try {
|
||||
resolveRedisConnectionOptions({ env: { BULLMQ_REDIS_URL: PASSWORD_REDIS_URL.replace("redis://", "https://") }, required: true });
|
||||
} catch (error) {
|
||||
invalidProtocolError = error;
|
||||
}
|
||||
|
||||
expect(invalidProtocolError).toMatchObject({ message: "BULLMQ_REDIS_URL must use redis:// or rediss://." });
|
||||
expectRedacted(invalidProtocolError);
|
||||
});
|
||||
|
||||
test("does not implicitly fall back to process.env", () => {
|
||||
vi.stubEnv("BULLMQ_REDIS_URL", "redis://process.example.com:6379/0");
|
||||
|
||||
expect(resolveRedisConnectionOptions()).toBeUndefined();
|
||||
expect(resolveRedisConnectionOptions({ env: {} })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("keeps process.env access behind an explicit entrypoint helper", () => {
|
||||
vi.stubEnv("BULLMQ_REDIS_URL", "redis://process.example.com:6382/4");
|
||||
|
||||
expect(resolveRedisConnectionOptionsFromProcessEnv()).toMatchObject({
|
||||
host: "process.example.com",
|
||||
port: 6382,
|
||||
db: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses credentials without leaking password-bearing URLs in database errors", () => {
|
||||
expect(parseRedisUrl(PASSWORD_REDIS_URL)).toMatchObject({
|
||||
host: "redis.example.com",
|
||||
username: "user",
|
||||
password: "super-secret-password",
|
||||
port: 6380,
|
||||
db: 2,
|
||||
});
|
||||
|
||||
let invalidDatabaseError: unknown;
|
||||
try {
|
||||
parseRedisUrl("BULLMQ_REDIS_URL", PASSWORD_REDIS_URL.replace("/2", "/nope"));
|
||||
} catch (error) {
|
||||
invalidDatabaseError = error;
|
||||
}
|
||||
|
||||
expect(invalidDatabaseError).toMatchObject({ message: "BULLMQ_REDIS_URL database index must be a non-negative integer." });
|
||||
expectRedacted(invalidDatabaseError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
export type ConnectionOptions = {
|
||||
db?: number;
|
||||
host: string;
|
||||
maxRetriesPerRequest: null;
|
||||
password?: string;
|
||||
port: number;
|
||||
tls?: Record<string, never>;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type RedisConnectionEnv = {
|
||||
REDIS_URL?: string;
|
||||
BULLMQ_REDIS_URL?: string;
|
||||
};
|
||||
|
||||
export type ResolveRedisConnectionOptions = {
|
||||
env?: RedisConnectionEnv;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export function resolveRedisConnectionOptions(options: ResolveRedisConnectionOptions & { required: true }): ConnectionOptions;
|
||||
export function resolveRedisConnectionOptions(options?: ResolveRedisConnectionOptions): ConnectionOptions | undefined;
|
||||
export function resolveRedisConnectionOptions(options: ResolveRedisConnectionOptions = {}): ConnectionOptions | undefined {
|
||||
const redisUrl = selectRedisUrl(options.env);
|
||||
|
||||
if (!redisUrl) {
|
||||
if (options.required) {
|
||||
throw new Error("Set BULLMQ_REDIS_URL or REDIS_URL before starting queue workers.");
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parseRedisUrl(redisUrl.name, redisUrl.value);
|
||||
}
|
||||
|
||||
export function resolveRedisConnectionOptionsFromProcessEnv(options: Omit<ResolveRedisConnectionOptions, "env"> & { required: true }): ConnectionOptions;
|
||||
export function resolveRedisConnectionOptionsFromProcessEnv(options?: Omit<ResolveRedisConnectionOptions, "env">): ConnectionOptions | undefined;
|
||||
export function resolveRedisConnectionOptionsFromProcessEnv(options: Omit<ResolveRedisConnectionOptions, "env"> = {}): ConnectionOptions | undefined {
|
||||
return resolveRedisConnectionOptions({ ...options, env: process.env as RedisConnectionEnv });
|
||||
}
|
||||
|
||||
export function parseRedisUrl(redisUrl: string): ConnectionOptions;
|
||||
export function parseRedisUrl(name: keyof RedisConnectionEnv, redisUrl: string): ConnectionOptions;
|
||||
export function parseRedisUrl(nameOrRedisUrl: keyof RedisConnectionEnv | string, redisUrl?: string): ConnectionOptions {
|
||||
const name = redisUrl === undefined ? "BULLMQ_REDIS_URL" : nameOrRedisUrl;
|
||||
const value = redisUrl ?? nameOrRedisUrl;
|
||||
let url: URL;
|
||||
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${name} must be a valid Redis URL.`);
|
||||
}
|
||||
|
||||
if (url.protocol !== "redis:" && url.protocol !== "rediss:") {
|
||||
throw new Error(`${name} must use redis:// or rediss://.`);
|
||||
}
|
||||
|
||||
return {
|
||||
db: parseRedisDatabase(name, url),
|
||||
host: url.hostname,
|
||||
maxRetriesPerRequest: null,
|
||||
password: url.password ? decodeURIComponent(url.password) : undefined,
|
||||
port: url.port ? Number(url.port) : 6379,
|
||||
tls: url.protocol === "rediss:" ? {} : undefined,
|
||||
username: url.username ? decodeURIComponent(url.username) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function selectRedisUrl(env: RedisConnectionEnv | undefined): { name: keyof RedisConnectionEnv; value: string } | undefined {
|
||||
const bullmqUrl = normalizeRedisUrl(env?.BULLMQ_REDIS_URL);
|
||||
if (bullmqUrl) {
|
||||
return { name: "BULLMQ_REDIS_URL", value: bullmqUrl };
|
||||
}
|
||||
|
||||
const redisUrl = normalizeRedisUrl(env?.REDIS_URL);
|
||||
if (redisUrl) {
|
||||
return { name: "REDIS_URL", value: redisUrl };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeRedisUrl(value: string | undefined): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function parseRedisDatabase(name: keyof RedisConnectionEnv | string, url: URL): number | undefined {
|
||||
const database = url.pathname.replace(/^\//, "");
|
||||
|
||||
if (!database) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(database)) {
|
||||
throw new Error(`${name} database index must be a non-negative integer.`);
|
||||
}
|
||||
|
||||
return Number(database);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { queueNames as directQueueNames } from "./names.js";
|
||||
import { buildExportJob, buildIdempotentJobId } from "./producers.js";
|
||||
import { queueNames as publicQueueNames } from "./index.js";
|
||||
import type { ExportJob, QueueJobDescriptor, QueueJobPayloads } from "./payloads.js";
|
||||
|
||||
function assertExportDescriptor(descriptor: QueueJobDescriptor<typeof directQueueNames.exports>) {
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
describe("export queue contract", () => {
|
||||
test("exports a stable exports queue name through direct and public surfaces", () => {
|
||||
expect(directQueueNames.exports).toBe("toolctrl.exports");
|
||||
expect(publicQueueNames.exports).toBe(directQueueNames.exports);
|
||||
});
|
||||
|
||||
test("builds an export descriptor matching the typed queue payload map", () => {
|
||||
const descriptor = assertExportDescriptor(
|
||||
buildExportJob({
|
||||
exportJobId: "export_job_contract_1",
|
||||
tenantId: "tenant_1",
|
||||
requestedByUserId: "user_1",
|
||||
exportType: "members",
|
||||
format: "json",
|
||||
requestId: "req_export_contract_1",
|
||||
complianceGate: {
|
||||
state: "paused",
|
||||
reason: "user-privacy-pause",
|
||||
requestId: "req_pause_contract_1",
|
||||
},
|
||||
metadata: { requestId: "req_export_contract_1" },
|
||||
})
|
||||
);
|
||||
const payload: ExportJob = descriptor.payload;
|
||||
const mappedPayload: QueueJobPayloads[typeof directQueueNames.exports] = payload;
|
||||
|
||||
expect(descriptor.queueName).toBe(directQueueNames.exports);
|
||||
expect(descriptor.jobName).toBe("run-export");
|
||||
expect(mappedPayload).toMatchObject({
|
||||
kind: "export.run",
|
||||
exportJobId: "export_job_contract_1",
|
||||
requestId: "req_export_contract_1",
|
||||
complianceGate: {
|
||||
state: "paused",
|
||||
reason: "user-privacy-pause",
|
||||
requestId: "req_pause_contract_1",
|
||||
},
|
||||
});
|
||||
expect(buildIdempotentJobId(descriptor)).toBe("export_job_contract_1");
|
||||
});
|
||||
|
||||
test("keeps export idempotency tied to exportJobId when compliance gate requestIds change", () => {
|
||||
const activeDescriptor = buildExportJob({
|
||||
exportJobId: "export_job_contract_idempotent_1",
|
||||
tenantId: "tenant_1",
|
||||
requestedByUserId: "user_1",
|
||||
exportType: "members",
|
||||
format: "json",
|
||||
complianceGate: {
|
||||
state: "active",
|
||||
resumedAt: "2026-01-01T00:00:00.000Z",
|
||||
requestId: "req_resume_contract_1",
|
||||
},
|
||||
});
|
||||
const pausedDescriptor = buildExportJob({
|
||||
exportJobId: "export_job_contract_idempotent_1",
|
||||
tenantId: "tenant_1",
|
||||
requestedByUserId: "user_1",
|
||||
exportType: "members",
|
||||
format: "json",
|
||||
complianceGate: {
|
||||
state: "paused",
|
||||
reason: "workspace-retention-pause",
|
||||
requestId: "req_pause_contract_2",
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildIdempotentJobId(activeDescriptor)).toBe("export_job_contract_idempotent_1");
|
||||
expect(buildIdempotentJobId(pausedDescriptor)).toBe("export_job_contract_idempotent_1");
|
||||
expect(buildIdempotentJobId(pausedDescriptor)).not.toBe(pausedDescriptor.payload.complianceGate?.requestId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createQueue, createQueueEvents, createWorker } from "./runtime.js";
|
||||
export type { TypedProcessor, TypedQueue, TypedQueueEvents, TypedWorker } from "./types.js";
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { queueNames, type QueueName } from "./names.js";
|
||||
import {
|
||||
WORKER_QUEUE_GROUPS,
|
||||
WORKER_QUEUE_GROUP_NAMES,
|
||||
getGroupForQueue,
|
||||
getQueueNamesForGroup,
|
||||
isWorkerQueueGroup,
|
||||
} from "./groups.js";
|
||||
|
||||
describe("WORKER_QUEUE_GROUPS exhaustiveness", () => {
|
||||
test("every queue declared in queueNames is assigned to exactly one group", () => {
|
||||
const allDeclared = new Set<QueueName>(Object.values(queueNames));
|
||||
const mapped = new Set<QueueName>();
|
||||
const duplicates: QueueName[] = [];
|
||||
|
||||
for (const group of WORKER_QUEUE_GROUP_NAMES) {
|
||||
for (const queue of WORKER_QUEUE_GROUPS[group]) {
|
||||
if (mapped.has(queue)) {
|
||||
duplicates.push(queue);
|
||||
}
|
||||
mapped.add(queue);
|
||||
}
|
||||
}
|
||||
|
||||
expect(duplicates).toEqual([]);
|
||||
|
||||
const missing = [...allDeclared].filter((q) => !mapped.has(q));
|
||||
const extra = [...mapped].filter((q) => !allDeclared.has(q));
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
expect(extra).toEqual([]);
|
||||
});
|
||||
|
||||
test("group names are stable", () => {
|
||||
expect(WORKER_QUEUE_GROUP_NAMES.slice().sort()).toEqual([
|
||||
"general",
|
||||
"import",
|
||||
"mail",
|
||||
"plugin",
|
||||
"webhook",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin group", () => {
|
||||
test("contains the plugin-invoke queue", () => {
|
||||
expect(getQueueNamesForGroup("plugin")).toEqual([queueNames.pluginInvoke]);
|
||||
});
|
||||
|
||||
test("plugin queue maps back to the plugin group", () => {
|
||||
expect(getGroupForQueue(queueNames.pluginInvoke)).toBe("plugin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getQueueNamesForGroup", () => {
|
||||
test("returns mail queue for the mail group", () => {
|
||||
expect(getQueueNamesForGroup("mail")).toEqual([queueNames.email]);
|
||||
});
|
||||
|
||||
test("returns import + export queues for the import group", () => {
|
||||
expect(getQueueNamesForGroup("import")).toEqual([queueNames.imports, queueNames.exports]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGroupForQueue", () => {
|
||||
test("maps every queue back to its group", () => {
|
||||
for (const queue of Object.values(queueNames)) {
|
||||
const group = getGroupForQueue(queue);
|
||||
expect(WORKER_QUEUE_GROUPS[group]).toContain(queue);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWorkerQueueGroup", () => {
|
||||
test("accepts every defined group", () => {
|
||||
for (const group of WORKER_QUEUE_GROUP_NAMES) {
|
||||
expect(isWorkerQueueGroup(group)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unknown values", () => {
|
||||
expect(isWorkerQueueGroup("all")).toBe(false);
|
||||
expect(isWorkerQueueGroup("")).toBe(false);
|
||||
expect(isWorkerQueueGroup("MAIL")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { queueNames, type QueueName } from "./names.js";
|
||||
|
||||
/**
|
||||
* Worker queue groups — single source of truth for which BullMQ queues a
|
||||
* given worker process should subscribe to.
|
||||
*
|
||||
* The five groups exist to break head-of-line-blocking between unrelated
|
||||
* workloads and to allow independent horizontal scaling per workload type
|
||||
* in docker-compose / Kubernetes.
|
||||
*
|
||||
* - `general` — short, mixed I/O jobs (notifications, retention, scan,
|
||||
* cleanup, outbox dispatcher cron). Concurrency ~10.
|
||||
* - `mail` — outbound mail and webhook delivery (SMTP/HTTP throughput-
|
||||
* bound). Concurrency ~5; replicate horizontally for volume.
|
||||
* - `import` — long-running CPU/memory-bound imports/exports. Concurrency
|
||||
* 1 — head-of-line-blocking is acceptable inside this group
|
||||
* because all jobs are heavy; we isolate them so they don't
|
||||
* stall the rest of the system.
|
||||
* - `webhook` — external API integrations (AI chat, browser automation)
|
||||
* that are I/O-bound and benefit from high parallelism.
|
||||
* - `plugin` — third-party WebAssembly plugin invocations (Phase 06
|
||||
* #21). Each job spins up a fresh WASM sandbox with hard
|
||||
* memory + fuel limits; isolation from other groups is
|
||||
* the whole point. Concurrency ~5, replicate ~2.
|
||||
*
|
||||
* Mapping is exhaustive: every queue name declared in `names.ts` is
|
||||
* assigned to exactly one group. The exhaustiveness check lives in
|
||||
* `groups.test.ts`.
|
||||
*/
|
||||
export const WORKER_QUEUE_GROUPS = {
|
||||
general: [queueNames.notifications, queueNames.maintenance, queueNames.fileScan, queueNames.filePreview],
|
||||
mail: [queueNames.email],
|
||||
import: [queueNames.imports, queueNames.exports],
|
||||
webhook: [queueNames.aiChatbot, queueNames.browserAutomation],
|
||||
plugin: [queueNames.pluginInvoke],
|
||||
} as const satisfies Record<string, readonly QueueName[]>;
|
||||
|
||||
export type WorkerQueueGroup = keyof typeof WORKER_QUEUE_GROUPS;
|
||||
|
||||
export const WORKER_QUEUE_GROUP_NAMES = Object.keys(WORKER_QUEUE_GROUPS) as readonly WorkerQueueGroup[];
|
||||
|
||||
const QUEUE_TO_GROUP: Readonly<Record<QueueName, WorkerQueueGroup>> = (() => {
|
||||
const map = {} as Record<QueueName, WorkerQueueGroup>;
|
||||
for (const group of WORKER_QUEUE_GROUP_NAMES) {
|
||||
for (const queue of WORKER_QUEUE_GROUPS[group]) {
|
||||
map[queue] = group;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Returns the queues that belong to `group`. The result is a frozen
|
||||
* readonly tuple — callers must not mutate it.
|
||||
*/
|
||||
export function getQueueNamesForGroup(group: WorkerQueueGroup): readonly QueueName[] {
|
||||
return WORKER_QUEUE_GROUPS[group];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse lookup: which group does this queue belong to? Useful for
|
||||
* tagging metrics and Sentry events with the worker group when the
|
||||
* producer side already knows the queue name.
|
||||
*/
|
||||
export function getGroupForQueue(queue: QueueName): WorkerQueueGroup {
|
||||
return QUEUE_TO_GROUP[queue];
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard — does this string identify a known worker queue group?
|
||||
* Used by the runtime entry point to validate `WORKER_QUEUE_GROUP` env.
|
||||
*/
|
||||
export function isWorkerQueueGroup(value: string): value is WorkerQueueGroup {
|
||||
return (WORKER_QUEUE_GROUP_NAMES as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
export { defaultJobOptions } from "./types.js";
|
||||
export type { TypedProcessor, TypedQueue, TypedQueueEvents, TypedWorker } from "./types.js";
|
||||
export {
|
||||
buildAiBrowserTaskJob,
|
||||
buildAiChatCompletionJob,
|
||||
buildAiEmbeddingJob,
|
||||
buildBrowserSessionCleanupJob,
|
||||
buildBrowserSessionRunJob,
|
||||
buildBrowserUsageFlushJob,
|
||||
buildCleanupJob,
|
||||
buildExportJob,
|
||||
buildFilePreviewJob,
|
||||
buildFileScanJob,
|
||||
buildImportJob,
|
||||
buildNotificationJob,
|
||||
buildPluginInvokeJob,
|
||||
buildRetryJobOptions,
|
||||
buildIdempotentJobId,
|
||||
buildTransactionalMailJob,
|
||||
buildWebhookDeliveryJob,
|
||||
buildWorkflowExecutionContinueRequestedOutboxEvent,
|
||||
buildWorkflowExecutionRequestedOutboxEvent,
|
||||
buildWorkflowTriggerOutboxEvent,
|
||||
enqueueJob,
|
||||
getEnqueueObserver,
|
||||
setEnqueueObserver,
|
||||
} from "./producers.js";
|
||||
export type { EnqueueObserver } from "./producers.js";
|
||||
export type {
|
||||
AiBrowserTaskJobDescriptor,
|
||||
AiBrowserTaskJobInput,
|
||||
AiChatCompletionJobDescriptor,
|
||||
AiChatCompletionJobInput,
|
||||
AiEmbeddingJobDescriptor,
|
||||
AiEmbeddingJobInput,
|
||||
AiProducerJobDescriptor,
|
||||
BrowserAutomationProducerJobDescriptor,
|
||||
BrowserSessionCleanupJobDescriptor,
|
||||
BrowserSessionCleanupJobInput,
|
||||
BrowserSessionRunJobDescriptor,
|
||||
BrowserSessionRunJobInput,
|
||||
BrowserUsageFlushJobDescriptor,
|
||||
BrowserUsageFlushJobInput,
|
||||
CleanupJobDescriptor,
|
||||
CleanupJobInput,
|
||||
EmailProducerJobDescriptor,
|
||||
ExportJobDescriptor,
|
||||
ExportJobInput,
|
||||
FilePreviewJobDescriptor,
|
||||
FilePreviewJobInput,
|
||||
FileScanJobDescriptor,
|
||||
FileScanJobInput,
|
||||
NotificationJobDescriptor,
|
||||
NotificationJobInput,
|
||||
PluginInvokeJobDescriptor,
|
||||
PluginInvokeJobInput,
|
||||
ProducerJobDescriptor,
|
||||
QueueEnqueueOptions,
|
||||
TransactionalMailJobDescriptor,
|
||||
TransactionalMailJobInput,
|
||||
WebhookDeliveryJobDescriptor,
|
||||
WebhookDeliveryJobInput,
|
||||
WorkflowTriggerOutboxEventInput,
|
||||
WorkflowExecutionContinueRequestedOutboxEventInput,
|
||||
WorkflowExecutionRequestedOutboxEventInput,
|
||||
} from "./producers.js";
|
||||
export { isQueueName, queueNames } from "./names.js";
|
||||
export type { QueueKey, QueueName } from "./names.js";
|
||||
export {
|
||||
WORKER_QUEUE_GROUPS,
|
||||
WORKER_QUEUE_GROUP_NAMES,
|
||||
getGroupForQueue,
|
||||
getQueueNamesForGroup,
|
||||
isWorkerQueueGroup,
|
||||
} from "./groups.js";
|
||||
export type { WorkerQueueGroup } from "./groups.js";
|
||||
export {
|
||||
JOB_PRIORITY_CRITICAL,
|
||||
JOB_PRIORITY_HIGH,
|
||||
JOB_PRIORITY_LOW,
|
||||
JOB_PRIORITY_NORMAL,
|
||||
getTenantPlanLookup,
|
||||
priorityFromOverride,
|
||||
priorityFromPlan,
|
||||
priorityLabel,
|
||||
resolveJobPriorityForTenant,
|
||||
setTenantPlanLookup,
|
||||
} from "./priority.js";
|
||||
export type { PriorityOverride, TenantPlanIdentifier, TenantPlanLookup } from "./priority.js";
|
||||
export { parseRedisUrl, resolveRedisConnectionOptions, resolveRedisConnectionOptionsFromProcessEnv } from "./connection.js";
|
||||
export type { ConnectionOptions, RedisConnectionEnv, ResolveRedisConnectionOptions } from "./connection.js";
|
||||
export type {
|
||||
ExportJob,
|
||||
FilePreviewJob,
|
||||
FileScanJob,
|
||||
ImportJob,
|
||||
PluginInvokeJob,
|
||||
QueueJsonValue,
|
||||
QueueJobDescriptor,
|
||||
QueueJobName,
|
||||
QueueJobNames,
|
||||
QueueJobPayloads,
|
||||
QueuePayload,
|
||||
WorkflowExecutionRequestedOutboxEvent,
|
||||
WorkflowExecutionRequestedOutboxPayload,
|
||||
WorkflowTriggerOutboxEvent,
|
||||
WorkflowTriggerOutboxPayload,
|
||||
} from "./payloads.js";
|
||||
@@ -0,0 +1,21 @@
|
||||
export const queueNames = {
|
||||
email: "toolctrl.email",
|
||||
notifications: "toolctrl.notifications",
|
||||
maintenance: "toolctrl.maintenance",
|
||||
aiChatbot: "toolctrl.ai-chatbot",
|
||||
browserAutomation: "toolctrl.browser-automation",
|
||||
fileScan: "toolctrl.file-scan",
|
||||
filePreview: "toolctrl.file-preview",
|
||||
exports: "toolctrl.exports",
|
||||
imports: "toolctrl.imports",
|
||||
pluginInvoke: "toolctrl.plugin-invoke",
|
||||
} as const;
|
||||
|
||||
export type QueueKey = keyof typeof queueNames;
|
||||
export type QueueName = (typeof queueNames)[QueueKey];
|
||||
|
||||
const queueNameValues = new Set<string>(Object.values(queueNames));
|
||||
|
||||
export function isQueueName(value: string): value is QueueName {
|
||||
return queueNameValues.has(value);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { queueNames, type QueueName } from "./names.js";
|
||||
|
||||
export type MailAddressPayload = string | { email: string; name?: string };
|
||||
export type MailAttachmentPayload = {
|
||||
content: string;
|
||||
filename: string;
|
||||
disposition?: "attachment" | "inline";
|
||||
id?: string;
|
||||
};
|
||||
export type MailHeaderPayload = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
export type MailTrackingSettingsPayload = {
|
||||
trackClicks?: boolean;
|
||||
trackOpens?: boolean;
|
||||
trackContent?: boolean;
|
||||
};
|
||||
|
||||
export type TransactionalMailJob = {
|
||||
kind: "mail.transactional";
|
||||
from?: MailAddressPayload;
|
||||
to: readonly MailAddressPayload[];
|
||||
cc?: readonly MailAddressPayload[];
|
||||
bcc?: readonly MailAddressPayload[];
|
||||
replyTo?: MailAddressPayload;
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
templateId?: string;
|
||||
attachments?: readonly MailAttachmentPayload[];
|
||||
tags?: readonly string[];
|
||||
precedenceBulk?: boolean;
|
||||
sendAt?: number;
|
||||
inReplyTo?: string;
|
||||
references?: readonly string[];
|
||||
settings?: MailTrackingSettingsPayload;
|
||||
headers?: readonly MailHeaderPayload[];
|
||||
listUnsubscribe?: string;
|
||||
templateData?: Record<string, unknown>;
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type WebhookDeliveryJob = {
|
||||
kind: "webhook.deliver";
|
||||
url: string;
|
||||
method?: "POST" | "PUT" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
tenantId?: string;
|
||||
eventName: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type NotificationJob = {
|
||||
kind: "notification.send";
|
||||
userId: string;
|
||||
channel: "email" | "in-app";
|
||||
messageKey: string;
|
||||
tenantId: string;
|
||||
category?: string;
|
||||
email?: string;
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
templateId?: string;
|
||||
templateData?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
sourceKey?: string;
|
||||
};
|
||||
|
||||
export type CleanupJob = {
|
||||
kind: "cleanup.run";
|
||||
task:
|
||||
| "expired-sessions"
|
||||
| "anonymous-chat"
|
||||
| "audit-log-retention"
|
||||
| "temporary-files"
|
||||
| "expired-export-artifacts"
|
||||
| "dsgvo-grace-period-scan"
|
||||
| "ai-cost-stripe-report"
|
||||
| "outbox-done-retention"
|
||||
| "workflow-execution-stalled-reconcile"
|
||||
| "task-archive-retention"
|
||||
| "task-reminder-sweep";
|
||||
olderThanHours?: number;
|
||||
olderThanMinutes?: number;
|
||||
take?: number;
|
||||
requestedBy?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AiJob = {
|
||||
kind: "ai.chat-completion" | "ai.embedding" | "ai.browser-task";
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
conversationId?: string;
|
||||
model?: string;
|
||||
messages?: readonly {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
}[];
|
||||
task?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type BrowserAutomationJob = {
|
||||
kind: "browser.session-run" | "browser.session-cleanup" | "usage.flush";
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
policyId?: string;
|
||||
allowedOrigins?: readonly string[];
|
||||
task?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type FileScanJob = {
|
||||
kind: "scan.file";
|
||||
bucket: string;
|
||||
key: string;
|
||||
tenantId?: string;
|
||||
uploadId?: string;
|
||||
contentType?: string;
|
||||
// AK-P4a §5 — the File row this object belongs to. The worker writes the
|
||||
// scan verdict (clean|infected) back onto File.scanStatus so the fail-closed
|
||||
// media proxy can serve it. Optional for legacy import-path scans that have
|
||||
// no File row yet.
|
||||
fileId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type FilePreviewJob = {
|
||||
kind: "preview.file";
|
||||
fileId: string;
|
||||
tenantId: string;
|
||||
bucket: string;
|
||||
storageKey: string;
|
||||
mimeType: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ExportJob = {
|
||||
kind: "export.run";
|
||||
exportJobId: string;
|
||||
tenantId: string;
|
||||
requestedByUserId: string;
|
||||
exportType: string;
|
||||
format: "csv" | "json";
|
||||
requestId?: string;
|
||||
complianceGate?: {
|
||||
state: "active" | "paused";
|
||||
reason?: string;
|
||||
resumedAt?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
filters?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ImportJob = {
|
||||
kind: "import.run";
|
||||
importJobId: string;
|
||||
tenantId: string;
|
||||
requestedByUserId: string;
|
||||
importType: string;
|
||||
format: "csv" | "json" | "xlsx";
|
||||
sourceBucket: string;
|
||||
sourceKey: string;
|
||||
requestId?: string;
|
||||
fileName?: string;
|
||||
contentType?: string;
|
||||
mappings?: Record<string, string>;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugin invocation payload — Phase 06 #21 foundation. Carries the
|
||||
* minimal identifying tuple a worker needs to load the right Plugin
|
||||
* row + invoke the right hook against a fresh WASM sandbox. Structured
|
||||
* payloads (typed per hook) are deferred to #22's hook lifecycle work.
|
||||
*/
|
||||
export type PluginInvokeJob = {
|
||||
kind: "plugin.invoke";
|
||||
tenantId: string;
|
||||
pluginId: string;
|
||||
version: string;
|
||||
hookName: string;
|
||||
/** Free-form JSON-serialisable payload; passed through to the plugin. */
|
||||
input?: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type QueueJsonPrimitive = string | number | boolean | null;
|
||||
export type QueueJsonValue = QueueJsonPrimitive | { [key: string]: QueueJsonValue } | QueueJsonValue[];
|
||||
|
||||
export type WorkflowTriggerOutboxPayload = {
|
||||
event: string;
|
||||
data: QueueJsonValue;
|
||||
userId: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowTriggerOutboxEvent = {
|
||||
tenantId: string | null;
|
||||
type: "workflow.trigger.event";
|
||||
payload: WorkflowTriggerOutboxPayload;
|
||||
dispatchKey: `workflow-trigger:${string}`;
|
||||
};
|
||||
|
||||
export type WorkflowExecutionRequestedOutboxPayload = {
|
||||
workflowId: string;
|
||||
executionId: string;
|
||||
type: "manual" | "api";
|
||||
input: QueueJsonValue;
|
||||
userId: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowExecutionRequestedOutboxEvent = {
|
||||
tenantId: string | null;
|
||||
type: "workflow.execution.requested";
|
||||
payload: WorkflowExecutionRequestedOutboxPayload;
|
||||
dispatchKey: `workflow-execution:${string}`;
|
||||
};
|
||||
|
||||
export type WorkflowExecutionContinueRequestedOutboxPayload = WorkflowExecutionRequestedOutboxPayload;
|
||||
|
||||
export type WorkflowExecutionContinueRequestedOutboxEvent = {
|
||||
tenantId: string | null;
|
||||
type: "workflow.execution.continue.requested";
|
||||
payload: WorkflowExecutionContinueRequestedOutboxPayload;
|
||||
dispatchKey: `workflow-continuation:${string}`;
|
||||
};
|
||||
|
||||
export type QueueJobPayloads = {
|
||||
[queueNames.email]: TransactionalMailJob | WebhookDeliveryJob;
|
||||
[queueNames.notifications]: NotificationJob;
|
||||
[queueNames.maintenance]: CleanupJob;
|
||||
[queueNames.aiChatbot]: AiJob;
|
||||
[queueNames.browserAutomation]: BrowserAutomationJob;
|
||||
[queueNames.fileScan]: FileScanJob;
|
||||
[queueNames.filePreview]: FilePreviewJob;
|
||||
[queueNames.exports]: ExportJob;
|
||||
[queueNames.imports]: ImportJob;
|
||||
[queueNames.pluginInvoke]: PluginInvokeJob;
|
||||
};
|
||||
|
||||
export type QueueJobNames = {
|
||||
[queueNames.email]: "send-transactional-email" | "deliver-webhook";
|
||||
[queueNames.notifications]: "send-notification";
|
||||
[queueNames.maintenance]: "run-cleanup" | "dsgvo-grace-period-scan";
|
||||
[queueNames.aiChatbot]: "chat-completion" | "create-embedding" | "browser-task";
|
||||
[queueNames.browserAutomation]: "browser-session-run" | "browser-session-cleanup" | "usage-flush";
|
||||
[queueNames.fileScan]: "scan-file";
|
||||
[queueNames.filePreview]: "generate-file-preview";
|
||||
[queueNames.exports]: "run-export";
|
||||
[queueNames.imports]: "run-import";
|
||||
[queueNames.pluginInvoke]: "invoke-plugin";
|
||||
};
|
||||
|
||||
export type QueuePayload<Name extends QueueName = QueueName> = QueueJobPayloads[Name];
|
||||
export type QueueJobName<Name extends QueueName = QueueName> = QueueJobNames[Name];
|
||||
|
||||
export type QueueJobDescriptor<Name extends QueueName = QueueName> = {
|
||||
queueName: Name;
|
||||
jobName: QueueJobName<Name>;
|
||||
payload: QueuePayload<Name>;
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
JOB_PRIORITY_CRITICAL,
|
||||
JOB_PRIORITY_HIGH,
|
||||
JOB_PRIORITY_LOW,
|
||||
JOB_PRIORITY_NORMAL,
|
||||
getTenantPlanLookup,
|
||||
priorityFromOverride,
|
||||
priorityFromPlan,
|
||||
priorityLabel,
|
||||
resolveJobPriorityForTenant,
|
||||
setTenantPlanLookup,
|
||||
type TenantPlanLookup,
|
||||
} from "./priority.js";
|
||||
|
||||
describe("priorityFromOverride", () => {
|
||||
test.each([
|
||||
["critical", JOB_PRIORITY_CRITICAL],
|
||||
["high", JOB_PRIORITY_HIGH],
|
||||
["normal", JOB_PRIORITY_NORMAL],
|
||||
["low", JOB_PRIORITY_LOW],
|
||||
] as const)("maps %s to numeric priority", (label, expected) => {
|
||||
expect(priorityFromOverride(label)).toBe(expected);
|
||||
});
|
||||
|
||||
test("throws on unsupported override", () => {
|
||||
expect(() => priorityFromOverride("urgent" as unknown as "critical")).toThrow(/Unsupported priority override/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("priorityFromPlan", () => {
|
||||
test("maps enterprise to CRITICAL", () => {
|
||||
expect(priorityFromPlan("enterprise")).toBe(JOB_PRIORITY_CRITICAL);
|
||||
});
|
||||
test("maps pro to NORMAL", () => {
|
||||
expect(priorityFromPlan("pro")).toBe(JOB_PRIORITY_NORMAL);
|
||||
});
|
||||
test("maps free to LOW", () => {
|
||||
expect(priorityFromPlan("free")).toBe(JOB_PRIORITY_LOW);
|
||||
});
|
||||
test("maps unknown to NORMAL", () => {
|
||||
expect(priorityFromPlan("trial")).toBe(JOB_PRIORITY_NORMAL);
|
||||
expect(priorityFromPlan(null)).toBe(JOB_PRIORITY_NORMAL);
|
||||
expect(priorityFromPlan(undefined)).toBe(JOB_PRIORITY_NORMAL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("priorityLabel", () => {
|
||||
test("round-trips known priorities", () => {
|
||||
expect(priorityLabel(JOB_PRIORITY_CRITICAL)).toBe("critical");
|
||||
expect(priorityLabel(JOB_PRIORITY_HIGH)).toBe("high");
|
||||
expect(priorityLabel(JOB_PRIORITY_NORMAL)).toBe("normal");
|
||||
expect(priorityLabel(JOB_PRIORITY_LOW)).toBe("low");
|
||||
});
|
||||
|
||||
test("labels unknown numeric priorities as 'other'", () => {
|
||||
expect(priorityLabel(99)).toBe("other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveJobPriorityForTenant", () => {
|
||||
const originalFlag = process.env.JOB_PRIORITY_ENABLED;
|
||||
|
||||
beforeEach(() => {
|
||||
setTenantPlanLookup(null);
|
||||
delete process.env.JOB_PRIORITY_ENABLED;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setTenantPlanLookup(null);
|
||||
if (originalFlag === undefined) {
|
||||
delete process.env.JOB_PRIORITY_ENABLED;
|
||||
} else {
|
||||
process.env.JOB_PRIORITY_ENABLED = originalFlag;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns CRITICAL for null tenantId (internal job)", async () => {
|
||||
await expect(resolveJobPriorityForTenant(null)).resolves.toBe(JOB_PRIORITY_CRITICAL);
|
||||
await expect(resolveJobPriorityForTenant(undefined)).resolves.toBe(JOB_PRIORITY_CRITICAL);
|
||||
await expect(resolveJobPriorityForTenant("")).resolves.toBe(JOB_PRIORITY_CRITICAL);
|
||||
});
|
||||
|
||||
test("returns NORMAL when no lookup is registered", async () => {
|
||||
await expect(resolveJobPriorityForTenant("tenant_1")).resolves.toBe(JOB_PRIORITY_NORMAL);
|
||||
});
|
||||
|
||||
test("uses registered lookup to resolve plan-based priority", async () => {
|
||||
const lookup = vi.fn<TenantPlanLookup>(async (tenantId) => {
|
||||
if (tenantId === "tenant_ent") return "enterprise";
|
||||
if (tenantId === "tenant_pro") return "pro";
|
||||
if (tenantId === "tenant_free") return "free";
|
||||
return null;
|
||||
});
|
||||
setTenantPlanLookup(lookup);
|
||||
|
||||
await expect(resolveJobPriorityForTenant("tenant_ent")).resolves.toBe(JOB_PRIORITY_CRITICAL);
|
||||
await expect(resolveJobPriorityForTenant("tenant_pro")).resolves.toBe(JOB_PRIORITY_NORMAL);
|
||||
await expect(resolveJobPriorityForTenant("tenant_free")).resolves.toBe(JOB_PRIORITY_LOW);
|
||||
await expect(resolveJobPriorityForTenant("tenant_unknown")).resolves.toBe(JOB_PRIORITY_NORMAL);
|
||||
expect(lookup).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
test("inline lookup override beats registered lookup", async () => {
|
||||
setTenantPlanLookup(async () => "free");
|
||||
const inline: TenantPlanLookup = async () => "enterprise";
|
||||
await expect(resolveJobPriorityForTenant("tenant_1", { lookup: inline })).resolves.toBe(JOB_PRIORITY_CRITICAL);
|
||||
});
|
||||
|
||||
test("explicit null lookup disables registered lookup", async () => {
|
||||
setTenantPlanLookup(async () => "enterprise");
|
||||
await expect(resolveJobPriorityForTenant("tenant_1", { lookup: null })).resolves.toBe(JOB_PRIORITY_NORMAL);
|
||||
});
|
||||
|
||||
test("falls back to NORMAL when lookup throws and logs a warning", async () => {
|
||||
const warn = vi.fn();
|
||||
setTenantPlanLookup(async () => {
|
||||
throw new Error("redis down");
|
||||
});
|
||||
await expect(resolveJobPriorityForTenant("tenant_1", { logger: { warn } })).resolves.toBe(JOB_PRIORITY_NORMAL);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]?.[0]).toMatchObject({ tenantId: "tenant_1" });
|
||||
});
|
||||
|
||||
test("feature-flag JOB_PRIORITY_ENABLED=false neutralises plan-based priority", async () => {
|
||||
setTenantPlanLookup(async () => "enterprise");
|
||||
process.env.JOB_PRIORITY_ENABLED = "false";
|
||||
await expect(resolveJobPriorityForTenant("tenant_ent")).resolves.toBe(JOB_PRIORITY_NORMAL);
|
||||
});
|
||||
|
||||
test("feature-flag still preserves CRITICAL for null tenantId", async () => {
|
||||
process.env.JOB_PRIORITY_ENABLED = "false";
|
||||
await expect(resolveJobPriorityForTenant(null)).resolves.toBe(JOB_PRIORITY_CRITICAL);
|
||||
});
|
||||
|
||||
test("setTenantPlanLookup / getTenantPlanLookup round-trip", () => {
|
||||
expect(getTenantPlanLookup()).toBeNull();
|
||||
const lookup: TenantPlanLookup = async () => "pro";
|
||||
setTenantPlanLookup(lookup);
|
||||
expect(getTenantPlanLookup()).toBe(lookup);
|
||||
setTenantPlanLookup(null);
|
||||
expect(getTenantPlanLookup()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Plan-based job-priority resolver.
|
||||
*
|
||||
* BullMQ accepts `priority: 1-2_097_152` where `1 = highest`. We use a
|
||||
* fixed 4-level scale so producers, dashboards and ops runbooks stay
|
||||
* legible:
|
||||
*
|
||||
* - `CRITICAL=1` internal jobs, password-reset / verification mails,
|
||||
* cron retention / cleanup
|
||||
* - `HIGH=3` system-triggered workflows
|
||||
* - `NORMAL=5` default for tenant work without a plan override
|
||||
* - `LOW=10` free-tier work, low-priority batches
|
||||
*
|
||||
* Plan → priority mapping:
|
||||
* - `enterprise` → CRITICAL (1)
|
||||
* - `pro` → NORMAL (5)
|
||||
* - `free` → LOW (10)
|
||||
* - everything else / null tenantId → CRITICAL (internal job)
|
||||
*
|
||||
* The resolver supports a pluggable plan lookup (so workers and api don't
|
||||
* need to depend on apps/web's subscription cache). When no lookup is
|
||||
* registered, `resolveJobPriorityForTenant` falls back to `NORMAL` for
|
||||
* tenant-scoped jobs and `CRITICAL` for null tenantId. Callers can register
|
||||
* a lookup via `setTenantPlanLookup` at process boot.
|
||||
*
|
||||
* Feature-flag: `JOB_PRIORITY_ENABLED=false` forces every resolver call to
|
||||
* return `NORMAL` so the system behaves like the pre-priority queue. Default
|
||||
* is enabled.
|
||||
*/
|
||||
|
||||
export const JOB_PRIORITY_CRITICAL = 1;
|
||||
export const JOB_PRIORITY_HIGH = 3;
|
||||
export const JOB_PRIORITY_NORMAL = 5;
|
||||
export const JOB_PRIORITY_LOW = 10;
|
||||
|
||||
export type PriorityOverride = "critical" | "high" | "normal" | "low";
|
||||
|
||||
export type TenantPlanIdentifier = "enterprise" | "pro" | "free" | string;
|
||||
|
||||
/**
|
||||
* Pluggable plan lookup. Implementations may read from a two-tier cache
|
||||
* (apps/web wires this up via the subscription cache) or query the database
|
||||
* directly. Returning `null` is treated as "unknown plan → NORMAL".
|
||||
*/
|
||||
export type TenantPlanLookup = (tenantId: string) => Promise<TenantPlanIdentifier | null>;
|
||||
|
||||
let registeredLookup: TenantPlanLookup | null = null;
|
||||
|
||||
/**
|
||||
* Register a plan-lookup. Idempotent: passing the same lookup twice is a
|
||||
* no-op. Passing `null` clears the registration (used by tests).
|
||||
*/
|
||||
export function setTenantPlanLookup(lookup: TenantPlanLookup | null): void {
|
||||
registeredLookup = lookup;
|
||||
}
|
||||
|
||||
export function getTenantPlanLookup(): TenantPlanLookup | null {
|
||||
return registeredLookup;
|
||||
}
|
||||
|
||||
function jobPriorityEnabled(): boolean {
|
||||
const raw = process.env.JOB_PRIORITY_ENABLED;
|
||||
if (raw === undefined || raw === null || raw === "") return true;
|
||||
return raw.toLowerCase() !== "false" && raw !== "0";
|
||||
}
|
||||
|
||||
export function priorityFromOverride(override: PriorityOverride): number {
|
||||
switch (override) {
|
||||
case "critical":
|
||||
return JOB_PRIORITY_CRITICAL;
|
||||
case "high":
|
||||
return JOB_PRIORITY_HIGH;
|
||||
case "normal":
|
||||
return JOB_PRIORITY_NORMAL;
|
||||
case "low":
|
||||
return JOB_PRIORITY_LOW;
|
||||
default: {
|
||||
// Exhaustiveness: TS will narrow `override` to `never`. Defensive at
|
||||
// runtime in case a caller passes an untyped string.
|
||||
const exhaustive: never = override;
|
||||
throw new Error(`Unsupported priority override: ${String(exhaustive)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function priorityFromPlan(plan: TenantPlanIdentifier | null | undefined): number {
|
||||
switch (plan) {
|
||||
case "enterprise":
|
||||
return JOB_PRIORITY_CRITICAL;
|
||||
case "pro":
|
||||
return JOB_PRIORITY_NORMAL;
|
||||
case "free":
|
||||
return JOB_PRIORITY_LOW;
|
||||
default:
|
||||
// Unknown / null plan → NORMAL so a tenant whose plan can't be looked
|
||||
// up still gets fair scheduling instead of being silently demoted.
|
||||
return JOB_PRIORITY_NORMAL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a BullMQ priority for the given tenantId.
|
||||
*
|
||||
* - `tenantId == null` → CRITICAL (internal jobs always preempt user work)
|
||||
* - feature-flag off → NORMAL (priority neutralised)
|
||||
* - no lookup registered → NORMAL for tenant-scoped jobs
|
||||
* - lookup throws → NORMAL (warn-only; never block enqueue on a
|
||||
* cache/db error)
|
||||
*/
|
||||
export async function resolveJobPriorityForTenant(
|
||||
tenantId: string | null | undefined,
|
||||
options: { lookup?: TenantPlanLookup | null; logger?: { warn: (obj: Record<string, unknown>, msg: string) => void } } = {},
|
||||
): Promise<number> {
|
||||
if (!tenantId) {
|
||||
return JOB_PRIORITY_CRITICAL;
|
||||
}
|
||||
if (!jobPriorityEnabled()) {
|
||||
return JOB_PRIORITY_NORMAL;
|
||||
}
|
||||
|
||||
const lookup = options.lookup === undefined ? registeredLookup : options.lookup;
|
||||
if (!lookup) {
|
||||
return JOB_PRIORITY_NORMAL;
|
||||
}
|
||||
|
||||
try {
|
||||
const plan = await lookup(tenantId);
|
||||
return priorityFromPlan(plan);
|
||||
} catch (error) {
|
||||
options.logger?.warn(
|
||||
{
|
||||
tenantId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"resolveJobPriorityForTenant: plan lookup failed, defaulting to NORMAL",
|
||||
);
|
||||
return JOB_PRIORITY_NORMAL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a numeric BullMQ priority back to its label. Used by metrics
|
||||
* exporters so the `priority` label stays low-cardinality
|
||||
* (`critical|high|normal|low|other`).
|
||||
*/
|
||||
export function priorityLabel(priority: number): "critical" | "high" | "normal" | "low" | "other" {
|
||||
switch (priority) {
|
||||
case JOB_PRIORITY_CRITICAL:
|
||||
return "critical";
|
||||
case JOB_PRIORITY_HIGH:
|
||||
return "high";
|
||||
case JOB_PRIORITY_NORMAL:
|
||||
return "normal";
|
||||
case JOB_PRIORITY_LOW:
|
||||
return "low";
|
||||
default:
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,583 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
const bullmq = require("bullmq");
|
||||
|
||||
const defaultJobOptions = {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
delay: 1000,
|
||||
type: "exponential",
|
||||
},
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 250,
|
||||
};
|
||||
|
||||
function createQueue(queueName, options) {
|
||||
return new bullmq.Queue(queueName, {
|
||||
defaultJobOptions,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function createQueueEvents(queueName, options) {
|
||||
return new bullmq.QueueEvents(queueName, options);
|
||||
}
|
||||
|
||||
function createWorker(queueName, processor, options) {
|
||||
return new bullmq.Worker(queueName, processor, options);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createQueue,
|
||||
createQueueEvents,
|
||||
createWorker,
|
||||
defaultJobOptions,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createQueue, createQueueEvents, createWorker } from "./runtime.js";
|
||||
export { defaultJobOptions } from "./types.js";
|
||||
export type { TypedProcessor, TypedQueue, TypedQueueEvents, TypedWorker } from "./types.js";
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Queue, QueueEvents, Worker, type QueueEventsOptions, type QueueOptions, type WorkerOptions } from "bullmq";
|
||||
import type { QueueJobName, QueuePayload } from "./payloads.js";
|
||||
import type { QueueName } from "./names.js";
|
||||
import { defaultJobOptions, type TypedProcessor, type TypedQueue, type TypedQueueEvents, type TypedWorker } from "./types.js";
|
||||
|
||||
export function createQueue<Name extends QueueName>(queueName: Name, options: QueueOptions): TypedQueue<Name> {
|
||||
return new Queue<QueuePayload<Name>, unknown, QueueJobName<Name>>(queueName, {
|
||||
defaultJobOptions,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function createQueueEvents<Name extends QueueName>(queueName: Name, options?: QueueEventsOptions): TypedQueueEvents<Name> {
|
||||
return new QueueEvents(queueName, options);
|
||||
}
|
||||
|
||||
export function createWorker<Name extends QueueName>(queueName: Name, processor: TypedProcessor<Name>, options: WorkerOptions): TypedWorker<Name> {
|
||||
return new Worker<QueuePayload<Name>, unknown, QueueJobName<Name>>(queueName, processor, options);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { QueueName } from "./names.js";
|
||||
import type { QueueJobName, QueuePayload } from "./payloads.js";
|
||||
|
||||
export type JobsOptions = {
|
||||
attempts?: number;
|
||||
backoff?: {
|
||||
delay?: number;
|
||||
type: string;
|
||||
};
|
||||
jobId?: string;
|
||||
removeOnComplete?: boolean | number;
|
||||
removeOnFail?: boolean | number;
|
||||
/**
|
||||
* BullMQ priority — 1 is highest, larger numbers are lower priority. We
|
||||
* use the constants from `priority.ts` (1 = critical, 3 = high, 5 = normal,
|
||||
* 10 = low) so dashboards stay legible.
|
||||
*/
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
export type TypedQueue<Name extends QueueName> = {
|
||||
add(jobName: QueueJobName<Name>, payload: QueuePayload<Name>, options?: JobsOptions): Promise<{ id?: string }>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
export type TypedQueueEvents<Name extends QueueName> = unknown | { readonly queueName?: Name };
|
||||
export type TypedJob<Name extends QueueName> = {
|
||||
id?: string | number;
|
||||
name: QueueJobName<Name>;
|
||||
data: QueuePayload<Name>;
|
||||
attemptsMade?: number;
|
||||
opts?: {
|
||||
attempts?: number;
|
||||
};
|
||||
updateData?(data: QueuePayload<Name>): Promise<void>;
|
||||
};
|
||||
export type TypedProcessor<Name extends QueueName> = (job: TypedJob<Name>) => Promise<unknown>;
|
||||
export type TypedWorker<Name extends QueueName> = {
|
||||
close(): Promise<void>;
|
||||
name: string;
|
||||
on(event: string, listener: (...args: never[]) => void): TypedWorker<Name>;
|
||||
};
|
||||
|
||||
export const defaultJobOptions = {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
delay: 1_000,
|
||||
type: "exponential",
|
||||
},
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 250,
|
||||
} as const satisfies JobsOptions;
|
||||
@@ -0,0 +1,300 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
buildAiBrowserTaskJob,
|
||||
buildAiChatCompletionJob,
|
||||
buildAiEmbeddingJob,
|
||||
buildBrowserSessionCleanupJob,
|
||||
buildBrowserSessionRunJob,
|
||||
buildBrowserUsageFlushJob,
|
||||
buildCleanupJob,
|
||||
buildExportJob,
|
||||
buildFilePreviewJob,
|
||||
buildFileScanJob,
|
||||
buildImportJob,
|
||||
buildNotificationJob,
|
||||
buildPluginInvokeJob,
|
||||
buildRetryJobOptions,
|
||||
buildTransactionalMailJob,
|
||||
buildWebhookDeliveryJob,
|
||||
enqueueJob,
|
||||
queueNames,
|
||||
type ProducerJobDescriptor,
|
||||
type QueueName,
|
||||
type TypedQueue,
|
||||
} from "../src/index.js";
|
||||
import * as producerExports from "../src/producers.js";
|
||||
|
||||
type RetryMetrics = Pick<
|
||||
ReturnType<typeof buildRetryJobOptions>,
|
||||
"attempts" | "backoff" | "removeOnComplete" | "removeOnFail"
|
||||
>;
|
||||
|
||||
const descriptorBuilders = {
|
||||
buildAiBrowserTaskJob: () => buildAiBrowserTaskJob({ tenantId: "tenant_1", task: { action: "research" } }),
|
||||
buildAiChatCompletionJob: () =>
|
||||
buildAiChatCompletionJob({ tenantId: "tenant_1", messages: [{ role: "user", content: "Hello" }] }),
|
||||
buildAiEmbeddingJob: () =>
|
||||
buildAiEmbeddingJob({ tenantId: "tenant_1", messages: [{ role: "user", content: "Embed this" }] }),
|
||||
buildBrowserSessionCleanupJob: () => buildBrowserSessionCleanupJob({ tenantId: "tenant_1", sessionId: "session_1" }),
|
||||
buildBrowserSessionRunJob: () =>
|
||||
buildBrowserSessionRunJob({ tenantId: "tenant_1", sessionId: "session_1", task: { action: "capture" } }),
|
||||
buildBrowserUsageFlushJob: () => buildBrowserUsageFlushJob({ tenantId: "tenant_1", sessionId: "session_1" }),
|
||||
buildCleanupJob: () => buildCleanupJob({ task: "expired-sessions" }),
|
||||
buildExportJob: () =>
|
||||
buildExportJob({
|
||||
exportJobId: "export_job_metrics_1",
|
||||
tenantId: "tenant_1",
|
||||
requestedByUserId: "user_1",
|
||||
exportType: "members",
|
||||
format: "csv",
|
||||
}),
|
||||
buildFilePreviewJob: () =>
|
||||
buildFilePreviewJob({ fileId: "file_1", tenantId: "tenant_1", bucket: "uploads", storageKey: "tenants/tenant_1/files/file_1/original", mimeType: "image/heic" }),
|
||||
buildFileScanJob: () => buildFileScanJob({ bucket: "uploads", key: "tenant_1/upload_1/file.pdf" }),
|
||||
buildImportJob: () =>
|
||||
buildImportJob({
|
||||
importJobId: "import_job_metrics_1",
|
||||
tenantId: "tenant_1",
|
||||
requestedByUserId: "user_1",
|
||||
importType: "members",
|
||||
format: "csv",
|
||||
sourceBucket: "uploads",
|
||||
sourceKey: "tenant_1/imports/members.csv",
|
||||
}),
|
||||
buildNotificationJob: () => buildNotificationJob({ tenantId: "tenant_1", userId: "user_1", channel: "in-app", messageKey: "message.ready" }),
|
||||
buildPluginInvokeJob: () => buildPluginInvokeJob({ tenantId: "tenant_1", pluginId: "plugin_1", version: "1.0.0", hookName: "onEvent" }),
|
||||
buildTransactionalMailJob: () => buildTransactionalMailJob({ to: ["user@example.com"], templateId: "welcome" }),
|
||||
buildWebhookDeliveryJob: () => buildWebhookDeliveryJob({ url: "https://example.com/webhook", eventName: "entity.created" }),
|
||||
} satisfies Record<string, () => ProducerJobDescriptor>;
|
||||
|
||||
function producerHelperExportNames(): string[] {
|
||||
return Object.keys(producerExports)
|
||||
.filter((name) => /^build[A-Z].*Job$/.test(name))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function retryMetricsFor(descriptor: ProducerJobDescriptor): RetryMetrics {
|
||||
const { attempts, backoff, removeOnComplete, removeOnFail } = buildRetryJobOptions(descriptor);
|
||||
|
||||
return { attempts, backoff, removeOnComplete, removeOnFail };
|
||||
}
|
||||
|
||||
function sortedRetryMetricsSnapshot(): Record<string, RetryMetrics> {
|
||||
return Object.fromEntries(
|
||||
Object.values(descriptorBuilders)
|
||||
.map((buildDescriptor) => {
|
||||
const descriptor = buildDescriptor();
|
||||
|
||||
return [`${descriptor.queueName}:${descriptor.jobName}`, retryMetricsFor(descriptor)] as const;
|
||||
})
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||||
);
|
||||
}
|
||||
|
||||
function createMockQueue<Name extends QueueName>(): TypedQueue<Name> {
|
||||
return { add: vi.fn().mockResolvedValue({ id: "queued-job" }) } as unknown as TypedQueue<Name>;
|
||||
}
|
||||
|
||||
async function enqueueOptionsFor(descriptor: ProducerJobDescriptor, options?: Parameters<typeof enqueueJob>[2]) {
|
||||
const queue = createMockQueue<typeof descriptor.queueName>();
|
||||
|
||||
await enqueueJob(queue, descriptor, options);
|
||||
|
||||
return vi.mocked(queue.add).mock.calls[0]?.[2];
|
||||
}
|
||||
|
||||
describe("queue retry metrics snapshot", () => {
|
||||
test("covers every exported producer descriptor helper", () => {
|
||||
expect(Object.keys(descriptorBuilders).sort()).toEqual(producerHelperExportNames());
|
||||
});
|
||||
|
||||
test("locks retry defaults for every queue producer descriptor", () => {
|
||||
expect(sortedRetryMetricsSnapshot()).toMatchInlineSnapshot(`
|
||||
{
|
||||
"${queueNames.aiChatbot}:browser-task": {
|
||||
"attempts": 2,
|
||||
"backoff": {
|
||||
"delay": 5000,
|
||||
"type": "exponential",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
"${queueNames.aiChatbot}:chat-completion": {
|
||||
"attempts": 3,
|
||||
"backoff": {
|
||||
"delay": 2000,
|
||||
"type": "exponential",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
"${queueNames.aiChatbot}:create-embedding": {
|
||||
"attempts": 3,
|
||||
"backoff": {
|
||||
"delay": 2000,
|
||||
"type": "exponential",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
"${queueNames.browserAutomation}:browser-session-cleanup": {
|
||||
"attempts": 3,
|
||||
"backoff": {
|
||||
"delay": 10000,
|
||||
"type": "fixed",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
"${queueNames.browserAutomation}:browser-session-run": {
|
||||
"attempts": 2,
|
||||
"backoff": {
|
||||
"delay": 5000,
|
||||
"type": "exponential",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
"${queueNames.browserAutomation}:usage-flush": {
|
||||
"attempts": 3,
|
||||
"backoff": {
|
||||
"delay": 10000,
|
||||
"type": "fixed",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
"${queueNames.email}:deliver-webhook": {
|
||||
"attempts": 5,
|
||||
"backoff": {
|
||||
"delay": 2000,
|
||||
"type": "exponential",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
"${queueNames.email}:send-transactional-email": {
|
||||
"attempts": 3,
|
||||
"backoff": {
|
||||
"delay": 1000,
|
||||
"type": "exponential",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
"${queueNames.exports}:run-export": {
|
||||
"attempts": 2,
|
||||
"backoff": {
|
||||
"delay": 10000,
|
||||
"type": "exponential",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
"${queueNames.filePreview}:generate-file-preview": {
|
||||
"attempts": 3,
|
||||
"backoff": {
|
||||
"delay": 3000,
|
||||
"type": "exponential",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
"${queueNames.fileScan}:scan-file": {
|
||||
"attempts": 3,
|
||||
"backoff": {
|
||||
"delay": 2000,
|
||||
"type": "exponential",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
"${queueNames.imports}:run-import": {
|
||||
"attempts": 2,
|
||||
"backoff": {
|
||||
"delay": 10000,
|
||||
"type": "exponential",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
"${queueNames.maintenance}:run-cleanup": {
|
||||
"attempts": 2,
|
||||
"backoff": {
|
||||
"delay": 5000,
|
||||
"type": "fixed",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
"${queueNames.notifications}:send-notification": {
|
||||
"attempts": 3,
|
||||
"backoff": {
|
||||
"delay": 1000,
|
||||
"type": "exponential",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
"${queueNames.pluginInvoke}:invoke-plugin": {
|
||||
"attempts": 3,
|
||||
"backoff": {
|
||||
"delay": 2000,
|
||||
"type": "exponential",
|
||||
},
|
||||
"removeOnComplete": true,
|
||||
"removeOnFail": 1000,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queue idempotency diagnostics", () => {
|
||||
test("uses an explicit jobId before an idempotencyKey", async () => {
|
||||
const options = await enqueueOptionsFor(buildTransactionalMailJob({ to: ["user@example.com"], templateId: "welcome" }), {
|
||||
idempotencyKey: "idempotency-key",
|
||||
jobId: "explicit-job-id",
|
||||
});
|
||||
|
||||
expect(options?.jobId).toBe("explicit-job-id");
|
||||
expect(options).not.toHaveProperty("idempotencyKey");
|
||||
});
|
||||
|
||||
test("uses an explicit idempotencyKey before payload metadata", async () => {
|
||||
const options = await enqueueOptionsFor(
|
||||
buildNotificationJob({
|
||||
tenantId: "tenant_1",
|
||||
userId: "user_1",
|
||||
channel: "in-app",
|
||||
messageKey: "message.ready",
|
||||
metadata: { idempotencyKey: "metadata-key" },
|
||||
}),
|
||||
{ idempotencyKey: "explicit-idempotency-key" }
|
||||
);
|
||||
|
||||
expect(options?.jobId).toBe("explicit-idempotency-key");
|
||||
expect(options).not.toHaveProperty("idempotencyKey");
|
||||
});
|
||||
|
||||
test("uses metadata idempotencyKey when no explicit key is present", async () => {
|
||||
const options = await enqueueOptionsFor(
|
||||
buildCleanupJob({ task: "temporary-files", metadata: { idempotencyKey: "metadata-key" } })
|
||||
);
|
||||
|
||||
expect(options?.jobId).toBe("metadata-key");
|
||||
});
|
||||
|
||||
test("does not derive jobId values from body payloads", async () => {
|
||||
const options = await enqueueOptionsFor(
|
||||
buildWebhookDeliveryJob({
|
||||
url: "https://example.com/webhook",
|
||||
eventName: "entity.created",
|
||||
body: { idempotencyKey: "unsafe-body-key", jobId: "unsafe-body-job-id" },
|
||||
})
|
||||
);
|
||||
|
||||
expect(options).not.toHaveProperty("jobId");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user