@boopkit/cap-background-jobs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "background-jobs",
3
+ "description": "Background job processing with RabbitMQ via CloudAMQP",
4
+ "type": "application",
5
+ "package": "@boopkit/cap-background-jobs",
6
+ "requires": ["services"],
7
+ "dependencies": ["amqplib@^0.10"],
8
+ "devDependencies": ["@types/amqplib@^0.10"],
9
+ "env": [
10
+ { "name": "CLOUDAMQP_URL", "description": "CloudAMQP connection URL (amqp://...)", "required": "boot" },
11
+ { "name": "QUEUE_PREFIX", "description": "Queue name prefix for environment isolation (e.g. dev/mike)" },
12
+ { "name": "MAX_MESSAGE_RETRIES", "description": "Maximum retry attempts before dead-lettering (default: 5)" }
13
+ ],
14
+ "scripts": {
15
+ "worker": { "cmd": "ts-node src/worker.ts", "description": "Start the background job worker process" },
16
+ "scheduler": { "cmd": "ts-node src/scheduler.ts", "description": "Publish trigger messages to job queues (run via Heroku Scheduler)" }
17
+ },
18
+ "files": [
19
+ "src/scheduler.ts",
20
+ "src/worker.ts",
21
+ "src/services/message-queue.service.ts",
22
+ "src/controllers/example-job.controller.ts",
23
+ "tests/services/message-queue.service.test.ts",
24
+ "tests/controllers/example-job.controller.test.ts",
25
+ "reference/background-jobs.ref.md"
26
+ ],
27
+ "skills": ["design.background-jobs.skill.md"],
28
+ "hooks": [],
29
+ "postInstall": {
30
+ "prerequisites": ["CloudAMQP addon provisioned (or CLOUDAMQP_URL set)"],
31
+ "steps": [
32
+ "Add CLOUDAMQP_URL to .env",
33
+ "Set QUEUE_PREFIX for local dev isolation (e.g. dev/yourname)",
34
+ "Add worker entry to Procfile: worker: npm run worker",
35
+ "Configure Heroku Scheduler to run npm run scheduler on desired interval"
36
+ ],
37
+ "verify": "npm run worker"
38
+ }
39
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@boopkit/cap-background-jobs",
3
+ "version": "0.1.0",
4
+ "description": "Background job processing with RabbitMQ for boopkit-scaffolded projects",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist/",
9
+ "scaffold/",
10
+ "skills/",
11
+ "capability.json"
12
+ ],
13
+ "boopkit": {
14
+ "capability": true,
15
+ "scaffoldDir": "scaffold",
16
+ "skillsDir": "skills",
17
+ "manifest": "capability.json"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json"
21
+ },
22
+ "dependencies": {
23
+ "amqplib": "^0.10.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/amqplib": "^0.10.0",
27
+ "typescript": "^5.9.3"
28
+ },
29
+ "license": "ISC",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
33
+ }
@@ -0,0 +1,160 @@
1
+ # Background Jobs Infrastructure
2
+
3
+ Async job processing with RabbitMQ via CloudAMQP: publish messages from any process, consume them in a dedicated worker with retry and dead-letter handling.
4
+
5
+ **Related skills**: [design.background-jobs.skill.md](../skills/design.background-jobs.skill.md)
6
+
7
+ ---
8
+
9
+ ## Process Model
10
+
11
+ ```
12
+ Web (src/server.ts) Scheduler (src/scheduler.ts)
13
+ │ publish() │ publish() → exit
14
+ ▼ ▼
15
+ ┌─────────────────────────────────────┐
16
+ │ CloudAMQP (RabbitMQ) │
17
+ │ ┌──────────┐ ┌────────┐ ┌───────┐ │
18
+ │ │ main │ │ .retry │ │.failed│ │
19
+ │ │ queue │ │ queue │ │ (DLQ) │ │
20
+ │ └────┬─────┘ └───┬────┘ └───────┘ │
21
+ └───────┼────────────┼────────────────┘
22
+ ▼ │
23
+ Worker (src/worker.ts)
24
+ │ listen() → controller.onMessage()
25
+ │ true → ACK
26
+ │ false → retry queue (backoff TTL) ──┘
27
+ │ throw → retry or DLQ after max retries
28
+ ```
29
+
30
+ Three separate OS processes share one MQ service:
31
+
32
+ | Process | Entry point | Role | Lifecycle |
33
+ |---------|------------|------|-----------|
34
+ | **Web** | `src/server.ts` | Publishes messages as a side effect of HTTP requests | Long-running |
35
+ | **Worker** | `src/worker.ts` | Consumes messages via registered controllers | Long-running |
36
+ | **Scheduler** | `src/scheduler.ts` | Publishes trigger messages, then exits | One-shot (cron) |
37
+
38
+ ---
39
+
40
+ ## Queue Topology
41
+
42
+ Each queue registered via `listen()` creates three RabbitMQ queues:
43
+
44
+ | Queue | Name pattern | Purpose |
45
+ |-------|-------------|---------|
46
+ | Main | `{QUEUE_PREFIX}/{name}` | Messages waiting for consumption |
47
+ | Retry | `{QUEUE_PREFIX}/{name}.retry` | Failed messages waiting for TTL-based redelivery |
48
+ | DLQ | `{QUEUE_PREFIX}/{name}.failed` | Messages that exceeded max retries |
49
+
50
+ Each queue has a corresponding direct exchange for routing. Retry messages dead-letter back to the main exchange after TTL expiry.
51
+
52
+ **Retry behavior:** Exponential backoff starting at 4s, doubling per retry, capped at 60s. Retry count tracked in `x-retry-count` header. After `MAX_MESSAGE_RETRIES` (default 5), messages move to the DLQ.
53
+
54
+ ---
55
+
56
+ ## Connection Lifecycle
57
+
58
+ - **Publish connection**: Lazy — created on first `publish()` call, reused across subsequent calls. Auto-reconnects on error by clearing the cached reference.
59
+ - **Listener connections**: One per `listen()` call — created at registration time. Each gets its own channel.
60
+ - **Shutdown**: `close()` tears down all connections (publish + listeners).
61
+
62
+ The web process only opens an AMQP connection if it actually publishes a message.
63
+
64
+ ---
65
+
66
+ ## Consumer Registration
67
+
68
+ The worker registers consumers via a static array in `worker.ts`:
69
+
70
+ ```typescript
71
+ import * as exampleJobController from './controllers/example-job.controller';
72
+
73
+ const consumers = [
74
+ { queue: exampleJobController.QUEUE_NAME, handler: exampleJobController.onMessage },
75
+ ];
76
+ ```
77
+
78
+ To add a new consumer: write a controller, add an entry to this array.
79
+
80
+ ---
81
+
82
+ ## ACK Modes
83
+
84
+ ### Auto-ACK (default)
85
+
86
+ Handler returns a boolean:
87
+
88
+ ```typescript
89
+ export async function onMessage(msg: unknown): Promise<boolean> {
90
+ // return true → ACK (success or permanent failure)
91
+ // return false → retry with backoff
92
+ // throw → retry, then DLQ after max retries
93
+ }
94
+ ```
95
+
96
+ ### Manual-ACK
97
+
98
+ Handler receives guarded `ack()`/`nack()` functions:
99
+
100
+ ```typescript
101
+ await MessageQueueService.listen('queue', async (msg, ack, nack) => {
102
+ // ack() → ACK the message
103
+ // nack() → retry with backoff / DLQ
104
+ // First call wins — subsequent calls are no-ops
105
+ // Resolving without calling either → treated as failure
106
+ }, { manualAck: true });
107
+ ```
108
+
109
+ ### Prefetch
110
+
111
+ Limits concurrent message delivery per consumer for backpressure control:
112
+
113
+ ```typescript
114
+ await MessageQueueService.listen('queue', handler, { prefetch: 10 });
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Environment Configuration
120
+
121
+ | Variable | Required | Description |
122
+ |----------|----------|-------------|
123
+ | `CLOUDAMQP_URL` | Yes (boot) | CloudAMQP connection URL (`amqp://...`) |
124
+ | `QUEUE_PREFIX` | No | Queue name prefix for environment isolation (e.g. `dev/mike`) |
125
+ | `MAX_MESSAGE_RETRIES` | No | Max retry attempts before dead-lettering (default: 5) |
126
+
127
+ ---
128
+
129
+ ## Heroku Setup
130
+
131
+ 1. **CloudAMQP addon**: `heroku addons:create cloudamqp` — sets `CLOUDAMQP_URL` automatically
132
+ 2. **Procfile**: Add `worker: npm run worker`
133
+ 3. **Heroku Scheduler**: Configure `npm run scheduler` on the desired interval
134
+ 4. **Scale worker**: `heroku ps:scale worker=1`
135
+
136
+ ---
137
+
138
+ ## Graceful Shutdown
139
+
140
+ The worker handles SIGINT/SIGTERM:
141
+
142
+ 1. Stop accepting new messages
143
+ 2. Drain in-flight messages (30s timeout — matches Heroku's SIGTERM grace period)
144
+ 3. Close all AMQP connections
145
+ 4. `process.exit(0)`
146
+
147
+ If drain exceeds 30s, the worker force-exits with code 1.
148
+
149
+ ---
150
+
151
+ ## Key Files
152
+
153
+ | File | Purpose |
154
+ |------|---------|
155
+ | `src/worker.ts` | Worker entry point — boots services, registers consumers, handles shutdown |
156
+ | `src/scheduler.ts` | Scheduler entry point — publishes trigger messages, exits |
157
+ | `src/services/message-queue.service.ts` | MQ service — publish, listen (auto/manual ACK), topology, retry/DLQ |
158
+ | `src/controllers/example-job.controller.ts` | Example consumer — copyable template for new job handlers |
159
+ | `tests/services/message-queue.service.test.ts` | MQ service unit tests |
160
+ | `tests/controllers/example-job.controller.test.ts` | Example controller unit tests |
File without changes
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Example Job Controller — copyable template for MQ consumer handlers.
3
+ *
4
+ * Demonstrates the auto-ACK pattern: parse payload, call a service,
5
+ * return true (ACK) or false (retry). Copy this file and replace the
6
+ * handler body with your business logic.
7
+ *
8
+ * Error classification:
9
+ * return true → ACK (success, or permanent failure — don't retry)
10
+ * return false → retry with exponential backoff
11
+ * uncaught throw → retry, then DLQ after max retries
12
+ */
13
+
14
+ /** Queue name constant — used by worker.ts to register this consumer. */
15
+ export const QUEUE_NAME = 'example-job';
16
+
17
+ /**
18
+ * Handler for 'example-job' queue messages (auto-ACK mode).
19
+ *
20
+ * @param msg - Parsed message payload (JSON already deserialized by MQ service)
21
+ * @returns true to ACK, false to retry
22
+ */
23
+ export async function onMessage(msg: unknown): Promise<boolean> {
24
+ // --- Parse: validate the payload shape ---
25
+ let payload: { task: string; data?: Record<string, unknown> };
26
+ try {
27
+ payload = validatePayload(msg);
28
+ } catch (err) {
29
+ // Poison message — bad shape, retrying won't help
30
+ console.error('[example-job] invalid payload — ACKing to prevent retry loop', {
31
+ error: (err as Error).message,
32
+ });
33
+ return true;
34
+ }
35
+
36
+ // --- Call: delegate to a service ---
37
+ try {
38
+ // Replace this with your actual service call, e.g.:
39
+ // await services.myService.process(payload.task, payload.data);
40
+ console.log('[example-job] processed:', payload.task);
41
+ return true;
42
+ } catch (err) {
43
+ // Transient error — retry with backoff
44
+ console.error('[example-job] processing failed — retrying', {
45
+ error: (err as Error).message,
46
+ });
47
+ return false;
48
+ }
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Internal helpers
53
+ // ---------------------------------------------------------------------------
54
+
55
+ function validatePayload(msg: unknown): { task: string; data?: Record<string, unknown> } {
56
+ if (typeof msg !== 'object' || msg === null || !('task' in msg)) {
57
+ throw new Error('payload must be an object with a "task" field');
58
+ }
59
+ const obj = msg as Record<string, unknown>;
60
+ if (typeof obj.task !== 'string') {
61
+ throw new Error('"task" must be a string');
62
+ }
63
+ return obj as { task: string; data?: Record<string, unknown> };
64
+ }
@@ -0,0 +1,39 @@
1
+ /** @boopkit/cap-background-jobs — Scheduler entry point for triggering background jobs. */
2
+
3
+ import { loadServices } from '@services';
4
+ import MessageQueueService from './services/message-queue.service';
5
+
6
+ // Static job registration — add new {queue, payload} entries here
7
+ const jobs = [
8
+ {
9
+ queue: 'example-job',
10
+ payload: {
11
+ task: 'scheduled-run',
12
+ data: {
13
+ triggeredBy: 'scheduler',
14
+ triggeredAt: new Date().toISOString(),
15
+ },
16
+ },
17
+ },
18
+ ];
19
+
20
+ async function run() {
21
+ await loadServices();
22
+
23
+ for (const job of jobs) {
24
+ const ok = await MessageQueueService.publish(job.queue, job.payload);
25
+ if (ok) {
26
+ console.log(`[scheduler] published to "${job.queue}": ${job.payload.task}`);
27
+ } else {
28
+ console.error(`[scheduler] failed to publish to "${job.queue}"`);
29
+ }
30
+ }
31
+
32
+ await MessageQueueService.close();
33
+ process.exit(0);
34
+ }
35
+
36
+ run().catch((err) => {
37
+ console.error('[scheduler] failed:', err);
38
+ process.exit(1);
39
+ });
File without changes
@@ -0,0 +1,316 @@
1
+ /** @boopkit/cap-background-jobs — RabbitMQ message queue service. */
2
+
3
+ import amqplib from 'amqplib';
4
+ import type { ChannelModel, Channel, ConsumeMessage } from 'amqplib';
5
+
6
+ const QUEUE_PREFIX = process.env.QUEUE_PREFIX ?? '';
7
+ const MAX_RETRIES = parseInt(process.env.MAX_MESSAGE_RETRIES ?? '5', 10);
8
+ const BACKOFF_BASE_MS = 4000;
9
+ const BACKOFF_MAX_MS = 60000;
10
+
11
+ /** All open connections tracked for shutdown cleanup. */
12
+ const connections: ChannelModel[] = [];
13
+
14
+ /** Lazy-initialized publish connection and channel. */
15
+ let publishConnection: ChannelModel | null = null;
16
+ let publishChannel: Channel | null = null;
17
+
18
+ const MessageQueueService = {
19
+ name: 'message-queue',
20
+ init,
21
+ publish,
22
+ listen,
23
+ close,
24
+ };
25
+
26
+ export default MessageQueueService;
27
+
28
+ /**
29
+ * Validate that CLOUDAMQP_URL is set. Does NOT open a connection —
30
+ * publish connection is lazy (first publish call), listener connections
31
+ * are created at listen() time.
32
+ */
33
+ function init(): void {
34
+ if (!process.env.CLOUDAMQP_URL) {
35
+ throw new Error(
36
+ 'E: CLOUDAMQP_URL is not set. ' +
37
+ 'R: The message queue service requires a CloudAMQP connection URL. ' +
38
+ 'R: Set CLOUDAMQP_URL in your environment (e.g. .env file).',
39
+ );
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Publish a message to the given queue.
45
+ * Lazy-initializes a persistent connection on first call, reuses it
46
+ * across subsequent calls. Returns true on success, false on failure.
47
+ */
48
+ async function publish(queue: string, payload: unknown): Promise<boolean> {
49
+ const queueName = prefixedName(queue);
50
+
51
+ try {
52
+ const channel = await getPublishChannel();
53
+ await channel.assertQueue(queueName, { durable: true });
54
+
55
+ const body = Buffer.from(JSON.stringify(payload));
56
+ const ok = channel.sendToQueue(queueName, body, {
57
+ persistent: true,
58
+ messageId: crypto.randomUUID(),
59
+ });
60
+
61
+ if (!ok) {
62
+ // Channel buffer full — wait for drain
63
+ await new Promise<void>((resolve) => channel.once('drain', resolve));
64
+ }
65
+
66
+ return true;
67
+ } catch (err) {
68
+ console.error(`[message-queue] publish error on "${queue}":`, err);
69
+ resetPublishConnection();
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /** Options for listen(). */
75
+ interface ListenOpts {
76
+ /** When true, handler receives guarded ack/nack functions instead of returning boolean. */
77
+ manualAck?: boolean;
78
+ /** Channel prefetch count — limits concurrent message delivery for backpressure. */
79
+ prefetch?: number;
80
+ }
81
+
82
+ /**
83
+ * Listen on a queue with auto-ACK or manual-ACK semantics.
84
+ *
85
+ * Asserts full queue topology on registration:
86
+ * - Main queue: `{prefix}/{name}`
87
+ * - Retry queue: `{prefix}/{name}.retry` (TTL redelivery to main)
88
+ * - DLQ: `{prefix}/{name}.failed` (dead-letter exchange)
89
+ *
90
+ * **Auto-ACK (default):** Handler returns true → ACK, false → retry with backoff,
91
+ * throws → NACK to DLQ after max retries.
92
+ *
93
+ * **Manual-ACK (opts.manualAck = true):** Handler receives guarded ack()/nack()
94
+ * functions. First call wins — subsequent calls are no-ops. If handler resolves
95
+ * or throws without settling → retry/DLQ after max retries.
96
+ */
97
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
98
+ async function listen(queue: string, handler: (...args: any[]) => Promise<any>, opts?: ListenOpts): Promise<void> {
99
+ const connection = await amqplib.connect(process.env.CLOUDAMQP_URL!);
100
+ connections.push(connection);
101
+
102
+ const channel = await connection.createChannel();
103
+ const { mainQueue, retryQueue, failedQueue } = await assertTopology(channel, queue);
104
+
105
+ if (opts?.prefetch != null) {
106
+ await channel.prefetch(opts.prefetch);
107
+ }
108
+
109
+ await channel.consume(mainQueue, async (raw) => {
110
+ if (!raw) return;
111
+
112
+ const retryCount = getRetryCount(raw);
113
+ let parsed: unknown;
114
+
115
+ try {
116
+ parsed = JSON.parse(raw.content.toString());
117
+ } catch {
118
+ // Poison message — ACK to prevent infinite redelivery
119
+ console.error(`[message-queue] unparseable message on "${queue}", sending to DLQ`);
120
+ channel.nack(raw, false, false);
121
+ return;
122
+ }
123
+
124
+ if (opts?.manualAck) {
125
+ // Manual-ACK: handler receives guarded ack/nack
126
+ let settled = false;
127
+
128
+ const guardedAck = (): void => {
129
+ if (settled) {
130
+ console.warn(`[message-queue] duplicate ack on "${queue}" — ignored`);
131
+ return;
132
+ }
133
+ settled = true;
134
+ channel.ack(raw);
135
+ };
136
+
137
+ const guardedNack = async (): Promise<void> => {
138
+ if (settled) {
139
+ console.warn(`[message-queue] duplicate nack on "${queue}" — ignored`);
140
+ return;
141
+ }
142
+ settled = true;
143
+ await retryOrDLQ(channel, raw, retryCount, retryQueue, failedQueue);
144
+ };
145
+
146
+ try {
147
+ await (handler as (msg: unknown, ack: () => void, nack: () => void) => Promise<void>)(parsed, guardedAck, guardedNack);
148
+
149
+ if (!settled) {
150
+ console.warn(`[message-queue] handler resolved without settling on "${queue}" — treating as failure`);
151
+ await retryOrDLQ(channel, raw, retryCount, retryQueue, failedQueue);
152
+ }
153
+ } catch (err) {
154
+ console.error(`[message-queue] handler threw on "${queue}":`, err);
155
+ if (!settled) {
156
+ await retryOrDLQ(channel, raw, retryCount, retryQueue, failedQueue);
157
+ }
158
+ }
159
+ } else {
160
+ // Auto-ACK: handler returns boolean
161
+ try {
162
+ const ok = await (handler as (msg: unknown) => Promise<boolean>)(parsed);
163
+
164
+ if (ok) {
165
+ channel.ack(raw);
166
+ } else {
167
+ await retryOrDLQ(channel, raw, retryCount, retryQueue, failedQueue);
168
+ }
169
+ } catch (err) {
170
+ console.error(`[message-queue] handler threw on "${queue}":`, err);
171
+ await retryOrDLQ(channel, raw, retryCount, retryQueue, failedQueue);
172
+ }
173
+ }
174
+ }, { noAck: false });
175
+ }
176
+
177
+ /** Close all tracked connections (publish + listeners). */
178
+ async function close(): Promise<void> {
179
+ resetPublishConnection();
180
+
181
+ const closing = connections.map(async (conn) => {
182
+ try {
183
+ await conn.close();
184
+ } catch {
185
+ // Already closed — ignore
186
+ }
187
+ });
188
+ await Promise.all(closing);
189
+ connections.length = 0;
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Internal helpers
194
+ // ---------------------------------------------------------------------------
195
+
196
+ function prefixedName(name: string): string {
197
+ return QUEUE_PREFIX ? `${QUEUE_PREFIX}/${name}` : name;
198
+ }
199
+
200
+ /** Lazy-create or return the existing publish channel. */
201
+ async function getPublishChannel(): Promise<Channel> {
202
+ if (publishChannel) return publishChannel;
203
+
204
+ const conn = await amqplib.connect(process.env.CLOUDAMQP_URL!);
205
+ connections.push(conn);
206
+ publishConnection = conn;
207
+
208
+ conn.on('error', () => resetPublishConnection());
209
+ conn.on('close', () => resetPublishConnection());
210
+
211
+ publishChannel = await conn.createChannel();
212
+ return publishChannel;
213
+ }
214
+
215
+ /** Clear cached publish connection so the next call reconnects. */
216
+ function resetPublishConnection(): void {
217
+ if (publishConnection) {
218
+ publishConnection.close().catch(() => { /* already closing */ });
219
+ const idx = connections.indexOf(publishConnection);
220
+ if (idx !== -1) connections.splice(idx, 1);
221
+ }
222
+ publishConnection = null;
223
+ publishChannel = null;
224
+ }
225
+
226
+ /**
227
+ * Assert main + retry + DLQ queues for a given queue name.
228
+ *
229
+ * Topology:
230
+ * main ──(dead-letter)──▸ {name}.failed (via exchange)
231
+ * retry ──(TTL expiry)──▸ main (via dead-letter back to main exchange)
232
+ */
233
+ async function assertTopology(
234
+ channel: Channel,
235
+ queue: string,
236
+ ): Promise<{ mainQueue: string; retryQueue: string; failedQueue: string }> {
237
+ const mainQueue = prefixedName(queue);
238
+ const retryQueue = `${mainQueue}.retry`;
239
+ const failedQueue = `${mainQueue}.failed`;
240
+
241
+ const mainExchange = `${mainQueue}.exchange`;
242
+ const retryExchange = `${retryQueue}.exchange`;
243
+ const failedExchange = `${failedQueue}.exchange`;
244
+
245
+ // Exchanges
246
+ await channel.assertExchange(mainExchange, 'direct', { durable: true });
247
+ await channel.assertExchange(retryExchange, 'direct', { durable: true });
248
+ await channel.assertExchange(failedExchange, 'direct', { durable: true });
249
+
250
+ // Main queue — dead-letters to failed exchange.
251
+ // Note: the DLX here is primarily exercised for poison messages (unparseable
252
+ // JSON) which are nacked without requeue. Normal failures go through
253
+ // retryOrDLQ() which manually publishes to the retry/failed queue and acks.
254
+ await channel.assertQueue(mainQueue, {
255
+ durable: true,
256
+ deadLetterExchange: failedExchange,
257
+ });
258
+ await channel.bindQueue(mainQueue, mainExchange, '');
259
+
260
+ // Retry queue — messages TTL then dead-letter back to main exchange
261
+ await channel.assertQueue(retryQueue, {
262
+ durable: true,
263
+ deadLetterExchange: mainExchange,
264
+ });
265
+ await channel.bindQueue(retryQueue, retryExchange, '');
266
+
267
+ // Failed (DLQ) — terminal resting place
268
+ await channel.assertQueue(failedQueue, { durable: true });
269
+ await channel.bindQueue(failedQueue, failedExchange, '');
270
+
271
+ return { mainQueue, retryQueue, failedQueue };
272
+ }
273
+
274
+ function getRetryCount(msg: ConsumeMessage): number {
275
+ const headers = msg.properties.headers ?? {};
276
+ return typeof headers['x-retry-count'] === 'number' ? headers['x-retry-count'] : 0;
277
+ }
278
+
279
+ function computeBackoff(retryCount: number): number {
280
+ return Math.min(BACKOFF_BASE_MS * Math.pow(2, retryCount), BACKOFF_MAX_MS);
281
+ }
282
+
283
+ /**
284
+ * Retry a message (publish to retry queue with incremented count and TTL)
285
+ * or send to DLQ if max retries exceeded.
286
+ */
287
+ async function retryOrDLQ(
288
+ channel: Channel,
289
+ raw: ConsumeMessage,
290
+ retryCount: number,
291
+ retryQueue: string,
292
+ failedQueue: string,
293
+ ): Promise<void> {
294
+ if (retryCount >= MAX_RETRIES) {
295
+ console.error(
296
+ `[message-queue] max retries (${MAX_RETRIES}) exceeded, moving to DLQ: ${failedQueue}`,
297
+ );
298
+ channel.publish('', failedQueue, raw.content, {
299
+ persistent: true,
300
+ headers: { ...raw.properties.headers, 'x-retry-count': retryCount },
301
+ });
302
+ channel.ack(raw);
303
+ return;
304
+ }
305
+
306
+ const ttl = computeBackoff(retryCount);
307
+ channel.publish('', retryQueue, raw.content, {
308
+ persistent: true,
309
+ expiration: String(ttl),
310
+ headers: {
311
+ ...raw.properties.headers,
312
+ 'x-retry-count': retryCount + 1,
313
+ },
314
+ });
315
+ channel.ack(raw);
316
+ }
@@ -0,0 +1,58 @@
1
+ /** @boopkit/cap-background-jobs — Worker entry point for background job processing. */
2
+
3
+ import { loadServices } from '@services';
4
+ import MessageQueueService from './services/message-queue.service';
5
+ import * as exampleJobController from './controllers/example-job.controller';
6
+
7
+ // Static consumer registration — add new {queue, handler} entries here
8
+ const consumers = [
9
+ { queue: exampleJobController.QUEUE_NAME, handler: exampleJobController.onMessage },
10
+ ];
11
+
12
+ const DRAIN_TIMEOUT_MS = 30_000;
13
+ let shuttingDown = false;
14
+
15
+ async function boot() {
16
+ await loadServices();
17
+
18
+ for (const consumer of consumers) {
19
+ await MessageQueueService.listen(consumer.queue, consumer.handler);
20
+ }
21
+
22
+ console.log(`[worker] listening on ${consumers.length} queue(s)`);
23
+ }
24
+
25
+ async function shutdown(signal: string) {
26
+ if (shuttingDown) return;
27
+ shuttingDown = true;
28
+
29
+ console.log(`[worker] ${signal} received — starting graceful shutdown`);
30
+
31
+ // Drain in-flight messages with timeout
32
+ const drainTimer = setTimeout(() => {
33
+ console.error('[worker] drain timeout exceeded — forcing exit');
34
+ process.exit(1);
35
+ }, DRAIN_TIMEOUT_MS);
36
+
37
+ try {
38
+ await MessageQueueService.close();
39
+ clearTimeout(drainTimer);
40
+ console.log('[worker] shutdown complete');
41
+ process.exit(0);
42
+ } catch (err) {
43
+ clearTimeout(drainTimer);
44
+ console.error('[worker] error during shutdown:', err);
45
+ process.exit(1);
46
+ }
47
+ }
48
+
49
+ process.on('SIGINT', () => shutdown('SIGINT'));
50
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
51
+
52
+ boot().catch((err) => {
53
+ console.error('[worker] boot failed:', err);
54
+ process.exit(1);
55
+ });
56
+
57
+ // Keep the worker process alive
58
+ process.stdin.resume();
File without changes
@@ -0,0 +1,50 @@
1
+ import { onMessage, QUEUE_NAME } from '../../src/controllers/example-job.controller';
2
+
3
+ describe('example-job controller', () => {
4
+ let consoleSpy: jest.SpyInstance;
5
+
6
+ beforeEach(() => {
7
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
8
+ jest.spyOn(console, 'error').mockImplementation();
9
+ });
10
+
11
+ afterEach(() => {
12
+ jest.restoreAllMocks();
13
+ });
14
+
15
+ it('exports QUEUE_NAME', () => {
16
+ expect(QUEUE_NAME).toBe('example-job');
17
+ });
18
+
19
+ it('ACKs a valid payload', async () => {
20
+ const result = await onMessage({ task: 'send-email', data: { to: 'a@b.com' } });
21
+ expect(result).toBe(true);
22
+ expect(consoleSpy).toHaveBeenCalledWith('[example-job] processed:', 'send-email');
23
+ });
24
+
25
+ it('ACKs payload with no optional data field', async () => {
26
+ const result = await onMessage({ task: 'cleanup' });
27
+ expect(result).toBe(true);
28
+ });
29
+
30
+ // Poison message: bad shape → ACK (don't retry)
31
+ it('ACKs when payload is not an object', async () => {
32
+ const result = await onMessage('not-an-object');
33
+ expect(result).toBe(true);
34
+ });
35
+
36
+ it('ACKs when payload is null', async () => {
37
+ const result = await onMessage(null);
38
+ expect(result).toBe(true);
39
+ });
40
+
41
+ it('ACKs when payload is missing "task" field', async () => {
42
+ const result = await onMessage({ foo: 'bar' });
43
+ expect(result).toBe(true);
44
+ });
45
+
46
+ it('ACKs when "task" is not a string', async () => {
47
+ const result = await onMessage({ task: 123 });
48
+ expect(result).toBe(true);
49
+ });
50
+ });
File without changes
@@ -0,0 +1,419 @@
1
+ import type { ConsumeMessage } from 'amqplib';
2
+
3
+ // Mock amqplib before importing the service
4
+ const mockChannel: Record<string, jest.Mock> = {
5
+ assertExchange: jest.fn().mockResolvedValue(undefined),
6
+ assertQueue: jest.fn().mockResolvedValue({ queue: 'test' }),
7
+ bindQueue: jest.fn().mockResolvedValue(undefined),
8
+ sendToQueue: jest.fn().mockReturnValue(true),
9
+ publish: jest.fn().mockReturnValue(true),
10
+ consume: jest.fn().mockResolvedValue({ consumerTag: 'test-tag' }),
11
+ prefetch: jest.fn().mockResolvedValue(undefined),
12
+ ack: jest.fn(),
13
+ nack: jest.fn(),
14
+ once: jest.fn(),
15
+ };
16
+
17
+ const mockConnection: Record<string, jest.Mock> = {
18
+ createChannel: jest.fn().mockResolvedValue(mockChannel),
19
+ close: jest.fn().mockResolvedValue(undefined),
20
+ on: jest.fn(),
21
+ };
22
+
23
+ jest.mock('amqplib', () => ({
24
+ connect: jest.fn().mockResolvedValue(mockConnection),
25
+ }));
26
+
27
+ import amqplib from 'amqplib';
28
+ import MessageQueueService from '../../src/services/message-queue.service';
29
+
30
+ describe('MessageQueueService', () => {
31
+ const ORIGINAL_ENV = process.env;
32
+
33
+ beforeEach(() => {
34
+ jest.clearAllMocks();
35
+ process.env = { ...ORIGINAL_ENV, CLOUDAMQP_URL: 'amqp://test:test@localhost/test' };
36
+ });
37
+
38
+ afterEach(() => {
39
+ process.env = ORIGINAL_ENV;
40
+ });
41
+
42
+ describe('init()', () => {
43
+ it('succeeds when CLOUDAMQP_URL is set', () => {
44
+ expect(() => MessageQueueService.init()).not.toThrow();
45
+ });
46
+
47
+ it('throws when CLOUDAMQP_URL is missing', () => {
48
+ delete process.env.CLOUDAMQP_URL;
49
+ expect(() => MessageQueueService.init()).toThrow('CLOUDAMQP_URL is not set');
50
+ });
51
+ });
52
+
53
+ describe('publish()', () => {
54
+ it('connects lazily on first publish', async () => {
55
+ const result = await MessageQueueService.publish('test-queue', { foo: 'bar' });
56
+
57
+ expect(result).toBe(true);
58
+ expect(amqplib.connect).toHaveBeenCalledWith(process.env.CLOUDAMQP_URL);
59
+ expect(mockConnection.createChannel).toHaveBeenCalled();
60
+ });
61
+
62
+ it('publishes with persistent flag and messageId', async () => {
63
+ await MessageQueueService.publish('test-queue', { data: 1 });
64
+
65
+ expect(mockChannel.sendToQueue).toHaveBeenCalledWith(
66
+ 'test-queue',
67
+ expect.any(Buffer),
68
+ expect.objectContaining({
69
+ persistent: true,
70
+ messageId: expect.any(String),
71
+ }),
72
+ );
73
+ });
74
+
75
+ it('serializes payload as JSON', async () => {
76
+ const payload = { key: 'value', count: 42 };
77
+ await MessageQueueService.publish('test-queue', payload);
78
+
79
+ const sentBuffer = (mockChannel.sendToQueue as jest.Mock).mock.calls[0][1];
80
+ expect(JSON.parse(sentBuffer.toString())).toEqual(payload);
81
+ });
82
+
83
+ it('returns false on error', async () => {
84
+ (mockChannel.sendToQueue as jest.Mock).mockImplementationOnce(() => {
85
+ throw new Error('channel closed');
86
+ });
87
+
88
+ const result = await MessageQueueService.publish('test-queue', {});
89
+ expect(result).toBe(false);
90
+ });
91
+
92
+ it('waits for drain when channel buffer is full', async () => {
93
+ (mockChannel.sendToQueue as jest.Mock).mockReturnValueOnce(false);
94
+ (mockChannel.once as jest.Mock).mockImplementationOnce(
95
+ (_event: string, cb: () => void) => { cb(); },
96
+ );
97
+
98
+ const result = await MessageQueueService.publish('test-queue', {});
99
+
100
+ expect(result).toBe(true);
101
+ expect(mockChannel.once).toHaveBeenCalledWith('drain', expect.any(Function));
102
+ });
103
+
104
+ // Note: QUEUE_PREFIX is captured once at module load time via const.
105
+ // It cannot be tested by setting process.env after import. The prefix
106
+ // behavior is verified through listen() topology assertions instead.
107
+ });
108
+
109
+ describe('listen()', () => {
110
+ it('creates a new connection per listener', async () => {
111
+ await MessageQueueService.listen('jobs', async () => true);
112
+
113
+ expect(amqplib.connect).toHaveBeenCalledWith(process.env.CLOUDAMQP_URL);
114
+ expect(mockConnection.createChannel).toHaveBeenCalled();
115
+ });
116
+
117
+ it('asserts full queue topology (main + retry + DLQ)', async () => {
118
+ await MessageQueueService.listen('jobs', async () => true);
119
+
120
+ // Exchanges
121
+ expect(mockChannel.assertExchange).toHaveBeenCalledWith(
122
+ 'jobs.exchange', 'direct', { durable: true },
123
+ );
124
+ expect(mockChannel.assertExchange).toHaveBeenCalledWith(
125
+ 'jobs.retry.exchange', 'direct', { durable: true },
126
+ );
127
+ expect(mockChannel.assertExchange).toHaveBeenCalledWith(
128
+ 'jobs.failed.exchange', 'direct', { durable: true },
129
+ );
130
+
131
+ // Queues
132
+ expect(mockChannel.assertQueue).toHaveBeenCalledWith(
133
+ 'jobs',
134
+ expect.objectContaining({ durable: true, deadLetterExchange: 'jobs.failed.exchange' }),
135
+ );
136
+ expect(mockChannel.assertQueue).toHaveBeenCalledWith(
137
+ 'jobs.retry',
138
+ expect.objectContaining({ durable: true, deadLetterExchange: 'jobs.exchange' }),
139
+ );
140
+ expect(mockChannel.assertQueue).toHaveBeenCalledWith(
141
+ 'jobs.failed',
142
+ expect.objectContaining({ durable: true }),
143
+ );
144
+ });
145
+
146
+ it('sets up a consumer on the main queue', async () => {
147
+ await MessageQueueService.listen('jobs', async () => true);
148
+ expect(mockChannel.consume).toHaveBeenCalledWith('jobs', expect.any(Function), { noAck: false });
149
+ });
150
+
151
+ describe('message dispatch', () => {
152
+ let consumeCallback: (msg: ConsumeMessage | null) => Promise<void>;
153
+
154
+ beforeEach(async () => {
155
+ (mockChannel.consume as jest.Mock).mockImplementationOnce(
156
+ (_queue: string, cb: (msg: ConsumeMessage | null) => Promise<void>) => {
157
+ consumeCallback = cb;
158
+ return Promise.resolve({ consumerTag: 'tag' });
159
+ },
160
+ );
161
+ await MessageQueueService.listen('jobs', async (msg) => {
162
+ if ((msg as any).fail) return false;
163
+ if ((msg as any).throw) throw new Error('boom');
164
+ return true;
165
+ });
166
+ });
167
+
168
+ function makeMessage(payload: unknown, retryCount = 0): ConsumeMessage {
169
+ return {
170
+ content: Buffer.from(JSON.stringify(payload)),
171
+ properties: {
172
+ headers: { 'x-retry-count': retryCount },
173
+ },
174
+ fields: { deliveryTag: 1, redelivered: false, exchange: '', routingKey: '' },
175
+ } as unknown as ConsumeMessage;
176
+ }
177
+
178
+ it('ACKs when handler returns true', async () => {
179
+ await consumeCallback(makeMessage({ ok: true }));
180
+ expect(mockChannel.ack).toHaveBeenCalled();
181
+ });
182
+
183
+ it('retries when handler returns false', async () => {
184
+ await consumeCallback(makeMessage({ fail: true }));
185
+
186
+ // Should publish to retry queue with incremented retry count
187
+ expect(mockChannel.publish).toHaveBeenCalledWith(
188
+ '',
189
+ 'jobs.retry',
190
+ expect.any(Buffer),
191
+ expect.objectContaining({
192
+ persistent: true,
193
+ expiration: '4000', // 4000 * 2^0
194
+ headers: expect.objectContaining({ 'x-retry-count': 1 }),
195
+ }),
196
+ );
197
+ expect(mockChannel.ack).toHaveBeenCalled();
198
+ });
199
+
200
+ it('sends to DLQ after max retries', async () => {
201
+ await consumeCallback(makeMessage({ fail: true }, 5));
202
+
203
+ expect(mockChannel.publish).toHaveBeenCalledWith(
204
+ '',
205
+ 'jobs.failed',
206
+ expect.any(Buffer),
207
+ expect.objectContaining({
208
+ persistent: true,
209
+ headers: expect.objectContaining({ 'x-retry-count': 5 }),
210
+ }),
211
+ );
212
+ expect(mockChannel.ack).toHaveBeenCalled();
213
+ });
214
+
215
+ it('retries on handler throw (below max retries)', async () => {
216
+ await consumeCallback(makeMessage({ throw: true }, 2));
217
+
218
+ expect(mockChannel.publish).toHaveBeenCalledWith(
219
+ '',
220
+ 'jobs.retry',
221
+ expect.any(Buffer),
222
+ expect.objectContaining({
223
+ expiration: '16000', // 4000 * 2^2
224
+ headers: expect.objectContaining({ 'x-retry-count': 3 }),
225
+ }),
226
+ );
227
+ });
228
+
229
+ it('ignores null messages', async () => {
230
+ await consumeCallback(null as unknown as ConsumeMessage);
231
+ expect(mockChannel.ack).not.toHaveBeenCalled();
232
+ expect(mockChannel.nack).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it('NACKs unparseable messages (poison)', async () => {
236
+ const poisonMsg = {
237
+ content: Buffer.from('not-json{{{'),
238
+ properties: { headers: {} },
239
+ fields: { deliveryTag: 1, redelivered: false, exchange: '', routingKey: '' },
240
+ } as unknown as ConsumeMessage;
241
+
242
+ await consumeCallback(poisonMsg);
243
+ expect(mockChannel.nack).toHaveBeenCalledWith(poisonMsg, false, false);
244
+ });
245
+ });
246
+ });
247
+
248
+ describe('listen() manual-ACK mode', () => {
249
+ let consumeCallback: (msg: ConsumeMessage | null) => Promise<void>;
250
+
251
+ function makeMessage(payload: unknown, retryCount = 0): ConsumeMessage {
252
+ return {
253
+ content: Buffer.from(JSON.stringify(payload)),
254
+ properties: {
255
+ headers: { 'x-retry-count': retryCount },
256
+ },
257
+ fields: { deliveryTag: 1, redelivered: false, exchange: '', routingKey: '' },
258
+ } as unknown as ConsumeMessage;
259
+ }
260
+
261
+ function setupManualAckListener(
262
+ handler: (msg: unknown, ack: () => void, nack: () => void) => Promise<void>,
263
+ ) {
264
+ (mockChannel.consume as jest.Mock).mockImplementationOnce(
265
+ (_queue: string, cb: (msg: ConsumeMessage | null) => Promise<void>) => {
266
+ consumeCallback = cb;
267
+ return Promise.resolve({ consumerTag: 'tag' });
268
+ },
269
+ );
270
+ return MessageQueueService.listen('jobs', handler, { manualAck: true });
271
+ }
272
+
273
+ it('handler receives ack and nack functions', async () => {
274
+ let receivedAck: (() => void) | undefined;
275
+ let receivedNack: (() => void) | undefined;
276
+
277
+ await setupManualAckListener(async (_msg, ack, nack) => {
278
+ receivedAck = ack;
279
+ receivedNack = nack;
280
+ ack();
281
+ });
282
+ await consumeCallback(makeMessage({ data: 1 }));
283
+
284
+ expect(typeof receivedAck).toBe('function');
285
+ expect(typeof receivedNack).toBe('function');
286
+ expect(mockChannel.ack).toHaveBeenCalled();
287
+ });
288
+
289
+ it('guarded ack — second call is no-op', async () => {
290
+ await setupManualAckListener(async (_msg, ack) => {
291
+ ack();
292
+ ack(); // second call — should be no-op
293
+ });
294
+ await consumeCallback(makeMessage({ data: 1 }));
295
+
296
+ expect(mockChannel.ack).toHaveBeenCalledTimes(1);
297
+ });
298
+
299
+ it('guarded nack — second call is no-op', async () => {
300
+ await setupManualAckListener(async (_msg, _ack, nack) => {
301
+ nack();
302
+ nack(); // second call — should be no-op
303
+ });
304
+ await consumeCallback(makeMessage({ data: 1 }));
305
+
306
+ // retryOrDLQ publishes to retry queue then acks — only one round
307
+ expect(mockChannel.publish).toHaveBeenCalledTimes(1);
308
+ expect(mockChannel.ack).toHaveBeenCalledTimes(1);
309
+ });
310
+
311
+ it('ack then nack — nack is no-op (first call wins)', async () => {
312
+ await setupManualAckListener(async (_msg, ack, nack) => {
313
+ ack();
314
+ nack(); // should be no-op since ack already settled
315
+ });
316
+ await consumeCallback(makeMessage({ data: 1 }));
317
+
318
+ expect(mockChannel.ack).toHaveBeenCalledTimes(1);
319
+ expect(mockChannel.publish).not.toHaveBeenCalled(); // no retry
320
+ });
321
+
322
+ it('handler throws without settling — retries via DLQ path', async () => {
323
+ await setupManualAckListener(async () => {
324
+ throw new Error('boom');
325
+ });
326
+ await consumeCallback(makeMessage({ data: 1 }));
327
+
328
+ // retryOrDLQ should have been called (publishes to retry queue)
329
+ expect(mockChannel.publish).toHaveBeenCalledWith(
330
+ '',
331
+ 'jobs.retry',
332
+ expect.any(Buffer),
333
+ expect.objectContaining({
334
+ persistent: true,
335
+ headers: expect.objectContaining({ 'x-retry-count': 1 }),
336
+ }),
337
+ );
338
+ expect(mockChannel.ack).toHaveBeenCalled();
339
+ });
340
+
341
+ it('handler resolves without settling — treated as failure', async () => {
342
+ await setupManualAckListener(async () => {
343
+ // deliberately not calling ack or nack
344
+ });
345
+ await consumeCallback(makeMessage({ data: 1 }));
346
+
347
+ // retryOrDLQ should have been called
348
+ expect(mockChannel.publish).toHaveBeenCalledWith(
349
+ '',
350
+ 'jobs.retry',
351
+ expect.any(Buffer),
352
+ expect.objectContaining({
353
+ headers: expect.objectContaining({ 'x-retry-count': 1 }),
354
+ }),
355
+ );
356
+ expect(mockChannel.ack).toHaveBeenCalled();
357
+ });
358
+
359
+ it('handler throws after acking — no double retry', async () => {
360
+ await setupManualAckListener(async (_msg, ack) => {
361
+ ack();
362
+ throw new Error('post-ack error');
363
+ });
364
+ await consumeCallback(makeMessage({ data: 1 }));
365
+
366
+ // ack was called but no retry should happen (already settled)
367
+ expect(mockChannel.ack).toHaveBeenCalledTimes(1);
368
+ expect(mockChannel.publish).not.toHaveBeenCalled();
369
+ });
370
+ });
371
+
372
+ describe('listen() prefetch', () => {
373
+ it('sets channel.prefetch when opts.prefetch is provided', async () => {
374
+ await MessageQueueService.listen('jobs', async () => true, { prefetch: 10 });
375
+ expect(mockChannel.prefetch).toHaveBeenCalledWith(10);
376
+ });
377
+
378
+ it('does not call prefetch when opts.prefetch is not provided', async () => {
379
+ await MessageQueueService.listen('jobs', async () => true);
380
+ expect(mockChannel.prefetch).not.toHaveBeenCalled();
381
+ });
382
+
383
+ it('sets prefetch before consuming', async () => {
384
+ const callOrder: string[] = [];
385
+ (mockChannel.prefetch as jest.Mock).mockImplementation(() => {
386
+ callOrder.push('prefetch');
387
+ return Promise.resolve(undefined);
388
+ });
389
+ (mockChannel.consume as jest.Mock).mockImplementation(() => {
390
+ callOrder.push('consume');
391
+ return Promise.resolve({ consumerTag: 'tag' });
392
+ });
393
+
394
+ await MessageQueueService.listen('jobs', async () => true, { prefetch: 5 });
395
+
396
+ expect(callOrder).toEqual(['prefetch', 'consume']);
397
+ });
398
+ });
399
+
400
+ describe('close()', () => {
401
+ it('closes all tracked connections', async () => {
402
+ // Create a listener connection
403
+ await MessageQueueService.listen('q', async () => true);
404
+
405
+ await MessageQueueService.close();
406
+ expect(mockConnection.close).toHaveBeenCalled();
407
+ });
408
+ });
409
+
410
+ describe('service shape', () => {
411
+ it('exports the required service interface', () => {
412
+ expect(MessageQueueService.name).toBe('message-queue');
413
+ expect(typeof MessageQueueService.init).toBe('function');
414
+ expect(typeof MessageQueueService.publish).toBe('function');
415
+ expect(typeof MessageQueueService.listen).toBe('function');
416
+ expect(typeof MessageQueueService.close).toBe('function');
417
+ });
418
+ });
419
+ });
@@ -0,0 +1,145 @@
1
+ # Background Jobs Design
2
+ When you need to add a background job consumer, publish messages, or work with the message queue infrastructure, follow these principles.
3
+
4
+ Assumes: controller design principles from [design.controllers.skill.md](../../cap-services/skills/design.controllers.skill.md) (services capability). MQ controllers follow the same thin-dispatch pattern — parse, call service, return result.
5
+
6
+ ## Adding a new consumer
7
+
8
+ Create a controller, register it in the worker. Two files to touch:
9
+
10
+ ### 1. Write the controller
11
+
12
+ Copy `src/controllers/example-job.controller.ts` and replace the handler body:
13
+
14
+ ```typescript
15
+ export const QUEUE_NAME = 'my-job';
16
+
17
+ export async function onMessage(msg: unknown): Promise<boolean> {
18
+ const payload = validatePayload(msg);
19
+
20
+ try {
21
+ await services.myService.process(payload);
22
+ return true; // ACK
23
+ } catch (err) {
24
+ console.error('[my-job] failed:', err);
25
+ return false; // retry with backoff
26
+ }
27
+ }
28
+ ```
29
+
30
+ **Error classification:**
31
+ - `return true` — Success, or permanent failure that retrying won't fix (bad input, missing entity)
32
+ - `return false` — Transient failure worth retrying (timeout, rate limit, temporary unavailability)
33
+ - `throw` — Unexpected error — retries, then DLQ after max attempts
34
+
35
+ ### 2. Register in worker.ts
36
+
37
+ ```typescript
38
+ import * as myJobController from './controllers/my-job.controller';
39
+
40
+ const consumers = [
41
+ { queue: exampleJobController.QUEUE_NAME, handler: exampleJobController.onMessage },
42
+ { queue: myJobController.QUEUE_NAME, handler: myJobController.onMessage },
43
+ ];
44
+ ```
45
+
46
+ That's it. The MQ service creates the queue topology (main + retry + DLQ) automatically on first listen.
47
+
48
+ ## Publishing messages
49
+
50
+ Use `MessageQueueService.publish(queue, payload)` from any process:
51
+
52
+ ```typescript
53
+ import MessageQueueService from './services/message-queue.service';
54
+
55
+ await MessageQueueService.publish('my-job', {
56
+ task: 'process-upload',
57
+ data: { fileId: 'abc123', userId: 'usr_456' },
58
+ });
59
+ ```
60
+
61
+ ### Where to publish from
62
+
63
+ | Source | When to use | Example |
64
+ |--------|------------|---------|
65
+ | **Service** (in web process) | Side effect of a user action | User uploads file → publish processing job |
66
+ | **Scheduler** | Periodic triggers on a cron schedule | Nightly cleanup, hourly sync |
67
+ | **Controller** (in worker) | Follow-up work from a completed job | Job A completes → publish Job B |
68
+
69
+ ### Adding a scheduled job
70
+
71
+ Add an entry to the `jobs` array in `src/scheduler.ts`:
72
+
73
+ ```typescript
74
+ const jobs = [
75
+ { queue: 'example-job', payload: { task: 'scheduled-run', data: { ... } } },
76
+ { queue: 'my-job', payload: { task: 'nightly-sync', data: { ... } } },
77
+ ];
78
+ ```
79
+
80
+ The scheduler publishes all jobs and exits. Configure Heroku Scheduler to run `npm run scheduler` at the desired interval.
81
+
82
+ ## Auto-ACK vs manual-ACK
83
+
84
+ **Use auto-ACK (default)** for most consumers. The handler returns `true`/`false` and the MQ service handles ACK/retry automatically. Simpler and safer.
85
+
86
+ **Use manual-ACK** when you need to:
87
+ - ACK after a batch of work completes (buffered batch pattern)
88
+ - Conditionally ACK based on external state that changes during processing
89
+ - Coordinate ACK with a transaction or external system commit
90
+
91
+ ```typescript
92
+ await MessageQueueService.listen('batch-job', async (msg, ack, nack) => {
93
+ try {
94
+ await processBatch(msg);
95
+ ack(); // explicit ACK after batch commit
96
+ } catch (err) {
97
+ nack(); // explicit retry/DLQ
98
+ }
99
+ }, { manualAck: true });
100
+ ```
101
+
102
+ **Guard rules:**
103
+ - First call to `ack()` or `nack()` wins — subsequent calls are silent no-ops
104
+ - If the handler resolves without calling either → treated as failure (retry/DLQ)
105
+ - If the handler throws after acking → no double-retry (already settled)
106
+
107
+ ## Prefetch and backpressure
108
+
109
+ Set `prefetch` to limit how many messages RabbitMQ delivers to a consumer before it ACKs:
110
+
111
+ ```typescript
112
+ await MessageQueueService.listen('heavy-job', handler, { prefetch: 5 });
113
+ ```
114
+
115
+ Use this for consumers that do expensive work (API calls, file processing) to prevent the worker from being overwhelmed. Without prefetch, RabbitMQ delivers all available messages as fast as the consumer can accept them.
116
+
117
+ ## Retry model
118
+
119
+ Failed messages follow exponential backoff:
120
+
121
+ ```
122
+ Attempt 1: 4s wait (4000 * 2^0)
123
+ Attempt 2: 8s wait (4000 * 2^1)
124
+ Attempt 3: 16s wait (4000 * 2^2)
125
+ Attempt 4: 32s wait (4000 * 2^3)
126
+ Attempt 5: 60s wait (capped at 60s)
127
+ → DLQ after MAX_MESSAGE_RETRIES (default 5)
128
+ ```
129
+
130
+ Messages in the DLQ (`{queue}.failed`) sit until manually inspected or reprocessed. Use the CloudAMQP management UI to inspect DLQ contents.
131
+
132
+ ## Queue naming
133
+
134
+ Queues are prefixed with `QUEUE_PREFIX` for environment isolation:
135
+
136
+ - Production: `QUEUE_PREFIX=` (empty) → `my-job`
137
+ - Dev: `QUEUE_PREFIX=dev/mike` → `dev/mike/my-job`
138
+
139
+ Set `QUEUE_PREFIX` in `.env` to avoid cross-talk between developers sharing a CloudAMQP instance.
140
+
141
+ ---
142
+
143
+ See also:
144
+ - [reference/background-jobs.ref.md](../reference/background-jobs.ref.md) for infrastructure topology and environment setup
145
+ - [reference/message-queue-patterns.ref.md](../../profiles/web-app/reference/message-queue-patterns.ref.md) for advanced patterns (buffered batch ACK, connection lifecycle)