@onlineapps/mq-client-core 4.1.0 → 5.0.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/CHANGELOG.md +766 -0
- package/README.md +226 -11
- package/package.json +1 -1
- package/src/BaseClient.js +105 -29
- package/src/config/configSchema.js +21 -4
- package/src/config/defaultConfig.js +14 -4
- package/src/config/deliveryPolicy.js +86 -6
- package/src/config/queueConfig.js +141 -60
- package/src/config/recurrencesSchema.js +121 -0
- package/src/config.js +5 -1
- package/src/defaults.js +7 -1
- package/src/index.js +67 -4
- package/src/layers/PublishLayer.js +113 -5
- package/src/monitoring-publish.js +89 -40
- package/src/scheduler-request.js +502 -0
- package/src/transports/rabbitmqClient.js +1303 -318
- package/src/utils/decodeMessage.js +123 -0
- package/src/utils/publishErrors.js +256 -17
- package/jest.config.js +0 -15
- package/jest.integration.config.js +0 -16
- package/src/workers/RecoveryWorker.js +0 -130
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The recurrences a service declares, and the request that registers ONE of them.
|
|
5
|
+
*
|
|
6
|
+
* `docs/architecture/scheduler.md` §7: a service declares the recurrences it owns and the
|
|
7
|
+
* wrapper re-asserts them at every boot as `scheduler.request` messages of kind `recurring`.
|
|
8
|
+
* The service answers with them through ONE hook (`scheduler: { recurrences: async () => [...] }`,
|
|
9
|
+
* d.834) - platform ones as a constant list, tenant ones derived from its own data - and this
|
|
10
|
+
* library fixes the SHAPE of a record (`validateRecurrences()`), never where it comes from
|
|
11
|
+
* (confirmation `scheduler` 011). The scheduler treats a
|
|
12
|
+
* repeated registration as an upsert — `idempotency_key` for a recurring request IS the
|
|
13
|
+
* schedule id — so re-asserting the declared state costs nothing and drifts nowhere
|
|
14
|
+
* (`api/infra/api_scheduler/src/consumer/decideOnRequest.js`, `contracts/scheduler.request.schema.json`).
|
|
15
|
+
*
|
|
16
|
+
* ONE RAIL FOR EVERY THING A SERVICE SAYS TO THE SCHEDULER. The queue takes one message with
|
|
17
|
+
* four branches, and three of them are a service's to send: `recurring` (a schedule, built from
|
|
18
|
+
* a record the service declared), `event` (a fact that happened, naming no
|
|
19
|
+
* cookbook - the scheduler starts every recipe whose `triggers` declare it, confirmation
|
|
20
|
+
* `scheduler` 004) and `cancel` (an item by its key). Each has a pure builder anybody can ask
|
|
21
|
+
* without a broker, and ONE `publishSchedulerRequest()` sends them all: the kind is a field of
|
|
22
|
+
* the message, never a second function - the split `./monitoring-initiated.js` uses for
|
|
23
|
+
* `workflow_initiated`, with one send instead of three.
|
|
24
|
+
*
|
|
25
|
+
* THE KEY IS ALWAYS THE CALLER'S. `idempotency_key` sits in the contract's root `required`, and
|
|
26
|
+
* the consumer decides a one-shot item ONCE by it (a second `event` of a known key answers
|
|
27
|
+
* `duplicate`). A key invented here would make every retry of one fact a second run, so no
|
|
28
|
+
* builder makes one up - it is the identity of the thing, and only the caller knows it.
|
|
29
|
+
*
|
|
30
|
+
* WHERE IT DIFFERS FROM THAT SIBLING, and why: sending is **fail-fast**. A lost monitoring
|
|
31
|
+
* copy is a missing record of work that already happened, so that rail returns `false` and
|
|
32
|
+
* reports. A lost registration is work that will never happen, and nothing rings when a run
|
|
33
|
+
* does not start — so every refusal here is thrown and the boot stops on it
|
|
34
|
+
* (`architecture-principles.md` §4).
|
|
35
|
+
*
|
|
36
|
+
* WHAT IT NEVER INVENTS: `tenant_id` and `workspace_id`. Both are mandatory and ≥ 1, and the
|
|
37
|
+
* caller passes them with every request — nothing here reads them from the environment or
|
|
38
|
+
* supplies a default. For a recurrence the service knows them from its own data, so the records
|
|
39
|
+
* its boot hook returns carry them; inside a handler `ctx.scheduler` takes them from the
|
|
40
|
+
* invocation envelope.
|
|
41
|
+
*
|
|
42
|
+
* @see api/docs/governance/confirmations/scheduler.md 002
|
|
43
|
+
* @see api/docs/governance/confirmations/scheduler.md 010
|
|
44
|
+
* @see api/docs/governance/confirmations/scheduler.md 011
|
|
45
|
+
*
|
|
46
|
+
* @see ./config/recurrencesSchema.js — the shape of one declared recurrence
|
|
47
|
+
* @see ./monitoring-initiated.js — the same split for `workflow_initiated`
|
|
48
|
+
* @see api/docs/architecture/scheduler.md §4, §7
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const Ajv = require('ajv');
|
|
52
|
+
|
|
53
|
+
const queueConfig = require('./config/queueConfig');
|
|
54
|
+
const { recurrencesSchema } = require('./config/recurrencesSchema');
|
|
55
|
+
|
|
56
|
+
/** The queue the scheduler consumes, composed from the declaration rather than typed. */
|
|
57
|
+
const schedulerRequestQueue = queueConfig.queueName('scheduler', 'request');
|
|
58
|
+
|
|
59
|
+
const ajv = new Ajv({ allErrors: true });
|
|
60
|
+
const validateDeclaration = ajv.compile(recurrencesSchema);
|
|
61
|
+
|
|
62
|
+
/** Ajv's findings as one sentence a human can act on. */
|
|
63
|
+
const describeErrors = (errors) => (errors || [])
|
|
64
|
+
.map((error) => {
|
|
65
|
+
const where = error.instancePath === '' ? 'the document' : error.instancePath;
|
|
66
|
+
const what = error.params && error.params.additionalProperty
|
|
67
|
+
? `${error.message} ("${error.params.additionalProperty}")`
|
|
68
|
+
: error.message;
|
|
69
|
+
return `${where} ${what}`;
|
|
70
|
+
})
|
|
71
|
+
.join('; ');
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check a parsed declaration document and hand back its records.
|
|
75
|
+
*
|
|
76
|
+
* @param {Object} document - `{ recurrences: [...] }`, as the service declared it.
|
|
77
|
+
* @param {string} source - Where it came from, named in every sentence this function throws.
|
|
78
|
+
* Required: a default here would put one caller's name on another caller's mistake.
|
|
79
|
+
* @returns {Array<Object>} The declared recurrences, in the order they were given.
|
|
80
|
+
* @throws {Error} When the document does not hold, naming every rule it broke.
|
|
81
|
+
*/
|
|
82
|
+
function validateRecurrences(document, source) {
|
|
83
|
+
if (typeof source !== 'string' || source.trim() === '') {
|
|
84
|
+
throw invalidRequest(
|
|
85
|
+
'[mq-client-core/scheduler-request] Missing source - Expected: a name for what is being '
|
|
86
|
+
+ 'checked, because every refusal below quotes it and a wrong name sends the reader to the '
|
|
87
|
+
+ 'wrong place. Fix: pass what the caller calls this document (e.g. "the declaration").'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) {
|
|
92
|
+
throw invalidRequest(
|
|
93
|
+
`[mq-client-core/scheduler-request] ${source} is not a declaration - Expected: an object with a "recurrences" list, `
|
|
94
|
+
+ `got ${Array.isArray(document) ? 'an array' : typeof document}. `
|
|
95
|
+
+ 'Fix: write { "recurrences": [ … ] }, or remove the file if the service declares none.'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!validateDeclaration(document)) {
|
|
100
|
+
throw invalidRequest(
|
|
101
|
+
`[mq-client-core/scheduler-request] ${source} does not hold: ${describeErrors(validateDeclaration.errors)} - `
|
|
102
|
+
+ 'Expected: every record carries id, cookbook_ref, cron (five fields) and an explicit '
|
|
103
|
+
+ 'IANA timezone, and nothing this shape does not declare. '
|
|
104
|
+
+ 'Fix: correct the record the message names; the shape is src/config/recurrencesSchema.js. '
|
|
105
|
+
+ 'Note: tenant and workspace do NOT belong here - the caller passes them '
|
|
106
|
+
+ '(confirmations scheduler 010, 011; the tenancy rule itself is scheduler 002).'
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
for (const recurrence of document.recurrences) {
|
|
112
|
+
if (seen.has(recurrence.id)) {
|
|
113
|
+
throw invalidRequest(
|
|
114
|
+
`[mq-client-core/scheduler-request] ${source} declares the id "${recurrence.id}" twice - Expected: one record per `
|
|
115
|
+
+ 'id, because the id travels as the scheduler\'s idempotency_key and a second record '
|
|
116
|
+
+ 'would silently overwrite the first at every boot. '
|
|
117
|
+
+ 'Fix: give each schedule its own id, or remove the duplicate.'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
seen.add(recurrence.id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return document.recurrences;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The code every refusal of a builder carries — and only those (`SCHEDULER_REQUEST_INVALID`).
|
|
128
|
+
*
|
|
129
|
+
* A caller has two kinds of failure to tell apart and must never do it by the wording of a
|
|
130
|
+
* message: a request that cannot be built (a record that breaks the declaration rules, tenancy,
|
|
131
|
+
* a missing key or requester, a key the port does not declare) and a request that could not be
|
|
132
|
+
* SENT (no connection, a publish the broker refused). The wrapper's boot phase 0.7b isolates the
|
|
133
|
+
* first as a failed source and stops the boot on the second (confirmation `scheduler` 020,
|
|
134
|
+
* operational reading 1). So the builders mark their refusals with this code, and
|
|
135
|
+
* `publishSchedulerRequest()` and the port's construction never do.
|
|
136
|
+
*/
|
|
137
|
+
const SCHEDULER_REQUEST_INVALID = 'SCHEDULER_REQUEST_INVALID';
|
|
138
|
+
|
|
139
|
+
/** A builder's refusal: the message as written, plus the code that names its kind. */
|
|
140
|
+
function invalidRequest(message) {
|
|
141
|
+
const error = new Error(message);
|
|
142
|
+
error.code = SCHEDULER_REQUEST_INVALID;
|
|
143
|
+
return error;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A whole number a tenant or workspace id may be: ≥ 1, never 0 (confirmation scheduler 002). */
|
|
147
|
+
const isContextId = (value) => Number.isInteger(value) && value >= 1;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Turn ONE declared recurrence into the message the scheduler takes.
|
|
151
|
+
*
|
|
152
|
+
* @param {Object} args
|
|
153
|
+
* @param {Object} args.declaration - One record the service declared.
|
|
154
|
+
* @param {number} args.tenantId - The tenant the runs execute under; supplied by the caller with
|
|
155
|
+
* this recurrence (the service's own data), never a default.
|
|
156
|
+
* @param {number} args.workspaceId - The workspace the runs execute under; supplied the same way.
|
|
157
|
+
* @param {string} args.requestedBy - The service registering this schedule.
|
|
158
|
+
* @returns {Object} The `scheduler.request` message, exactly the keys the contract declares.
|
|
159
|
+
* @throws {Error} When anything is missing, zero, or not a whole number.
|
|
160
|
+
*/
|
|
161
|
+
function buildRecurringRequest({ declaration, tenantId, workspaceId, requestedBy } = {}) {
|
|
162
|
+
const [recurrence] = validateRecurrences({ recurrences: [declaration] }, 'the declaration');
|
|
163
|
+
|
|
164
|
+
requireTenancy(tenantId, workspaceId);
|
|
165
|
+
const by = requireRequestedBy(requestedBy);
|
|
166
|
+
|
|
167
|
+
const request = {
|
|
168
|
+
kind: 'recurring',
|
|
169
|
+
idempotency_key: recurrence.id,
|
|
170
|
+
cookbook_ref: recurrence.cookbook_ref,
|
|
171
|
+
recurrence: { cron: recurrence.cron, timezone: recurrence.timezone },
|
|
172
|
+
tenant_id: tenantId,
|
|
173
|
+
workspace_id: workspaceId,
|
|
174
|
+
requested_by: by
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Written only when the declaration states them: an absent `input` is no input, and an absent
|
|
178
|
+
// `catch_up` is the scheduler's own default (§5) - a value materialised here would be a second
|
|
179
|
+
// owner of it.
|
|
180
|
+
if (Object.prototype.hasOwnProperty.call(recurrence, 'input')) request.input = recurrence.input;
|
|
181
|
+
if (Object.prototype.hasOwnProperty.call(recurrence, 'catch_up')) request.catch_up = recurrence.catch_up;
|
|
182
|
+
|
|
183
|
+
return request;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The platform spelling of a service or an event name, as the consumer's contract defines it. */
|
|
187
|
+
const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
188
|
+
|
|
189
|
+
/** The key that identifies the item, whatever kind it is - required by the contract's root. */
|
|
190
|
+
function requireIdempotencyKey(idempotencyKey) {
|
|
191
|
+
if (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '') {
|
|
192
|
+
throw invalidRequest(
|
|
193
|
+
'[mq-client-core/scheduler-request] Missing idempotency_key - Expected: the stable identity '
|
|
194
|
+
+ 'of this item, chosen by the caller. The scheduler decides a one-shot item ONCE by this '
|
|
195
|
+
+ 'key, so a key invented here would turn every retry of one fact into a second run. '
|
|
196
|
+
+ 'Fix: pass the key that names the thing (for an event, what it happened to).'
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return idempotencyKey.trim();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The service registering, cancelling or reporting - checked by the scheduler against services.json. */
|
|
204
|
+
function requireRequestedBy(requestedBy) {
|
|
205
|
+
if (typeof requestedBy !== 'string' || requestedBy.trim() === '') {
|
|
206
|
+
throw invalidRequest(
|
|
207
|
+
'[mq-client-core/scheduler-request] Missing requested_by - Expected: the platform name of the '
|
|
208
|
+
+ 'service sending this request; the scheduler checks it against api/config/services.json '
|
|
209
|
+
+ "and against the cookbook's own trigger declaration. "
|
|
210
|
+
+ 'Fix: pass the service name the wrapper already holds.'
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return requestedBy.trim();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Both context ids, under the one rule confirmation `scheduler` 002 fixed. */
|
|
218
|
+
function requireTenancy(tenantId, workspaceId) {
|
|
219
|
+
if (!isContextId(tenantId)) {
|
|
220
|
+
throw invalidRequest(
|
|
221
|
+
`[mq-client-core/scheduler-request] Invalid tenant_id ${JSON.stringify(tenantId)} - Expected: `
|
|
222
|
+
+ 'a whole number of at least 1. The owner rejected a reserved value: 0 is not a platform '
|
|
223
|
+
+ 'scope, and an absent tenant is an error rather than a default (confirmation scheduler 002). '
|
|
224
|
+
+ 'Fix: pass tenantId with every request - a recurrence carries it in the record the service '
|
|
225
|
+
+ 'returns from its own data, and ctx.scheduler fills it from the invocation envelope; it is '
|
|
226
|
+
+ 'never read from anywhere else.'
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!isContextId(workspaceId)) {
|
|
231
|
+
throw invalidRequest(
|
|
232
|
+
`[mq-client-core/scheduler-request] Invalid workspace_id ${JSON.stringify(workspaceId)} - `
|
|
233
|
+
+ 'Expected: a whole number of at least 1, under the same rule as the tenant (confirmation '
|
|
234
|
+
+ 'scheduler 002). Fix: pass workspaceId with every request - a recurrence carries it in the '
|
|
235
|
+
+ 'record the service returns from its own data, and ctx.scheduler fills it from the invocation '
|
|
236
|
+
+ 'envelope; it is never read from anywhere else.'
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Turn a fact into the message that reports it.
|
|
243
|
+
*
|
|
244
|
+
* It names NO cookbook, and that is the point: the scheduler starts every recipe whose
|
|
245
|
+
* `triggers` declare this event (confirmation `scheduler` 004), so a producer naming a recipe
|
|
246
|
+
* would be mapping work to something it may not own (`ingest-poll-trigger-rail` 001: which
|
|
247
|
+
* cookbook runs is a platform decision, not the producer's).
|
|
248
|
+
*
|
|
249
|
+
* @param {Object} args
|
|
250
|
+
* @param {{service: string, name: string}} args.event - Who it happened to, and what happened.
|
|
251
|
+
* @param {number} args.tenantId
|
|
252
|
+
* @param {number} args.workspaceId
|
|
253
|
+
* @param {string} args.requestedBy
|
|
254
|
+
* @param {string} args.idempotencyKey - The identity of this fact; see the module note.
|
|
255
|
+
* @param {Object} [args.input] - Handed to every cookbook the event starts, unchanged.
|
|
256
|
+
* @returns {Object} The `scheduler.request` message of kind `event`.
|
|
257
|
+
* @throws {Error} When anything is missing or not the shape the contract takes.
|
|
258
|
+
*/
|
|
259
|
+
function buildEventRequest({ event, tenantId, workspaceId, requestedBy, idempotencyKey, input } = {}) {
|
|
260
|
+
const key = requireIdempotencyKey(idempotencyKey);
|
|
261
|
+
const by = requireRequestedBy(requestedBy);
|
|
262
|
+
requireTenancy(tenantId, workspaceId);
|
|
263
|
+
|
|
264
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) {
|
|
265
|
+
throw invalidRequest(
|
|
266
|
+
`[mq-client-core/scheduler-request] Missing event - Expected: { service, name } - who it `
|
|
267
|
+
+ 'happened to and what happened, both of them. '
|
|
268
|
+
+ 'Fix: pass the fact this service publishes, never a cookbook name.'
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const [field, value] of [['service', event.service], ['name', event.name]]) {
|
|
273
|
+
if (typeof value !== 'string' || !NAME_PATTERN.test(value)) {
|
|
274
|
+
throw invalidRequest(
|
|
275
|
+
`[mq-client-core/scheduler-request] Invalid event.${field} ${JSON.stringify(value)} - `
|
|
276
|
+
+ 'Expected: the platform spelling, lower case letters, digits and dashes '
|
|
277
|
+
+ '(the pattern the scheduler\'s own contract declares). '
|
|
278
|
+
+ `Fix: pass the ${field} as api/config/services.json spells it.`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const request = {
|
|
284
|
+
kind: 'event',
|
|
285
|
+
idempotency_key: key,
|
|
286
|
+
event: { service: event.service, name: event.name },
|
|
287
|
+
tenant_id: tenantId,
|
|
288
|
+
workspace_id: workspaceId,
|
|
289
|
+
requested_by: by
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
if (input !== undefined) request.input = input;
|
|
293
|
+
|
|
294
|
+
return request;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Take an item back by its key.
|
|
299
|
+
*
|
|
300
|
+
* Idempotent by contract: a key that names nothing is answered `cancel-nothing` by the
|
|
301
|
+
* scheduler, not refused - so this builder judges nothing about the key, it only requires one.
|
|
302
|
+
* The item carries the context it was registered with, which is why no tenancy travels here.
|
|
303
|
+
*
|
|
304
|
+
* @param {Object} args
|
|
305
|
+
* @param {string} args.idempotencyKey - The key of the schedule or pending item to remove.
|
|
306
|
+
* @param {string} args.requestedBy - Only the service that registered an item may cancel it.
|
|
307
|
+
* @returns {Object} The `scheduler.request` message of kind `cancel`.
|
|
308
|
+
* @throws {Error} When the key or the requester is missing.
|
|
309
|
+
*/
|
|
310
|
+
function buildCancelRequest({ idempotencyKey, requestedBy } = {}) {
|
|
311
|
+
return {
|
|
312
|
+
kind: 'cancel',
|
|
313
|
+
idempotency_key: requireIdempotencyKey(idempotencyKey),
|
|
314
|
+
requested_by: requireRequestedBy(requestedBy)
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Every key the port's `registerRecurring()` takes — anything else is refused, never dropped. */
|
|
319
|
+
const RECURRING_PORT_KEYS = Object.freeze([
|
|
320
|
+
'id', 'cookbookRef', 'cron', 'timezone', 'tenantId', 'workspaceId', 'input', 'catchUp'
|
|
321
|
+
]);
|
|
322
|
+
|
|
323
|
+
/** The kinds this library builds, and the only ones it will send. */
|
|
324
|
+
const SENDABLE_KINDS = Object.freeze(['recurring', 'event', 'cancel']);
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Send one request of any kind this library builds. Throws on every refusal — see the module
|
|
328
|
+
* note on fail-fast.
|
|
329
|
+
*
|
|
330
|
+
* @param {Object} mqClient - A connected `BaseClient`.
|
|
331
|
+
* @param {Object} request - What one of the builders above produced.
|
|
332
|
+
* @returns {Promise<void>}
|
|
333
|
+
* @throws {Error} When the client is not connected, the request is not one of ours, or the
|
|
334
|
+
* publish fails.
|
|
335
|
+
*/
|
|
336
|
+
async function publishSchedulerRequest(mqClient, request) {
|
|
337
|
+
if (!mqClient || typeof mqClient.publish !== 'function' || typeof mqClient.isConnected !== 'function') {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`[mq-client-core/scheduler-request] Missing client - Expected: a connected @onlineapps/mq-client-core BaseClient, so `
|
|
340
|
+
+ `the registration reaches ${schedulerRequestQueue}. `
|
|
341
|
+
+ 'Fix: pass the client the composition root connected.'
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!mqClient.isConnected()) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`[mq-client-core/scheduler-request] The client is not connected - Expected: a registration reaches `
|
|
348
|
+
+ `${schedulerRequestQueue} before the service starts taking work; a schedule nobody `
|
|
349
|
+
+ 'registered is a run that never happens. Fix: connect the client first.'
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (!request || !SENDABLE_KINDS.includes(request.kind) || typeof request.idempotency_key !== 'string') {
|
|
354
|
+
throw new Error(
|
|
355
|
+
'[mq-client-core/scheduler-request] Not a request this library built - Expected: one of '
|
|
356
|
+
+ `${SENDABLE_KINDS.join(', ')}, got kind ${JSON.stringify(request && request.kind)}. `
|
|
357
|
+
+ 'Fix: build the message with buildRecurringRequest(), buildEventRequest() or '
|
|
358
|
+
+ 'buildCancelRequest() before sending it.'
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
await mqClient.publish(schedulerRequestQueue, request);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`[mq-client-core/scheduler-request] Could not send "${request.idempotency_key}" (${request.kind}) `
|
|
367
|
+
+ `on ${schedulerRequestQueue}: ${error.message} - Expected: the request to reach the `
|
|
368
|
+
+ 'scheduler; a request nobody received is work that will not happen. '
|
|
369
|
+
+ 'Fix: act on the reason above - the boot stops here rather than running a service whose '
|
|
370
|
+
+ 'schedules were silently not registered.',
|
|
371
|
+
{ cause: error }
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* A port one service holds: the three things it says, with WHO is speaking already decided.
|
|
378
|
+
*
|
|
379
|
+
* TWO CALLERS, TWO DOORS. The wrapper builds `ctx.scheduler` over this factory at boot and fills
|
|
380
|
+
* the tenancy of each call from the envelope of the run. The operator CLIs of biz-ingest
|
|
381
|
+
* (`scripts/run-{poll,fetch-source-item,retry-pending-items}.js`) do NOT boot the wrapper - a
|
|
382
|
+
* bootstrap there would start consumers, the registry and a heartbeat - and today they are the
|
|
383
|
+
* only way a document of the pilot comes into being, so they call this factory directly with the
|
|
384
|
+
* service identity and the tenancy of the row they are working on. That is why tenancy is
|
|
385
|
+
* explicit on every call rather than held by the port: one port, two kinds of caller, and no
|
|
386
|
+
* hidden context that the CLI would have to fake (BIZ-ingest measurement, 2026-09-23).
|
|
387
|
+
*
|
|
388
|
+
* EAGER ON PURPOSE. The client and the name of the queue are decided HERE, when the port is
|
|
389
|
+
* made, so a service that announces facts falls over at boot rather than at its first document.
|
|
390
|
+
* (On a pin whose `queueConfig` declares no `scheduler` section this module already refuses at
|
|
391
|
+
* require time, which is earlier still.) And because a defect of the wiring must never answer in
|
|
392
|
+
* place of the operation, every refusal names the slot it is about and how to fix it.
|
|
393
|
+
*
|
|
394
|
+
* @param {Object} args
|
|
395
|
+
* @param {Object} args.mqClient - A `BaseClient` (connected by the time a verb is called).
|
|
396
|
+
* @param {string} args.requestedBy - The service this port speaks for; it becomes `requested_by`
|
|
397
|
+
* on every request and `event.service` on every fact.
|
|
398
|
+
* @returns {{registerRecurring: Function, publishEvent: Function, cancel: Function}}
|
|
399
|
+
* @throws {Error} When a slot is missing or unusable - at construction, never later.
|
|
400
|
+
*/
|
|
401
|
+
function createSchedulerPublisher({ mqClient, requestedBy } = {}) {
|
|
402
|
+
if (!mqClient || typeof mqClient.publish !== 'function' || typeof mqClient.isConnected !== 'function') {
|
|
403
|
+
throw new Error(
|
|
404
|
+
'[mq-client-core/scheduler-request] Cannot build the scheduler port: slot "mqClient" is '
|
|
405
|
+
+ 'missing or cannot publish - Expected: the @onlineapps/mq-client-core BaseClient this '
|
|
406
|
+
+ `service connects, so a request reaches ${schedulerRequestQueue}. This is decided now, at `
|
|
407
|
+
+ 'construction, because a service that announces facts must fail at boot rather than at '
|
|
408
|
+
+ 'its first document. Fix: pass the client the composition root built.'
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const by = (() => {
|
|
413
|
+
try {
|
|
414
|
+
return requireRequestedBy(requestedBy);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
'[mq-client-core/scheduler-request] Cannot build the scheduler port: slot "requestedBy" is '
|
|
418
|
+
+ `missing - ${error.message} `
|
|
419
|
+
+ 'Fix: pass the platform name of the service this port speaks for (config.serviceName).'
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
})();
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
/**
|
|
426
|
+
* Re-assert one recurrence. The id is the schedule's identity, so a repeat upserts.
|
|
427
|
+
*
|
|
428
|
+
* @param {Object} args - `{ id, cookbookRef, cron, timezone, tenantId, workspaceId, input?, catchUp? }`
|
|
429
|
+
* @returns {Promise<void>}
|
|
430
|
+
*/
|
|
431
|
+
async registerRecurring(record = {}) {
|
|
432
|
+
// As strict as the declaration it becomes (`additionalProperties: false` in
|
|
433
|
+
// `./config/recurrencesSchema.js`): a key nobody reads is a typo or a second definition of
|
|
434
|
+
// something, and dropping it in silence hides both (`architecture-principles.md` §3).
|
|
435
|
+
for (const key of Object.keys(record)) {
|
|
436
|
+
if (!RECURRING_PORT_KEYS.includes(key)) {
|
|
437
|
+
throw invalidRequest(
|
|
438
|
+
`[mq-client-core/scheduler-request] registerRecurring got a key the request does not declare ("${key}") - `
|
|
439
|
+
+ 'Expected: exactly { id, cookbookRef, cron, timezone, tenantId, workspaceId, input?, catchUp? }. '
|
|
440
|
+
+ 'Fix: remove the key from the record the service returns / the ctx.scheduler call.'
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const { id, cookbookRef, cron, timezone, tenantId, workspaceId, input, catchUp } = record;
|
|
446
|
+
const declaration = { id, cookbook_ref: cookbookRef, cron, timezone };
|
|
447
|
+
if (input !== undefined) declaration.input = input;
|
|
448
|
+
if (catchUp !== undefined) declaration.catch_up = catchUp;
|
|
449
|
+
|
|
450
|
+
await publishSchedulerRequest(mqClient, buildRecurringRequest({
|
|
451
|
+
declaration,
|
|
452
|
+
tenantId,
|
|
453
|
+
workspaceId,
|
|
454
|
+
requestedBy: by
|
|
455
|
+
}));
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Announce one fact. The service is this port's; the key is the caller's (the identity of the
|
|
460
|
+
* thing that happened - a job's uuid, not a clock reading), because the scheduler decides a
|
|
461
|
+
* one-shot item once by it.
|
|
462
|
+
*
|
|
463
|
+
* @param {Object} args - `{ name, idempotencyKey, tenantId, workspaceId, input? }`
|
|
464
|
+
* @returns {Promise<void>}
|
|
465
|
+
*/
|
|
466
|
+
async publishEvent({ name, idempotencyKey, tenantId, workspaceId, input } = {}) {
|
|
467
|
+
await publishSchedulerRequest(mqClient, buildEventRequest({
|
|
468
|
+
event: { service: by, name },
|
|
469
|
+
idempotencyKey,
|
|
470
|
+
tenantId,
|
|
471
|
+
workspaceId,
|
|
472
|
+
requestedBy: by,
|
|
473
|
+
input
|
|
474
|
+
}));
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Take back an item this service registered, by its id.
|
|
479
|
+
*
|
|
480
|
+
* @param {Object} args - `{ id }`
|
|
481
|
+
* @returns {Promise<void>}
|
|
482
|
+
*/
|
|
483
|
+
async cancel({ id } = {}) {
|
|
484
|
+
await publishSchedulerRequest(mqClient, buildCancelRequest({
|
|
485
|
+
idempotencyKey: id,
|
|
486
|
+
requestedBy: by
|
|
487
|
+
}));
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
module.exports = {
|
|
493
|
+
schedulerRequestQueue,
|
|
494
|
+
SENDABLE_KINDS,
|
|
495
|
+
SCHEDULER_REQUEST_INVALID,
|
|
496
|
+
validateRecurrences,
|
|
497
|
+
buildRecurringRequest,
|
|
498
|
+
buildEventRequest,
|
|
499
|
+
buildCancelRequest,
|
|
500
|
+
publishSchedulerRequest,
|
|
501
|
+
createSchedulerPublisher
|
|
502
|
+
};
|