@boopkit/cap-background-jobs 0.3.0 → 0.4.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.
- package/capability.json +43 -10
- package/dist/message-queue.service.d.ts +1 -1
- package/dist/message-queue.service.d.ts.map +1 -1
- package/dist/message-queue.service.js +50 -12
- package/dist/message-queue.service.js.map +1 -1
- package/package.json +4 -2
- package/scaffold/src/controllers/example-job.controller.ts +7 -3
- package/scaffold/src/scheduler.ts +19 -14
- package/scaffold/src/services/index.ts +4 -0
- package/scaffold/src/worker.ts +12 -8
- package/skills/{design.background-jobs.skill.md → boop-background-jobs-background-jobs-design/SKILL.md} +6 -1
package/capability.json
CHANGED
|
@@ -3,17 +3,43 @@
|
|
|
3
3
|
"description": "Background job processing with RabbitMQ via CloudAMQP",
|
|
4
4
|
"type": "application",
|
|
5
5
|
"package": "@boopkit/cap-background-jobs",
|
|
6
|
-
"requires": [
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
"requires": [
|
|
7
|
+
"services"
|
|
8
|
+
],
|
|
9
|
+
"dependencies": [
|
|
10
|
+
"amqplib@^0.10",
|
|
11
|
+
"minimist@^1.2",
|
|
12
|
+
"@boopkit/logger",
|
|
13
|
+
"winston@^3"
|
|
14
|
+
],
|
|
15
|
+
"devDependencies": [
|
|
16
|
+
"@types/amqplib@^0.10",
|
|
17
|
+
"@types/minimist@^1.2"
|
|
18
|
+
],
|
|
9
19
|
"env": [
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
{
|
|
21
|
+
"name": "CLOUDAMQP_URL",
|
|
22
|
+
"description": "CloudAMQP connection URL (amqp://...)",
|
|
23
|
+
"required": "boot"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "QUEUE_PREFIX",
|
|
27
|
+
"description": "Queue name prefix for environment isolation (e.g. dev/mike)"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "MAX_MESSAGE_RETRIES",
|
|
31
|
+
"description": "Maximum retry attempts before dead-lettering (default: 5)"
|
|
32
|
+
}
|
|
13
33
|
],
|
|
14
34
|
"scripts": {
|
|
15
|
-
"worker": {
|
|
16
|
-
|
|
35
|
+
"worker": {
|
|
36
|
+
"cmd": "tsx src/worker.ts",
|
|
37
|
+
"description": "Start the background job worker process"
|
|
38
|
+
},
|
|
39
|
+
"scheduler": {
|
|
40
|
+
"cmd": "tsx src/scheduler.ts",
|
|
41
|
+
"description": "Publish trigger messages to job queues — supports --interval and --job filtering"
|
|
42
|
+
}
|
|
17
43
|
},
|
|
18
44
|
"files": [
|
|
19
45
|
"src/scheduler.ts",
|
|
@@ -22,10 +48,14 @@
|
|
|
22
48
|
"tests/controllers/example-job.controller.test.ts",
|
|
23
49
|
"reference/background-jobs.ref.md"
|
|
24
50
|
],
|
|
25
|
-
"skills": [
|
|
51
|
+
"skills": [
|
|
52
|
+
"boop-background-jobs-background-jobs-design"
|
|
53
|
+
],
|
|
26
54
|
"hooks": [],
|
|
27
55
|
"postInstall": {
|
|
28
|
-
"prerequisites": [
|
|
56
|
+
"prerequisites": [
|
|
57
|
+
"CloudAMQP addon provisioned (or CLOUDAMQP_URL set)"
|
|
58
|
+
],
|
|
29
59
|
"steps": [
|
|
30
60
|
"Add CLOUDAMQP_URL to .env",
|
|
31
61
|
"Set QUEUE_PREFIX for local dev isolation (e.g. dev/yourname)",
|
|
@@ -33,5 +63,8 @@
|
|
|
33
63
|
"Configure Heroku Scheduler to run 'npm run scheduler -- run-jobs --interval <tag>' on desired interval (e.g. 10-minutes, 60-minutes)"
|
|
34
64
|
],
|
|
35
65
|
"verify": "npm run worker"
|
|
66
|
+
},
|
|
67
|
+
"skill_migrations": {
|
|
68
|
+
"boop-background-jobs-background-jobs-design": "design.background-jobs.skill.md"
|
|
36
69
|
}
|
|
37
70
|
}
|
|
@@ -42,6 +42,6 @@ interface ListenOpts {
|
|
|
42
42
|
* or throws without settling → retry/DLQ after max retries.
|
|
43
43
|
*/
|
|
44
44
|
declare function listen(queue: string, handler: (...args: any[]) => Promise<any>, opts?: ListenOpts): Promise<void>;
|
|
45
|
-
/** Close all tracked connections (publish +
|
|
45
|
+
/** Close all tracked connections (publish + listener). */
|
|
46
46
|
declare function close(): Promise<void>;
|
|
47
47
|
//# sourceMappingURL=message-queue.service.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"message-queue.service.d.ts","sourceRoot":"","sources":["../src/message-queue.service.ts"],"names":[],"mappings":"AAAA,qEAAqE;
|
|
1
|
+
{"version":3,"file":"message-queue.service.d.ts","sourceRoot":"","sources":["../src/message-queue.service.ts"],"names":[],"mappings":"AAAA,qEAAqE;AAyBrE,QAAA,MAAM,mBAAmB;;;;;;CAMxB,CAAC;AAEF,eAAe,mBAAmB,CAAC;AAEnC;;;;GAIG;AACH,iBAAS,IAAI,IAAI,IAAI,CAQpB;AAED;;;;GAIG;AACH,iBAAe,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CA0BxE;AAED,4BAA4B;AAC5B,UAAU,UAAU;IAClB,2FAA2F;IAC3F,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oFAAoF;IACpF,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;;;GAcG;AAEH,iBAAe,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA2EhH;AAED,0DAA0D;AAC1D,iBAAe,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAqCpC"}
|
|
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
};
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
const amqplib_1 = __importDefault(require("amqplib"));
|
|
8
|
+
const logger_1 = require("@boopkit/logger");
|
|
9
|
+
const logger = (0, logger_1.createLogger)('background-jobs');
|
|
8
10
|
const BACKOFF_BASE_MS = 4000;
|
|
9
11
|
const BACKOFF_MAX_MS = 60000;
|
|
10
12
|
/** Read env lazily — package imports run before dotenv/app boot. */
|
|
@@ -15,6 +17,8 @@ const connections = [];
|
|
|
15
17
|
/** Lazy-initialized publish connection and confirm channel. */
|
|
16
18
|
let publishConnection = null;
|
|
17
19
|
let publishChannel = null;
|
|
20
|
+
/** Lazy-initialized shared listener connection. */
|
|
21
|
+
let listenerConnection = null;
|
|
18
22
|
const MessageQueueService = {
|
|
19
23
|
name: 'message-queue',
|
|
20
24
|
init,
|
|
@@ -58,7 +62,7 @@ async function publish(queue, payload) {
|
|
|
58
62
|
return true;
|
|
59
63
|
}
|
|
60
64
|
catch (err) {
|
|
61
|
-
|
|
65
|
+
logger.error(`publish error on "${queue}"`, { err });
|
|
62
66
|
resetPublishConnection();
|
|
63
67
|
return false;
|
|
64
68
|
}
|
|
@@ -80,8 +84,7 @@ async function publish(queue, payload) {
|
|
|
80
84
|
*/
|
|
81
85
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
82
86
|
async function listen(queue, handler, opts) {
|
|
83
|
-
const connection = await
|
|
84
|
-
connections.push(connection);
|
|
87
|
+
const connection = await getListenerConnection();
|
|
85
88
|
const channel = await connection.createChannel();
|
|
86
89
|
const { mainQueue, retryQueue, failedQueue } = await assertTopology(channel, queue);
|
|
87
90
|
if (opts?.prefetch != null) {
|
|
@@ -97,7 +100,7 @@ async function listen(queue, handler, opts) {
|
|
|
97
100
|
}
|
|
98
101
|
catch {
|
|
99
102
|
// Poison message — ACK to prevent infinite redelivery
|
|
100
|
-
|
|
103
|
+
logger.error(`unparseable message on "${queue}", sending to DLQ`);
|
|
101
104
|
channel.nack(raw, false, false);
|
|
102
105
|
return;
|
|
103
106
|
}
|
|
@@ -106,7 +109,7 @@ async function listen(queue, handler, opts) {
|
|
|
106
109
|
let settled = false;
|
|
107
110
|
const guardedAck = () => {
|
|
108
111
|
if (settled) {
|
|
109
|
-
|
|
112
|
+
logger.warn(`duplicate ack on "${queue}" — ignored`);
|
|
110
113
|
return;
|
|
111
114
|
}
|
|
112
115
|
settled = true;
|
|
@@ -114,7 +117,7 @@ async function listen(queue, handler, opts) {
|
|
|
114
117
|
};
|
|
115
118
|
const guardedNack = async () => {
|
|
116
119
|
if (settled) {
|
|
117
|
-
|
|
120
|
+
logger.warn(`duplicate nack on "${queue}" — ignored`);
|
|
118
121
|
return;
|
|
119
122
|
}
|
|
120
123
|
settled = true;
|
|
@@ -123,12 +126,12 @@ async function listen(queue, handler, opts) {
|
|
|
123
126
|
try {
|
|
124
127
|
await handler(parsed, guardedAck, guardedNack);
|
|
125
128
|
if (!settled) {
|
|
126
|
-
|
|
129
|
+
logger.warn(`handler resolved without settling on "${queue}" — treating as failure`);
|
|
127
130
|
await retryOrDLQ(channel, raw, retryCount, retryQueue, failedQueue);
|
|
128
131
|
}
|
|
129
132
|
}
|
|
130
133
|
catch (err) {
|
|
131
|
-
|
|
134
|
+
logger.error(`handler threw on "${queue}"`, { err });
|
|
132
135
|
if (!settled) {
|
|
133
136
|
await retryOrDLQ(channel, raw, retryCount, retryQueue, failedQueue);
|
|
134
137
|
}
|
|
@@ -146,15 +149,15 @@ async function listen(queue, handler, opts) {
|
|
|
146
149
|
}
|
|
147
150
|
}
|
|
148
151
|
catch (err) {
|
|
149
|
-
|
|
152
|
+
logger.error(`handler threw on "${queue}"`, { err });
|
|
150
153
|
await retryOrDLQ(channel, raw, retryCount, retryQueue, failedQueue);
|
|
151
154
|
}
|
|
152
155
|
}
|
|
153
156
|
}, { noAck: false });
|
|
154
157
|
}
|
|
155
|
-
/** Close all tracked connections (publish +
|
|
158
|
+
/** Close all tracked connections (publish + listener). */
|
|
156
159
|
async function close() {
|
|
157
|
-
// Close the publish connection explicitly
|
|
160
|
+
// Close the publish connection explicitly
|
|
158
161
|
if (publishConnection) {
|
|
159
162
|
const conn = publishConnection;
|
|
160
163
|
publishConnection = null;
|
|
@@ -169,6 +172,20 @@ async function close() {
|
|
|
169
172
|
// Already closed — ignore
|
|
170
173
|
}
|
|
171
174
|
}
|
|
175
|
+
// Close the shared listener connection explicitly
|
|
176
|
+
if (listenerConnection) {
|
|
177
|
+
const conn = listenerConnection;
|
|
178
|
+
listenerConnection = null;
|
|
179
|
+
const idx = connections.indexOf(conn);
|
|
180
|
+
if (idx !== -1)
|
|
181
|
+
connections.splice(idx, 1);
|
|
182
|
+
try {
|
|
183
|
+
await conn.close();
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Already closed — ignore
|
|
187
|
+
}
|
|
188
|
+
}
|
|
172
189
|
const closing = connections.map(async (conn) => {
|
|
173
190
|
try {
|
|
174
191
|
await conn.close();
|
|
@@ -187,6 +204,27 @@ function prefixedName(name) {
|
|
|
187
204
|
const prefix = getQueuePrefix();
|
|
188
205
|
return prefix ? `${prefix}/${name}` : name;
|
|
189
206
|
}
|
|
207
|
+
/** Lazy-create or return the shared listener connection. */
|
|
208
|
+
async function getListenerConnection() {
|
|
209
|
+
if (listenerConnection)
|
|
210
|
+
return listenerConnection;
|
|
211
|
+
const conn = await amqplib_1.default.connect(process.env.CLOUDAMQP_URL);
|
|
212
|
+
connections.push(conn);
|
|
213
|
+
listenerConnection = conn;
|
|
214
|
+
conn.on('error', () => resetListenerConnection());
|
|
215
|
+
conn.on('close', () => resetListenerConnection());
|
|
216
|
+
return conn;
|
|
217
|
+
}
|
|
218
|
+
/** Clear cached listener connection so the next listen() call reconnects. */
|
|
219
|
+
function resetListenerConnection() {
|
|
220
|
+
if (listenerConnection) {
|
|
221
|
+
listenerConnection.close().catch(() => { });
|
|
222
|
+
const idx = connections.indexOf(listenerConnection);
|
|
223
|
+
if (idx !== -1)
|
|
224
|
+
connections.splice(idx, 1);
|
|
225
|
+
}
|
|
226
|
+
listenerConnection = null;
|
|
227
|
+
}
|
|
190
228
|
/** Lazy-create or return the existing publish confirm channel. */
|
|
191
229
|
async function getPublishChannel() {
|
|
192
230
|
if (publishChannel)
|
|
@@ -262,7 +300,7 @@ function computeBackoff(retryCount) {
|
|
|
262
300
|
async function retryOrDLQ(channel, raw, retryCount, retryQueue, failedQueue) {
|
|
263
301
|
const maxRetries = getMaxRetries();
|
|
264
302
|
if (retryCount >= maxRetries) {
|
|
265
|
-
|
|
303
|
+
logger.error(`[message-queue] max retries (${maxRetries}) exceeded, moving to DLQ: ${failedQueue}`);
|
|
266
304
|
channel.publish('', failedQueue, raw.content, {
|
|
267
305
|
persistent: true,
|
|
268
306
|
headers: { ...raw.properties.headers, 'x-retry-count': retryCount },
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"message-queue.service.js","sourceRoot":"","sources":["../src/message-queue.service.ts"],"names":[],"mappings":";AAAA,qEAAqE;;;;;AAErE,sDAA8B;
|
|
1
|
+
{"version":3,"file":"message-queue.service.js","sourceRoot":"","sources":["../src/message-queue.service.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boopkit/cap-background-jobs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Background job processing with RabbitMQ for boopkit-scaffolded projects",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
"build": "tsc -p tsconfig.json"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
+
"@boopkit/logger": "^0.4.0",
|
|
23
24
|
"amqplib": "^0.10.0",
|
|
24
|
-
"minimist": "^1.2.8"
|
|
25
|
+
"minimist": "^1.2.8",
|
|
26
|
+
"winston": "^3.17.0"
|
|
25
27
|
},
|
|
26
28
|
"devDependencies": {
|
|
27
29
|
"@types/amqplib": "^0.10.0",
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* uncaught throw → retry, then DLQ after max retries
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { createLogger } from '@boopkit/logger';
|
|
15
|
+
|
|
16
|
+
const logger = createLogger('background-jobs');
|
|
17
|
+
|
|
14
18
|
/** Queue name constant — used by worker.ts to register this consumer. */
|
|
15
19
|
export const QUEUE_NAME = 'example-job';
|
|
16
20
|
|
|
@@ -27,7 +31,7 @@ export async function onMessage(msg: unknown): Promise<boolean> {
|
|
|
27
31
|
payload = validatePayload(msg);
|
|
28
32
|
} catch (err) {
|
|
29
33
|
// Poison message — bad shape, retrying won't help
|
|
30
|
-
|
|
34
|
+
logger.error('invalid payload — ACKing to prevent retry loop', {
|
|
31
35
|
error: (err as Error).message,
|
|
32
36
|
});
|
|
33
37
|
return true;
|
|
@@ -37,11 +41,11 @@ export async function onMessage(msg: unknown): Promise<boolean> {
|
|
|
37
41
|
try {
|
|
38
42
|
// Replace this with your actual service call, e.g.:
|
|
39
43
|
// await services.myService.process(payload.task, payload.data);
|
|
40
|
-
|
|
44
|
+
logger.info(`processed: ${payload.task}`);
|
|
41
45
|
return true;
|
|
42
46
|
} catch (err) {
|
|
43
47
|
// Transient error — retry with backoff
|
|
44
|
-
|
|
48
|
+
logger.error('processing failed — retrying', {
|
|
45
49
|
error: (err as Error).message,
|
|
46
50
|
});
|
|
47
51
|
return false;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/** @boopkit/cap-background-jobs — Scheduler entry point for triggering background jobs. */
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { createLogger } from '@boopkit/logger';
|
|
4
|
+
// @ts-ignore - services scaffolded by cap-services
|
|
5
|
+
import { loadServices } from './services';
|
|
4
6
|
import minimist from 'minimist';
|
|
5
|
-
import MessageQueueService from '
|
|
7
|
+
import { MessageQueueService } from '@boopkit/cap-background-jobs';
|
|
6
8
|
|
|
7
9
|
interface JobConfig {
|
|
8
10
|
/** Human-readable job name (used for --job filtering). */
|
|
@@ -33,6 +35,8 @@ const jobs: JobConfig[] = [
|
|
|
33
35
|
},
|
|
34
36
|
];
|
|
35
37
|
|
|
38
|
+
const logger = createLogger('background-jobs');
|
|
39
|
+
|
|
36
40
|
/**
|
|
37
41
|
* Publish trigger messages for jobs matching the given filters.
|
|
38
42
|
*/
|
|
@@ -48,8 +52,8 @@ async function runJobs(
|
|
|
48
52
|
selected = selected.filter((j) => j.name === opts.job);
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
logger.info(
|
|
56
|
+
`${selected.length} job(s) selected` +
|
|
53
57
|
(opts.interval ? ` for interval "${opts.interval}"` : '') +
|
|
54
58
|
(opts.job ? ` matching job "${opts.job}"` : ''),
|
|
55
59
|
);
|
|
@@ -57,14 +61,15 @@ async function runJobs(
|
|
|
57
61
|
for (const job of selected) {
|
|
58
62
|
const ok = await MessageQueueService.publish(job.queue, job.payload);
|
|
59
63
|
if (ok) {
|
|
60
|
-
|
|
64
|
+
logger.info(`published to "${job.queue}": ${job.payload.task}`);
|
|
61
65
|
} else {
|
|
62
|
-
|
|
66
|
+
logger.error(`failed to publish to "${job.queue}"`);
|
|
63
67
|
}
|
|
64
68
|
}
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
function printUsage(): void {
|
|
72
|
+
// printUsage stays as console.log — it's CLI help text, not operational logging
|
|
68
73
|
console.log(`Usage: npm run scheduler -- <command> [options]
|
|
69
74
|
|
|
70
75
|
Commands:
|
|
@@ -101,9 +106,9 @@ async function main(args: string[], opts: Record<string, unknown>): Promise<void
|
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
case 'list': {
|
|
104
|
-
|
|
109
|
+
logger.info('registered jobs:');
|
|
105
110
|
for (const job of jobs) {
|
|
106
|
-
|
|
111
|
+
logger.info(` ${job.name} queue=${job.queue} interval=${job.interval}`);
|
|
107
112
|
}
|
|
108
113
|
break;
|
|
109
114
|
}
|
|
@@ -111,7 +116,7 @@ async function main(args: string[], opts: Record<string, unknown>): Promise<void
|
|
|
111
116
|
case 'publish': {
|
|
112
117
|
const queue = opts.queue as string | undefined;
|
|
113
118
|
if (!queue) {
|
|
114
|
-
|
|
119
|
+
logger.error('--queue is required for publish command');
|
|
115
120
|
process.exit(1);
|
|
116
121
|
}
|
|
117
122
|
const task = (opts.task as string) ?? 'cli';
|
|
@@ -120,22 +125,22 @@ async function main(args: string[], opts: Record<string, unknown>): Promise<void
|
|
|
120
125
|
try {
|
|
121
126
|
data = JSON.parse(opts.data as string);
|
|
122
127
|
} catch {
|
|
123
|
-
|
|
128
|
+
logger.error('--data must be valid JSON');
|
|
124
129
|
process.exit(1);
|
|
125
130
|
}
|
|
126
131
|
}
|
|
127
132
|
const ok = await MessageQueueService.publish(queue, { task, data });
|
|
128
133
|
if (ok) {
|
|
129
|
-
|
|
134
|
+
logger.info(`published to "${queue}": ${task}`);
|
|
130
135
|
} else {
|
|
131
|
-
|
|
136
|
+
logger.error(`failed to publish to "${queue}"`);
|
|
132
137
|
process.exit(1);
|
|
133
138
|
}
|
|
134
139
|
break;
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
default:
|
|
138
|
-
|
|
143
|
+
logger.error(`unknown command: ${command}`);
|
|
139
144
|
printUsage();
|
|
140
145
|
process.exit(1);
|
|
141
146
|
}
|
|
@@ -146,6 +151,6 @@ async function main(args: string[], opts: Record<string, unknown>): Promise<void
|
|
|
146
151
|
|
|
147
152
|
const argv = minimist(process.argv.slice(2));
|
|
148
153
|
main(argv._, argv).catch((err) => {
|
|
149
|
-
|
|
154
|
+
logger.error('failed', { err });
|
|
150
155
|
process.exit(1);
|
|
151
156
|
});
|
package/scaffold/src/worker.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/** @boopkit/cap-background-jobs — Worker entry point for background job processing. */
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import { createLogger } from '@boopkit/logger';
|
|
4
|
+
// @ts-ignore - services scaffolded by cap-services
|
|
5
|
+
import { loadServices } from './services';
|
|
6
|
+
import { MessageQueueService } from '@boopkit/cap-background-jobs';
|
|
5
7
|
import * as exampleJobController from './controllers/example-job.controller';
|
|
6
8
|
|
|
9
|
+
const logger = createLogger('background-jobs');
|
|
10
|
+
|
|
7
11
|
// Static consumer registration — add new {queue, handler} entries here
|
|
8
12
|
const consumers = [
|
|
9
13
|
{ queue: exampleJobController.QUEUE_NAME, handler: exampleJobController.onMessage },
|
|
@@ -19,29 +23,29 @@ async function boot() {
|
|
|
19
23
|
await MessageQueueService.listen(consumer.queue, consumer.handler);
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
logger.info(`listening on ${consumers.length} queue(s)`);
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
async function shutdown(signal: string) {
|
|
26
30
|
if (shuttingDown) return;
|
|
27
31
|
shuttingDown = true;
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
logger.info(`${signal} received — starting graceful shutdown`);
|
|
30
34
|
|
|
31
35
|
// Drain in-flight messages with timeout
|
|
32
36
|
const drainTimer = setTimeout(() => {
|
|
33
|
-
|
|
37
|
+
logger.error('drain timeout exceeded — forcing exit');
|
|
34
38
|
process.exit(1);
|
|
35
39
|
}, DRAIN_TIMEOUT_MS);
|
|
36
40
|
|
|
37
41
|
try {
|
|
38
42
|
await MessageQueueService.close();
|
|
39
43
|
clearTimeout(drainTimer);
|
|
40
|
-
|
|
44
|
+
logger.info('shutdown complete');
|
|
41
45
|
process.exit(0);
|
|
42
46
|
} catch (err) {
|
|
43
47
|
clearTimeout(drainTimer);
|
|
44
|
-
|
|
48
|
+
logger.error('error during shutdown', { err });
|
|
45
49
|
process.exit(1);
|
|
46
50
|
}
|
|
47
51
|
}
|
|
@@ -50,7 +54,7 @@ process.on('SIGINT', () => shutdown('SIGINT'));
|
|
|
50
54
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
51
55
|
|
|
52
56
|
boot().catch((err) => {
|
|
53
|
-
|
|
57
|
+
logger.error('boot failed', { err });
|
|
54
58
|
process.exit(1);
|
|
55
59
|
});
|
|
56
60
|
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: boop-background-jobs-background-jobs-design
|
|
3
|
+
description: "When you need to add a background job consumer, publish messages, or work with the message queue infrastructure, follow these principles."
|
|
4
|
+
metadata:
|
|
5
|
+
category: design
|
|
6
|
+
---
|
|
1
7
|
# 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
8
|
|
|
4
9
|
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
10
|
|