@vida-global/core 2.3.6 → 2.4.1
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/index.js +10 -8
- package/lib/appDependencies/index.js +48 -0
- package/lib/http/README.md +1 -1
- package/lib/http/client.js +2 -2
- package/lib/jobQueue/README.md +56 -17
- package/lib/jobQueue/abstractJob.js +39 -3
- package/lib/jobQueue/queue.js +28 -20
- package/lib/jobQueue/worker.js +69 -22
- package/lib/logger/README.md +12 -0
- package/lib/logger/index.js +24 -8
- package/lib/server/README.md +32 -1
- package/lib/server/controllerMixins/renderer.js +11 -0
- package/lib/server/doc/renderer.md +23 -0
- package/package.json +1 -1
- package/test/appDependencies/appDependencies.test.js +93 -0
- package/test/http/client.test.js +21 -0
- package/test/jobQueue/abstractJob.test.js +64 -5
- package/test/jobQueue/helpers/bullmqMock.js +19 -16
- package/test/jobQueue/helpers/fixtureJobs/defaultJobForBeta.js +18 -0
- package/test/jobQueue/helpers/loggerMock.js +16 -5
- package/test/jobQueue/helpers/queue.js +2 -4
- package/test/jobQueue/helpers/worker.js +10 -3
- package/test/jobQueue/queue.test.js +80 -34
- package/test/jobQueue/worker.test.js +147 -21
- package/test/logger/index.test.js +113 -0
- package/test/server/controllerMixins/renderer.test.js +59 -0
package/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
const ActiveRecord
|
|
2
|
-
const httpLibs
|
|
3
|
-
const APM
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
1
|
+
const ActiveRecord = require('./lib/activeRecord');
|
|
2
|
+
const httpLibs = require('./lib/http/client');
|
|
3
|
+
const APM = require('./lib/apm');
|
|
4
|
+
const { AppDependencies } = require('./lib/appDependencies');
|
|
5
|
+
const JobQueue = require('./lib/jobQueue');
|
|
6
|
+
const { logger } = require('./lib/logger');
|
|
7
|
+
const redisLibs = require('./lib/redis');
|
|
8
|
+
const cacheLibs = require('./lib/cache');
|
|
9
|
+
const serverLibs = require('./lib/server');
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
const {
|
|
@@ -27,6 +28,7 @@ module.exports = {
|
|
|
27
28
|
...redisLibs,
|
|
28
29
|
...serverErrorsToExport,
|
|
29
30
|
ApiDocsGenerator,
|
|
31
|
+
AppDependencies,
|
|
30
32
|
VidaServer,
|
|
31
33
|
VidaServerController,
|
|
32
34
|
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
class AppDependencies {
|
|
2
|
+
#dependencies = {};
|
|
3
|
+
#controllerClass = null;
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
installOn(controllerClass) {
|
|
7
|
+
this.#controllerClass = controllerClass;
|
|
8
|
+
for (const name of Object.keys(this.#dependencies)) this.#defineAccessor(name);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
setDependency(name, dependency) {
|
|
13
|
+
this.#dependencies[name] = dependency;
|
|
14
|
+
this.#defineAccessor(name);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
get(name) {
|
|
19
|
+
return this.#dependencies[name] ?? null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
// Mainly for tests, which register their own doubles and must not leak them into the next one.
|
|
24
|
+
reset() {
|
|
25
|
+
this.#dependencies = {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
// Registering after `installOn` still defines the accessor, so wiring order does not matter.
|
|
30
|
+
// An existing property is left alone: a controller that defines the name itself wins.
|
|
31
|
+
#defineAccessor(name) {
|
|
32
|
+
if (!this.#controllerClass) return;
|
|
33
|
+
|
|
34
|
+
const prototype = this.#controllerClass.prototype;
|
|
35
|
+
if (Object.getOwnPropertyDescriptor(prototype, name)) return;
|
|
36
|
+
|
|
37
|
+
const dependencies = this;
|
|
38
|
+
Object.defineProperty(prototype, name, {
|
|
39
|
+
configurable: false,
|
|
40
|
+
get() { return dependencies.get(name); }
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
AppDependencies: new AppDependencies()
|
|
48
|
+
};
|
package/lib/http/README.md
CHANGED
|
@@ -54,7 +54,7 @@ const response = await client.getTickets({ page: 1, pageSize: 25 });
|
|
|
54
54
|
| `put(endpoint, opts)` | `body`, `headers`, `timeout`, `signal` |
|
|
55
55
|
| `delete(endpoint, opts)` | `requestParams`, `headers`, `timeout`, `signal` |
|
|
56
56
|
|
|
57
|
-
For `GET`/`DELETE`, `requestParams` becomes the query string. For `POST`/`PUT`, `body` becomes the request body
|
|
57
|
+
For `GET`/`DELETE`, `requestParams` becomes the query string. For `POST`/`PUT`, `body` becomes the request body. Object bodies are serialized as JSON; string bodies are sent unchanged.
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
### Query string encoding
|
package/lib/http/client.js
CHANGED
|
@@ -37,7 +37,7 @@ class HttpClient {
|
|
|
37
37
|
|
|
38
38
|
endpoint = `${this.urlRoot}${endpoint}`;
|
|
39
39
|
const url = new URL(endpoint);
|
|
40
|
-
const requestData = structuredClone(requestParams
|
|
40
|
+
const requestData = structuredClone(requestParams ?? body ?? {});
|
|
41
41
|
const payload = this.#prepareRequestPayload(url, requestData, headers, method, timeout, signal);
|
|
42
42
|
|
|
43
43
|
try {
|
|
@@ -124,7 +124,7 @@ class HttpClient {
|
|
|
124
124
|
|
|
125
125
|
#addRequestData(payload, requestData, method, url, headers) {
|
|
126
126
|
if (method == "POST" || method == "PUT") {
|
|
127
|
-
payload.body = JSON.stringify(requestData
|
|
127
|
+
payload.body = typeof requestData == 'string' ? requestData : JSON.stringify(requestData);
|
|
128
128
|
} else {
|
|
129
129
|
this.#addUrlRequestData(url, requestData, headers);
|
|
130
130
|
}
|
package/lib/jobQueue/README.md
CHANGED
|
@@ -53,6 +53,9 @@ Inside `run()`:
|
|
|
53
53
|
- `this.id` — UUID for the job.
|
|
54
54
|
- `this.logger` — child logger tagged with the job's id.
|
|
55
55
|
- `await this.updateProgress(value)` — pushes progress to BullMQ for observation.
|
|
56
|
+
- `this.attempt`, `this.maxAttempts`, `this.isFinalAttempt` — which try this is. Use
|
|
57
|
+
`isFinalAttempt` when a failure needs cleaning up once the retries are spent, rather than on every
|
|
58
|
+
attempt. Run outside a worker they read as a single final attempt, since nothing will retry.
|
|
56
59
|
|
|
57
60
|
Enqueue a job with `queueJob`:
|
|
58
61
|
|
|
@@ -107,12 +110,48 @@ class CatchAllJob extends AbstractJob {
|
|
|
107
110
|
```
|
|
108
111
|
|
|
109
112
|
|
|
110
|
-
## Concurrency
|
|
113
|
+
## Concurrency, queue maximums and rate limits
|
|
111
114
|
|
|
112
|
-
|
|
115
|
+
```js
|
|
116
|
+
const worker = new JobQueue.Worker('myQueue', {
|
|
117
|
+
concurrency: 5, // jobs at once in THIS worker
|
|
118
|
+
maximumConcurrency: 8, // jobs at once across EVERY worker on the queue
|
|
119
|
+
rateLimit: { max: 100, duration: 60000 } // jobs per period across every worker
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**`concurrency`** is per worker. Two processes at 5 each run 10 at once. It falls back to the
|
|
124
|
+
`WORKER_CONCURRENCY` environment variable, then to 1, so a process running a single worker can be
|
|
125
|
+
configured without touching code.
|
|
126
|
+
|
|
127
|
+
**`maximumConcurrency`** is the cap across every worker on the queue, enforced by Redis, so it does
|
|
128
|
+
what concurrency alone cannot. It is applied on `listen()` and **persists** — a worker started
|
|
129
|
+
without one clears it, so the value in Redis always matches the code running.
|
|
130
|
+
|
|
131
|
+
**`rateLimit`** is also enforced queue-wide, through a Redis key on the queue. Every worker on a
|
|
132
|
+
queue therefore shares one limit, and two workers passing different values will fight — the last to
|
|
133
|
+
start wins. A job that needs its own rate belongs on its own queue. There is no per-job or per-group
|
|
134
|
+
rate limiting in the open source BullMQ; that is a Pro feature.
|
|
135
|
+
|
|
136
|
+
Both `maximumConcurrency` and `rateLimit` are logged when applied, so the value in force is visible
|
|
137
|
+
in the worker's own output.
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
## Shutting down
|
|
113
141
|
|
|
114
|
-
|
|
115
|
-
|
|
142
|
+
`close()` waits for jobs already in flight before resolving, which is what stops a deploy killing a
|
|
143
|
+
job halfway through. Core installs no signal handlers — the application owns the process:
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
const worker = new JobQueue.Worker('myQueue');
|
|
147
|
+
worker.listen();
|
|
148
|
+
|
|
149
|
+
for (const signal of ['SIGTERM', 'SIGINT']) {
|
|
150
|
+
process.on(signal, async () => {
|
|
151
|
+
await worker.close();
|
|
152
|
+
process.exit(0);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
116
155
|
```
|
|
117
156
|
|
|
118
157
|
|
|
@@ -130,27 +169,27 @@ await queue.close(); // close the connection (call before process exit)
|
|
|
130
169
|
await queue.pause(); // stop dispatching work
|
|
131
170
|
await queue.resume(); // resume after pause
|
|
132
171
|
|
|
133
|
-
await queue.
|
|
172
|
+
await queue.getJobCounts(); // { waiting, active, completed, failed, delayed, paused }
|
|
173
|
+
await queue.numWorkers(); // number of workers currently attached
|
|
174
|
+
|
|
134
175
|
await queue.getQueuedJobs(); // formatted snapshot of waiting jobs
|
|
135
|
-
await queue.numActiveJobs();
|
|
136
176
|
await queue.getActiveJobs();
|
|
137
|
-
await queue.numFailedJobs();
|
|
138
177
|
await queue.getFailedJobs();
|
|
178
|
+
await queue.getJob(id); // one job, or null
|
|
139
179
|
|
|
140
|
-
await queue.
|
|
141
|
-
await queue.
|
|
142
|
-
await queue.clearQueuedJobs(); // drain all queued jobs
|
|
180
|
+
await queue.clean(grace, limit, type); // remove finished jobs older than `grace` ms
|
|
181
|
+
await queue.clearQueuedJobs(); // drain all queued jobs
|
|
143
182
|
|
|
144
|
-
await queue.
|
|
183
|
+
await queue.setMaximumConcurrency(8); // usually left to the Worker option above
|
|
184
|
+
await queue.maximumConcurrency();
|
|
185
|
+
await queue.removeMaximumConcurrency();
|
|
145
186
|
```
|
|
146
187
|
|
|
147
|
-
|
|
188
|
+
`clean` takes BullMQ's argument order, which is `(grace, limit, type)`. Bull's was
|
|
189
|
+
`(grace, type, limit)` — worth checking when porting a call.
|
|
190
|
+
|
|
191
|
+
Each `get*Jobs` and `getJob` result has this shape:
|
|
148
192
|
|
|
149
193
|
```js
|
|
150
194
|
{ queueName, name, args, id, attemptsMade, attemptsStarted, progress, failedReason }
|
|
151
195
|
```
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
## TODO
|
|
155
|
-
|
|
156
|
-
- **Rate limiting.** Pre-job rate limiting is not yet wired up.
|
|
@@ -7,9 +7,16 @@ const DEFAULT_NUM_RETRIES = 3;
|
|
|
7
7
|
const DEFAULT_RETRY_BACKOFF_TYPE = 'exponential';
|
|
8
8
|
const DEFAULT_RETRY_BACKOFF_DELAY = 2000;
|
|
9
9
|
|
|
10
|
+
// BullMQ keeps every finished job record forever unless told otherwise, so without these Redis
|
|
11
|
+
// grows without bound. A bounded window keeps recent history inspectable and lets the rest go.
|
|
12
|
+
const DEFAULT_KEEP_COMPLETED = { age: 86400, count: 1000 }; // a day, or the last thousand
|
|
13
|
+
const DEFAULT_KEEP_FAILED = { age: 604800 }; // a week
|
|
14
|
+
|
|
10
15
|
|
|
11
16
|
class AbstractJob {
|
|
17
|
+
#attempt;
|
|
12
18
|
#id;
|
|
19
|
+
#maxAttempts;
|
|
13
20
|
#updateProgress;
|
|
14
21
|
|
|
15
22
|
|
|
@@ -36,9 +43,11 @@ class AbstractJob {
|
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
|
|
39
|
-
async _run(id, args, updateProgress) {
|
|
46
|
+
async _run(id, args, updateProgress, { attempt = 1, maxAttempts = 1 } = {}) {
|
|
40
47
|
this.#id = id;
|
|
41
48
|
this.#updateProgress = updateProgress;
|
|
49
|
+
this.#attempt = attempt;
|
|
50
|
+
this.#maxAttempts = maxAttempts;
|
|
42
51
|
await this.run(...args);
|
|
43
52
|
}
|
|
44
53
|
|
|
@@ -48,6 +57,21 @@ class AbstractJob {
|
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
|
|
60
|
+
get attempt() {
|
|
61
|
+
return this.#attempt;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
get maxAttempts() {
|
|
66
|
+
return this.#maxAttempts;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
get isFinalAttempt() {
|
|
71
|
+
return this.#attempt >= this.#maxAttempts;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
51
75
|
get loggerLibrary() {
|
|
52
76
|
return logger;
|
|
53
77
|
}
|
|
@@ -59,7 +83,7 @@ class AbstractJob {
|
|
|
59
83
|
|
|
60
84
|
|
|
61
85
|
get logger() {
|
|
62
|
-
return this.loggerLibrary
|
|
86
|
+
return this.loggerLibrary.scope(this.constructor.name).child(this.id);
|
|
63
87
|
}
|
|
64
88
|
|
|
65
89
|
|
|
@@ -90,7 +114,9 @@ class AbstractJob {
|
|
|
90
114
|
backoff: {
|
|
91
115
|
type: this.retryBackoffType,
|
|
92
116
|
delay: this.retryBackoffDelay
|
|
93
|
-
}
|
|
117
|
+
},
|
|
118
|
+
removeOnComplete: this.keepCompleted,
|
|
119
|
+
removeOnFail: this.keepFailed
|
|
94
120
|
}
|
|
95
121
|
|
|
96
122
|
return settings;
|
|
@@ -102,6 +128,16 @@ class AbstractJob {
|
|
|
102
128
|
}
|
|
103
129
|
|
|
104
130
|
|
|
131
|
+
static get keepCompleted() {
|
|
132
|
+
return DEFAULT_KEEP_COMPLETED;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
static get keepFailed() {
|
|
137
|
+
return DEFAULT_KEEP_FAILED;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
105
141
|
static get retryBackoffType() {
|
|
106
142
|
return DEFAULT_RETRY_BACKOFF_TYPE;
|
|
107
143
|
}
|
package/lib/jobQueue/queue.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const { AbstractJobComponent } = require('./abstractJobComponent');
|
|
2
2
|
const BullMQ = require('bullmq');
|
|
3
3
|
const { logger } = require('../logger');
|
|
4
|
-
const { randomUUID } = require('crypto');
|
|
5
4
|
|
|
6
5
|
|
|
7
6
|
logger.addScope('queue');
|
|
@@ -22,12 +21,10 @@ class Queue extends AbstractJobComponent {
|
|
|
22
21
|
get loggerLibrary() { return logger }
|
|
23
22
|
get logger() { return this.loggerLibrary.queue.child(this.fullQueueName) }
|
|
24
23
|
get queueClass() { return BullMQ.Queue }
|
|
25
|
-
get idGenerator() { return randomUUID }
|
|
26
24
|
|
|
27
25
|
|
|
28
26
|
async queueJob(job, args, settings) {
|
|
29
|
-
|
|
30
|
-
await this.#queue.add(job.name, { id, args }, settings);
|
|
27
|
+
await this.#queue.add(job.name, { args }, settings);
|
|
31
28
|
}
|
|
32
29
|
|
|
33
30
|
|
|
@@ -67,11 +64,29 @@ class Queue extends AbstractJobComponent {
|
|
|
67
64
|
}
|
|
68
65
|
|
|
69
66
|
|
|
67
|
+
/***********************************************************************************************
|
|
68
|
+
* LIMITS
|
|
69
|
+
***********************************************************************************************/
|
|
70
|
+
async setMaximumConcurrency(maximum) {
|
|
71
|
+
await this.#queue.setGlobalConcurrency(maximum);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async maximumConcurrency() {
|
|
76
|
+
return await this.#queue.getGlobalConcurrency();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async removeMaximumConcurrency() {
|
|
81
|
+
await this.#queue.removeGlobalConcurrency();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
70
85
|
/***********************************************************************************************
|
|
71
86
|
* JOBS
|
|
72
87
|
***********************************************************************************************/
|
|
73
|
-
async
|
|
74
|
-
return await this.#queue.
|
|
88
|
+
async getJobCounts() {
|
|
89
|
+
return await this.#queue.getJobCounts();
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
|
|
@@ -81,35 +96,28 @@ class Queue extends AbstractJobComponent {
|
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
|
|
84
|
-
async numActiveJobs() {
|
|
85
|
-
return await this.#queue.getActiveCount();
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
99
|
async getActiveJobs() {
|
|
90
100
|
const jobs = await this.#queue.getActive();
|
|
91
101
|
return this.#_formatJobsData(jobs);
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
|
|
95
|
-
async numFailedJobs() {
|
|
96
|
-
return await this.#queue.getFailedCount();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
105
|
async getFailedJobs() {
|
|
101
106
|
const jobs = await this.#queue.getFailed();
|
|
102
107
|
return this.#_formatJobsData(jobs);
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
|
|
106
|
-
async
|
|
107
|
-
await this.#queue.
|
|
111
|
+
async getJob(id) {
|
|
112
|
+
const job = await this.#queue.getJob(id);
|
|
113
|
+
return job ? this.#_formatJobData(job) : null;
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
|
|
111
|
-
|
|
112
|
-
|
|
117
|
+
// `grace` is in milliseconds and only jobs older than that are removed. Note the argument order
|
|
118
|
+
// differs from bull's `clean(grace, type, limit)`.
|
|
119
|
+
async clean(grace, limit, type) {
|
|
120
|
+
return await this.#queue.clean(grace, limit, type);
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
|
package/lib/jobQueue/worker.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const APM = require('../apm');
|
|
2
2
|
const { AbstractJobComponent } = require('./abstractJobComponent');
|
|
3
3
|
const BullMQ = require('bullmq');
|
|
4
|
+
const { getQueue } = require('./queue');
|
|
4
5
|
const { JobImporter } = require('./jobImporter');
|
|
5
6
|
const { logger } = require('../logger');
|
|
6
7
|
const { randomUUID } = require('crypto');
|
|
@@ -21,36 +22,51 @@ let registeredJobs;
|
|
|
21
22
|
|
|
22
23
|
class Worker extends AbstractJobComponent {
|
|
23
24
|
#defaultJob;
|
|
25
|
+
#options;
|
|
24
26
|
#queueName;
|
|
25
27
|
#_worker;
|
|
26
28
|
|
|
27
29
|
|
|
28
|
-
constructor(queueName) {
|
|
30
|
+
constructor(queueName, options = {}) {
|
|
29
31
|
super();
|
|
30
32
|
this.#queueName = queueName || process.env.WORKER_QUEUE_NAME;
|
|
33
|
+
this.#options = options;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
|
|
34
37
|
async listen() {
|
|
35
38
|
await this.#registerJobs();
|
|
39
|
+
await this.#applyQueueLimits();
|
|
36
40
|
this.logger.info(`Worker is listening on queue ${this.fullQueueName}`);
|
|
37
41
|
await this.#worker.run();
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
|
|
45
|
+
// Waits for jobs already in flight. Core installs no signal handlers of its own — the
|
|
46
|
+
// application owns the process and decides when this is called.
|
|
47
|
+
async close() {
|
|
48
|
+
await this.#worker.close();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
41
52
|
#registerJobs() {
|
|
42
|
-
|
|
53
|
+
registeredJobs ||= this.#importJobs();
|
|
54
|
+
|
|
55
|
+
this.#defaultJob = Object.values(registeredJobs)
|
|
56
|
+
.find(job => job.defaultFor == this.queueName) || null;
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
|
|
44
|
-
|
|
45
|
-
const jobs
|
|
46
|
-
registeredJobs = Object.fromEntries(jobs.map(job => [job.name, job]));
|
|
47
|
-
this.#defaultJob = jobs.find(job => job.defaultFor == this.queueName) || null;
|
|
60
|
+
#importJobs() {
|
|
61
|
+
const jobs = new JobImporter(this.jobDirectories).jobs;
|
|
48
62
|
|
|
49
63
|
if (process.env.NODE_ENV != 'test') {
|
|
50
64
|
for (const job of jobs) {
|
|
51
65
|
this.logger.verbose(`JOB: ${job.name}`);
|
|
52
66
|
}
|
|
53
67
|
}
|
|
68
|
+
|
|
69
|
+
return Object.fromEntries(jobs.map(job => [job.name, job]));
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
|
|
@@ -100,13 +116,24 @@ class Worker extends AbstractJobComponent {
|
|
|
100
116
|
concurrency: this.concurrency,
|
|
101
117
|
connection: this.redisConnectionDetails
|
|
102
118
|
};
|
|
119
|
+
if (this.rateLimit) options.limiter = this.rateLimit;
|
|
103
120
|
|
|
104
121
|
return options;
|
|
105
122
|
}
|
|
106
123
|
|
|
107
124
|
|
|
125
|
+
// How many jobs this one worker runs at once. The constructor option wins so several workers in
|
|
126
|
+
// a process can differ; the environment variable is the default for any that says nothing.
|
|
108
127
|
get concurrency() {
|
|
109
|
-
|
|
128
|
+
return parseInt(this.#options.concurrency || process.env.WORKER_CONCURRENCY || 1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
// `{max, duration}`. BullMQ enforces this through a Redis key on the queue, so it is shared by
|
|
133
|
+
// every worker listening on it — two workers with different values fight and the last to start
|
|
134
|
+
// wins. A job that needs its own rate gets its own queue.
|
|
135
|
+
get rateLimit() {
|
|
136
|
+
return this.#options.rateLimit || null;
|
|
110
137
|
}
|
|
111
138
|
|
|
112
139
|
|
|
@@ -115,6 +142,28 @@ class Worker extends AbstractJobComponent {
|
|
|
115
142
|
}
|
|
116
143
|
|
|
117
144
|
|
|
145
|
+
get queue() {
|
|
146
|
+
return getQueue(this.queueName);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async #applyQueueLimits() {
|
|
151
|
+
const maximumConcurrency = this.#options.maximumConcurrency ?? null;
|
|
152
|
+
|
|
153
|
+
if (maximumConcurrency === null) {
|
|
154
|
+
await this.queue.removeMaximumConcurrency();
|
|
155
|
+
} else {
|
|
156
|
+
await this.queue.setMaximumConcurrency(maximumConcurrency);
|
|
157
|
+
this.logger.info(`Queue ${this.fullQueueName} maximum concurrency is ${maximumConcurrency}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (this.rateLimit) {
|
|
161
|
+
const { max, duration } = this.rateLimit;
|
|
162
|
+
this.logger.info(`Queue ${this.fullQueueName} rate limit is ${max} per ${duration}ms`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
118
167
|
/***********************************************************************************************
|
|
119
168
|
* PROCESS JOB
|
|
120
169
|
***********************************************************************************************/
|
|
@@ -130,13 +179,12 @@ class Worker extends AbstractJobComponent {
|
|
|
130
179
|
|
|
131
180
|
|
|
132
181
|
#buildJobContext(bullJob, jobClass) {
|
|
133
|
-
const { id, args } = this.#dataForJob(bullJob);
|
|
134
182
|
return {
|
|
135
183
|
bullJob,
|
|
136
184
|
jobClass,
|
|
137
185
|
jobName: jobClass.name,
|
|
138
|
-
id,
|
|
139
|
-
args,
|
|
186
|
+
id: bullJob.id,
|
|
187
|
+
args: this.#argsForJob(bullJob),
|
|
140
188
|
queueName: this.queueName,
|
|
141
189
|
attempt: bullJob.attemptsMade + 1,
|
|
142
190
|
maxAttempts: bullJob.opts.attempts,
|
|
@@ -162,11 +210,14 @@ class Worker extends AbstractJobComponent {
|
|
|
162
210
|
|
|
163
211
|
|
|
164
212
|
async #executeJob(context) {
|
|
165
|
-
const { bullJob, jobClass, id, args, jobName } = context;
|
|
213
|
+
const { bullJob, jobClass, id, args, jobName, attempt, maxAttempts } = context;
|
|
166
214
|
const job = new jobClass();
|
|
167
215
|
await this.apm.startSegment(`job:${jobName}`,
|
|
168
216
|
true,
|
|
169
|
-
() => job._run(id,
|
|
217
|
+
() => job._run(id,
|
|
218
|
+
args,
|
|
219
|
+
bullJob.updateProgress.bind(bullJob),
|
|
220
|
+
{ attempt, maxAttempts }));
|
|
170
221
|
}
|
|
171
222
|
|
|
172
223
|
|
|
@@ -227,8 +278,8 @@ class Worker extends AbstractJobComponent {
|
|
|
227
278
|
}
|
|
228
279
|
|
|
229
280
|
|
|
230
|
-
#handleJobCompletion(
|
|
231
|
-
const logger = this.#loggerForJob(
|
|
281
|
+
#handleJobCompletion(job, duration) {
|
|
282
|
+
const logger = this.#loggerForJob(job);
|
|
232
283
|
logger.debug(`Completed in ${duration}ms`);
|
|
233
284
|
}
|
|
234
285
|
|
|
@@ -240,8 +291,6 @@ class Worker extends AbstractJobComponent {
|
|
|
240
291
|
|
|
241
292
|
|
|
242
293
|
#handleJobFailure(job, err) {
|
|
243
|
-
const log = `Job Failed (Attempt ${job.attemptsMade}/${job.opts.attempts}): ${job.name} - ${err}`;
|
|
244
|
-
|
|
245
294
|
const lastRetry = job.attemptsMade == job.opts.attempts;
|
|
246
295
|
const logger = this.#loggerForJob(job);
|
|
247
296
|
logger.debug(`ERROR (${job.attemptsMade}/${job.opts.attempts}) ${err}`);
|
|
@@ -261,8 +310,7 @@ class Worker extends AbstractJobComponent {
|
|
|
261
310
|
|
|
262
311
|
|
|
263
312
|
#loggerForJob(bullJob) {
|
|
264
|
-
|
|
265
|
-
return this.logger.child(id);
|
|
313
|
+
return this.logger.child(bullJob.id);
|
|
266
314
|
}
|
|
267
315
|
|
|
268
316
|
|
|
@@ -272,12 +320,11 @@ class Worker extends AbstractJobComponent {
|
|
|
272
320
|
}
|
|
273
321
|
|
|
274
322
|
|
|
275
|
-
#
|
|
323
|
+
#argsForJob({ name, data }) {
|
|
276
324
|
if (name == '__default__') { // legacy for vida.live. Remove when vida.live has been fully migrated
|
|
277
|
-
return
|
|
325
|
+
return [data];
|
|
278
326
|
} else {
|
|
279
|
-
|
|
280
|
-
return { id, args };
|
|
327
|
+
return data.args;
|
|
281
328
|
}
|
|
282
329
|
}
|
|
283
330
|
}
|
package/lib/logger/README.md
CHANGED
|
@@ -55,6 +55,18 @@ logger.bar.error('my log');
|
|
|
55
55
|
// [2026-04-30 16:16:12.797 -0700][ERROR][BAR] my log
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
`scope(name)` registers on first use and returns the logger, which is how a scope gets named after
|
|
59
|
+
something that is not lowercase, such as a class. The scope answers to both spellings afterwards:
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
logger.scope('FinalizeSiprecCall').info('my log');
|
|
63
|
+
// [2026-04-30 16:16:12.797 -0700][INFO][FINALIZESIPRECCALL] my log
|
|
64
|
+
|
|
65
|
+
logger.FinalizeSiprecCall === logger.finalizesipreccall; // true
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The tag is always the lowercased name, so log lines read the same however the scope was spelled.
|
|
69
|
+
|
|
58
70
|
### Child loggers with IDs
|
|
59
71
|
|
|
60
72
|
`child(id)` returns a logger that tags every line with the given id. This is the standard pattern for per-request logging — the server framework already calls `logger.child(requestId)` for every controller instance.
|
package/lib/logger/index.js
CHANGED
|
@@ -56,9 +56,8 @@ class Logger {
|
|
|
56
56
|
const scopes = this.scopes.map(scope => `[${scope.toUpperCase()}]`).join('');
|
|
57
57
|
let prefix = `[${info.timestamp}][${level}]${scopes}`;
|
|
58
58
|
if (id) prefix = `${prefix}[${id}]`;
|
|
59
|
-
info.message = `${prefix} ${info.message}`;
|
|
60
59
|
|
|
61
|
-
return info;
|
|
60
|
+
return { ...info, message: `${prefix} ${info.message}` };
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
|
|
@@ -73,16 +72,33 @@ class Logger {
|
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
|
|
75
|
+
// The scope is reachable both as it was given and lowercased, so a scope named after a class
|
|
76
|
+
// answers to `logger.FinalizeSiprecCall` and `logger.finalizesiprecall` alike.
|
|
76
77
|
addScope(scope) {
|
|
77
|
-
|
|
78
|
-
const existing
|
|
78
|
+
const scopeName = scope.toLowerCase();
|
|
79
|
+
const existing = this[scopeName];
|
|
79
80
|
if (existing) {
|
|
80
|
-
if (!existing instanceof Logger) throw `Property already exists at ${
|
|
81
|
-
return;
|
|
81
|
+
if (!(existing instanceof Logger)) throw new Error(`Property already exists at ${scopeName}`);
|
|
82
|
+
return existing;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
const logger = this.#createLogger(this.scopes.concat([
|
|
85
|
-
|
|
85
|
+
const logger = this.#createLogger(this.scopes.concat([scopeName]), this.id);
|
|
86
|
+
for (const name of [scopeName, scope]) this.#defineScope(name, logger);
|
|
87
|
+
return logger;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
scope(name) {
|
|
92
|
+
return this.addScope(name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
#defineScope(name, logger) {
|
|
97
|
+
const existing = this[name];
|
|
98
|
+
if (existing instanceof Logger) return;
|
|
99
|
+
if (existing) throw new Error(`Property already exists at ${name}`);
|
|
100
|
+
|
|
101
|
+
Object.defineProperty(this, name, { get: () => logger });
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
|