@onlineapps/mq-client-core 4.0.1 → 4.2.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 +220 -0
- package/README.md +174 -0
- package/package.json +1 -1
- package/src/config/queueConfig.js +272 -21
- package/src/config/recurrencesSchema.js +121 -0
- package/src/index.js +47 -0
- package/src/monitoring-initiated.js +187 -0
- package/src/scheduler-request.js +481 -0
- package/src/transports/rabbitmqClient.js +71 -5
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `workflow_initiated` event — ONE rail for every service that starts a run.
|
|
5
|
+
*
|
|
6
|
+
* Monitoring learns that a run exists from this event and from nothing else: its intake
|
|
7
|
+
* reads the run's tenancy, labels and trigger out of `workflow_initiated` and marks the
|
|
8
|
+
* run `active` on it (`api/infra/api_monitoring/src/consumer/index.js`). A service that
|
|
9
|
+
* does not file it starts runs that are invisible for their whole life.
|
|
10
|
+
*
|
|
11
|
+
* Until now two services built that event, each in its own words: the gateway
|
|
12
|
+
* (`infra/api_gateway/shared/monitoringInitiatedCopy.js`, with a redacted cookbook) and
|
|
13
|
+
* the scheduler (`infra/api_scheduler/src/publisher/workflowPublisher.js`, inline, the
|
|
14
|
+
* cookbook verbatim and no `tenant_uuid` at all). Two implementations of one concern are
|
|
15
|
+
* a defect by default (`change-discipline.md` § One rail per concern), and the owner
|
|
16
|
+
* settled which rail survives: this one, in the library that already owns the transport
|
|
17
|
+
* half of the same wire (`docs/governance/confirmations/scheduler.md` 009). Both copies
|
|
18
|
+
* are retired by their own services in the change that adopts this module — no period
|
|
19
|
+
* with both (`architecture-principles.md` §11).
|
|
20
|
+
*
|
|
21
|
+
* WHAT THIS MODULE DOES NOT DO, deliberately:
|
|
22
|
+
*
|
|
23
|
+
* 1. **It does not redact.** What is sensitive in a cookbook is known only to the
|
|
24
|
+
* service spec in the registry, and reading that spec would give this library a
|
|
25
|
+
* dependency on the operation specs and on `infrastructure-tools`. The caller passes
|
|
26
|
+
* the observability copy it already built (`cookbookRedaction.js` in the gateway); a
|
|
27
|
+
* path that cannot redact does not file the event at all, because an empty set of
|
|
28
|
+
* sensitive fields is silent consent to an unredacted copy.
|
|
29
|
+
* 2. **It invents no value.** `service_name` and the instant are the caller's, and every
|
|
30
|
+
* key it reads out of `_system` must EXIST — `hasOwnProperty`, not truthiness, so
|
|
31
|
+
* `tenant_uuid: null` passes through as the explicit value it is (a webhook run has
|
|
32
|
+
* no personal identity, and a scheduled run of the pilot writes `null` on purpose —
|
|
33
|
+
* `docs/governance/confirmations/run-envelope-tenant-uuid.md` 001). A `|| null` here
|
|
34
|
+
* would hide a broken envelope instead of reporting it (`architecture-principles.md`
|
|
35
|
+
* §3).
|
|
36
|
+
*
|
|
37
|
+
* @see api/docs/governance/confirmations/scheduler.md 009
|
|
38
|
+
* @see api/docs/biz/70-contracts/monitoring-events.md
|
|
39
|
+
* @see ./monitoring-publish.js — the transport half: the queue, the fanout, the fail-safe
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
43
|
+
const { publishToMonitoringWorkflow } = require('./monitoring-publish');
|
|
44
|
+
|
|
45
|
+
/** Starting a run is not a step of the cookbook; it has its own constant coordinates. */
|
|
46
|
+
const STEP_ID = 'workflow_start';
|
|
47
|
+
const STEP_INDEX = -1;
|
|
48
|
+
/** The event's own two words, which the monitoring intake matches on. */
|
|
49
|
+
const EVENT_TYPE = 'initiated';
|
|
50
|
+
const STATUS = 'initiated';
|
|
51
|
+
|
|
52
|
+
/** The keys of `_system` this event carries, each of which must exist on the envelope. */
|
|
53
|
+
const REQUIRED_SYSTEM_KEYS = Object.freeze(['tenant_id', 'tenant_uuid']);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The body of the `initiated` event — exactly these keys and no others.
|
|
57
|
+
*
|
|
58
|
+
* @param {Object} args
|
|
59
|
+
* @param {string} args.workflowId - the id of the run this event reports.
|
|
60
|
+
* @param {Object} args.redactedCookbook - the observability copy of the unified cookbook,
|
|
61
|
+
* ALREADY redacted by the caller; it travels as it arrived.
|
|
62
|
+
* @param {string} args.serviceName - the platform name of the service filing the event;
|
|
63
|
+
* the monitoring intake refuses an event that names no publisher (`service_name_missing`).
|
|
64
|
+
* @param {string} args.timestamp - the instant the run started, ISO-8601. The caller's,
|
|
65
|
+
* not a second reading of the clock here: the run and the row observing it then say the
|
|
66
|
+
* same thing.
|
|
67
|
+
* @returns {Object} the message for `monitoring.workflow`.
|
|
68
|
+
* @throws {Error} when an argument is missing, or the envelope does not carry what this
|
|
69
|
+
* event states about it.
|
|
70
|
+
*/
|
|
71
|
+
function buildWorkflowInitiatedEvent({ workflowId, redactedCookbook, serviceName, timestamp } = {}) {
|
|
72
|
+
if (typeof workflowId !== 'string' || workflowId.trim() === '') {
|
|
73
|
+
throw new Error(
|
|
74
|
+
'[monitoring-initiated] Missing workflow_id - Expected: the id of the run this event '
|
|
75
|
+
+ 'reports, as a non-empty string. Fix: pass workflowId of the run being published.'
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (typeof serviceName !== 'string' || serviceName.trim() === '') {
|
|
79
|
+
throw new Error(
|
|
80
|
+
'[monitoring-initiated] Missing service_name - Expected: the platform name of the service '
|
|
81
|
+
+ 'filing this event; the monitoring intake refuses an event that names no publisher '
|
|
82
|
+
+ '(service_name_missing). Fix: pass config.serviceName.'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (typeof timestamp !== 'string' || timestamp.trim() === '') {
|
|
86
|
+
throw new Error(
|
|
87
|
+
'[monitoring-initiated] Missing timestamp - Expected: the instant the run started, as an '
|
|
88
|
+
+ 'ISO-8601 string taken from the run itself, so the run and the row observing it say the '
|
|
89
|
+
+ 'same thing. Fix: pass the envelope\'s own instant, not a new Date() read here.'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (!redactedCookbook || typeof redactedCookbook !== 'object') {
|
|
93
|
+
throw new Error(
|
|
94
|
+
'[monitoring-initiated] Missing cookbook - Expected: the redacted observability copy of the '
|
|
95
|
+
+ 'unified cookbook. Fix: pass the copy the caller built with its own redaction '
|
|
96
|
+
+ '(a path that cannot redact does not file this event at all).'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const system = redactedCookbook._system;
|
|
101
|
+
if (!system || typeof system !== 'object') {
|
|
102
|
+
throw new Error(
|
|
103
|
+
'[monitoring-initiated] Cookbook carries no _system - Expected: the unified cookbook with '
|
|
104
|
+
+ 'its system metadata, which is where the intake reads the run\'s tenancy, labels and '
|
|
105
|
+
+ 'trigger. Fix: build the copy from the unified cookbook the run was published with.'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const key of REQUIRED_SYSTEM_KEYS) {
|
|
110
|
+
if (!Object.prototype.hasOwnProperty.call(system, key)) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`[monitoring-initiated] Cookbook _system carries no ${key} - Expected: every entry path `
|
|
113
|
+
+ 'writes it explicitly (a value, or null where the path has none - a webhook run proves '
|
|
114
|
+
+ 'itself by signature, a scheduled run of the pilot has no personal identity). '
|
|
115
|
+
+ `Fix: set _system.${key} where the unified cookbook is built.`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
event_type: EVENT_TYPE,
|
|
122
|
+
workflow_id: workflowId,
|
|
123
|
+
service_name: serviceName,
|
|
124
|
+
step_index: STEP_INDEX,
|
|
125
|
+
step_id: STEP_ID,
|
|
126
|
+
cookbook: redactedCookbook,
|
|
127
|
+
status: STATUS,
|
|
128
|
+
tenant_uuid: system.tenant_uuid,
|
|
129
|
+
tenant_id: system.tenant_id,
|
|
130
|
+
timestamp
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* File the `initiated` event. NEVER throws — a lost copy is reported, not raised.
|
|
136
|
+
*
|
|
137
|
+
* It is observability about a run that has already happened: refusing the run because the
|
|
138
|
+
* observer could not be told would trade a real effect for a record of it. Both services
|
|
139
|
+
* that build this event today made the same decision in their own words, and it moves here
|
|
140
|
+
* with the event. A missing logger is the one exception, and it is the transport half's
|
|
141
|
+
* rule (`monitoring-publish.js`): without a logger the promise "every failure is reported"
|
|
142
|
+
* cannot hold, so the call refuses instead of dropping events in silence.
|
|
143
|
+
*
|
|
144
|
+
* @param {Object} args
|
|
145
|
+
* @param {Object} args.mqClient - the connected `BaseClient`.
|
|
146
|
+
* @param {Object} args.logger - the service logger; REQUIRED.
|
|
147
|
+
* @param {string} args.workflowId
|
|
148
|
+
* @param {Object} args.redactedCookbook
|
|
149
|
+
* @param {string} args.serviceName
|
|
150
|
+
* @param {string} args.timestamp
|
|
151
|
+
* @param {Object} [args.context] - extra context for the log lines.
|
|
152
|
+
* @returns {Promise<boolean>} whether the event reached the broker.
|
|
153
|
+
*/
|
|
154
|
+
async function publishWorkflowInitiated({
|
|
155
|
+
mqClient,
|
|
156
|
+
logger,
|
|
157
|
+
workflowId,
|
|
158
|
+
redactedCookbook,
|
|
159
|
+
serviceName,
|
|
160
|
+
timestamp,
|
|
161
|
+
context = {}
|
|
162
|
+
} = {}) {
|
|
163
|
+
assertLogger(
|
|
164
|
+
'publishWorkflowInitiated',
|
|
165
|
+
logger,
|
|
166
|
+
'a copy monitoring never received is reported instead of silently returning false',
|
|
167
|
+
'pass the logger your service already built'
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const event = buildWorkflowInitiatedEvent({ workflowId, redactedCookbook, serviceName, timestamp });
|
|
172
|
+
return await publishToMonitoringWorkflow(mqClient, event, logger, { workflowId, ...context });
|
|
173
|
+
} catch (error) {
|
|
174
|
+
logger.warn('[monitoring-initiated] Failed to publish workflow_initiated to monitoring', {
|
|
175
|
+
...context,
|
|
176
|
+
workflow_id: workflowId,
|
|
177
|
+
service_name: serviceName,
|
|
178
|
+
error: error.message
|
|
179
|
+
});
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
buildWorkflowInitiatedEvent,
|
|
186
|
+
publishWorkflowInitiated
|
|
187
|
+
};
|
|
@@ -0,0 +1,481 @@
|
|
|
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 new Error(
|
|
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 new Error(
|
|
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 new Error(
|
|
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 new Error(
|
|
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
|
+
/** A whole number a tenant or workspace id may be: ≥ 1, never 0 (confirmation scheduler 002). */
|
|
127
|
+
const isContextId = (value) => Number.isInteger(value) && value >= 1;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Turn ONE declared recurrence into the message the scheduler takes.
|
|
131
|
+
*
|
|
132
|
+
* @param {Object} args
|
|
133
|
+
* @param {Object} args.declaration - One record the service declared.
|
|
134
|
+
* @param {number} args.tenantId - The tenant the runs execute under; supplied by the caller with
|
|
135
|
+
* this recurrence (the service's own data), never a default.
|
|
136
|
+
* @param {number} args.workspaceId - The workspace the runs execute under; supplied the same way.
|
|
137
|
+
* @param {string} args.requestedBy - The service registering this schedule.
|
|
138
|
+
* @returns {Object} The `scheduler.request` message, exactly the keys the contract declares.
|
|
139
|
+
* @throws {Error} When anything is missing, zero, or not a whole number.
|
|
140
|
+
*/
|
|
141
|
+
function buildRecurringRequest({ declaration, tenantId, workspaceId, requestedBy } = {}) {
|
|
142
|
+
const [recurrence] = validateRecurrences({ recurrences: [declaration] }, 'the declaration');
|
|
143
|
+
|
|
144
|
+
requireTenancy(tenantId, workspaceId);
|
|
145
|
+
const by = requireRequestedBy(requestedBy);
|
|
146
|
+
|
|
147
|
+
const request = {
|
|
148
|
+
kind: 'recurring',
|
|
149
|
+
idempotency_key: recurrence.id,
|
|
150
|
+
cookbook_ref: recurrence.cookbook_ref,
|
|
151
|
+
recurrence: { cron: recurrence.cron, timezone: recurrence.timezone },
|
|
152
|
+
tenant_id: tenantId,
|
|
153
|
+
workspace_id: workspaceId,
|
|
154
|
+
requested_by: by
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Written only when the declaration states them: an absent `input` is no input, and an absent
|
|
158
|
+
// `catch_up` is the scheduler's own default (§5) - a value materialised here would be a second
|
|
159
|
+
// owner of it.
|
|
160
|
+
if (Object.prototype.hasOwnProperty.call(recurrence, 'input')) request.input = recurrence.input;
|
|
161
|
+
if (Object.prototype.hasOwnProperty.call(recurrence, 'catch_up')) request.catch_up = recurrence.catch_up;
|
|
162
|
+
|
|
163
|
+
return request;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The platform spelling of a service or an event name, as the consumer's contract defines it. */
|
|
167
|
+
const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
168
|
+
|
|
169
|
+
/** The key that identifies the item, whatever kind it is - required by the contract's root. */
|
|
170
|
+
function requireIdempotencyKey(idempotencyKey) {
|
|
171
|
+
if (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '') {
|
|
172
|
+
throw new Error(
|
|
173
|
+
'[mq-client-core/scheduler-request] Missing idempotency_key - Expected: the stable identity '
|
|
174
|
+
+ 'of this item, chosen by the caller. The scheduler decides a one-shot item ONCE by this '
|
|
175
|
+
+ 'key, so a key invented here would turn every retry of one fact into a second run. '
|
|
176
|
+
+ 'Fix: pass the key that names the thing (for an event, what it happened to).'
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return idempotencyKey.trim();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The service registering, cancelling or reporting - checked by the scheduler against services.json. */
|
|
184
|
+
function requireRequestedBy(requestedBy) {
|
|
185
|
+
if (typeof requestedBy !== 'string' || requestedBy.trim() === '') {
|
|
186
|
+
throw new Error(
|
|
187
|
+
'[mq-client-core/scheduler-request] Missing requested_by - Expected: the platform name of the '
|
|
188
|
+
+ 'service sending this request; the scheduler checks it against api/config/services.json '
|
|
189
|
+
+ "and against the cookbook's own trigger declaration. "
|
|
190
|
+
+ 'Fix: pass the service name the wrapper already holds.'
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return requestedBy.trim();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Both context ids, under the one rule confirmation `scheduler` 002 fixed. */
|
|
198
|
+
function requireTenancy(tenantId, workspaceId) {
|
|
199
|
+
if (!isContextId(tenantId)) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`[mq-client-core/scheduler-request] Invalid tenant_id ${JSON.stringify(tenantId)} - Expected: `
|
|
202
|
+
+ 'a whole number of at least 1. The owner rejected a reserved value: 0 is not a platform '
|
|
203
|
+
+ 'scope, and an absent tenant is an error rather than a default (confirmation scheduler 002). '
|
|
204
|
+
+ 'Fix: pass tenantId with every request - a recurrence carries it in the record the service '
|
|
205
|
+
+ 'returns from its own data, and ctx.scheduler fills it from the invocation envelope; it is '
|
|
206
|
+
+ 'never read from anywhere else.'
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!isContextId(workspaceId)) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`[mq-client-core/scheduler-request] Invalid workspace_id ${JSON.stringify(workspaceId)} - `
|
|
213
|
+
+ 'Expected: a whole number of at least 1, under the same rule as the tenant (confirmation '
|
|
214
|
+
+ 'scheduler 002). Fix: pass workspaceId with every request - a recurrence carries it in the '
|
|
215
|
+
+ 'record the service returns from its own data, and ctx.scheduler fills it from the invocation '
|
|
216
|
+
+ 'envelope; it is never read from anywhere else.'
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Turn a fact into the message that reports it.
|
|
223
|
+
*
|
|
224
|
+
* It names NO cookbook, and that is the point: the scheduler starts every recipe whose
|
|
225
|
+
* `triggers` declare this event (confirmation `scheduler` 004), so a producer naming a recipe
|
|
226
|
+
* would be mapping work to something it may not own (`ingest-poll-trigger-rail` 001: which
|
|
227
|
+
* cookbook runs is a platform decision, not the producer's).
|
|
228
|
+
*
|
|
229
|
+
* @param {Object} args
|
|
230
|
+
* @param {{service: string, name: string}} args.event - Who it happened to, and what happened.
|
|
231
|
+
* @param {number} args.tenantId
|
|
232
|
+
* @param {number} args.workspaceId
|
|
233
|
+
* @param {string} args.requestedBy
|
|
234
|
+
* @param {string} args.idempotencyKey - The identity of this fact; see the module note.
|
|
235
|
+
* @param {Object} [args.input] - Handed to every cookbook the event starts, unchanged.
|
|
236
|
+
* @returns {Object} The `scheduler.request` message of kind `event`.
|
|
237
|
+
* @throws {Error} When anything is missing or not the shape the contract takes.
|
|
238
|
+
*/
|
|
239
|
+
function buildEventRequest({ event, tenantId, workspaceId, requestedBy, idempotencyKey, input } = {}) {
|
|
240
|
+
const key = requireIdempotencyKey(idempotencyKey);
|
|
241
|
+
const by = requireRequestedBy(requestedBy);
|
|
242
|
+
requireTenancy(tenantId, workspaceId);
|
|
243
|
+
|
|
244
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`[mq-client-core/scheduler-request] Missing event - Expected: { service, name } - who it `
|
|
247
|
+
+ 'happened to and what happened, both of them. '
|
|
248
|
+
+ 'Fix: pass the fact this service publishes, never a cookbook name.'
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const [field, value] of [['service', event.service], ['name', event.name]]) {
|
|
253
|
+
if (typeof value !== 'string' || !NAME_PATTERN.test(value)) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`[mq-client-core/scheduler-request] Invalid event.${field} ${JSON.stringify(value)} - `
|
|
256
|
+
+ 'Expected: the platform spelling, lower case letters, digits and dashes '
|
|
257
|
+
+ '(the pattern the scheduler\'s own contract declares). '
|
|
258
|
+
+ `Fix: pass the ${field} as api/config/services.json spells it.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const request = {
|
|
264
|
+
kind: 'event',
|
|
265
|
+
idempotency_key: key,
|
|
266
|
+
event: { service: event.service, name: event.name },
|
|
267
|
+
tenant_id: tenantId,
|
|
268
|
+
workspace_id: workspaceId,
|
|
269
|
+
requested_by: by
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
if (input !== undefined) request.input = input;
|
|
273
|
+
|
|
274
|
+
return request;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Take an item back by its key.
|
|
279
|
+
*
|
|
280
|
+
* Idempotent by contract: a key that names nothing is answered `cancel-nothing` by the
|
|
281
|
+
* scheduler, not refused - so this builder judges nothing about the key, it only requires one.
|
|
282
|
+
* The item carries the context it was registered with, which is why no tenancy travels here.
|
|
283
|
+
*
|
|
284
|
+
* @param {Object} args
|
|
285
|
+
* @param {string} args.idempotencyKey - The key of the schedule or pending item to remove.
|
|
286
|
+
* @param {string} args.requestedBy - Only the service that registered an item may cancel it.
|
|
287
|
+
* @returns {Object} The `scheduler.request` message of kind `cancel`.
|
|
288
|
+
* @throws {Error} When the key or the requester is missing.
|
|
289
|
+
*/
|
|
290
|
+
function buildCancelRequest({ idempotencyKey, requestedBy } = {}) {
|
|
291
|
+
return {
|
|
292
|
+
kind: 'cancel',
|
|
293
|
+
idempotency_key: requireIdempotencyKey(idempotencyKey),
|
|
294
|
+
requested_by: requireRequestedBy(requestedBy)
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Every key the port's `registerRecurring()` takes — anything else is refused, never dropped. */
|
|
299
|
+
const RECURRING_PORT_KEYS = Object.freeze([
|
|
300
|
+
'id', 'cookbookRef', 'cron', 'timezone', 'tenantId', 'workspaceId', 'input', 'catchUp'
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
/** The kinds this library builds, and the only ones it will send. */
|
|
304
|
+
const SENDABLE_KINDS = Object.freeze(['recurring', 'event', 'cancel']);
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Send one request of any kind this library builds. Throws on every refusal — see the module
|
|
308
|
+
* note on fail-fast.
|
|
309
|
+
*
|
|
310
|
+
* @param {Object} mqClient - A connected `BaseClient`.
|
|
311
|
+
* @param {Object} request - What one of the builders above produced.
|
|
312
|
+
* @returns {Promise<void>}
|
|
313
|
+
* @throws {Error} When the client is not connected, the request is not one of ours, or the
|
|
314
|
+
* publish fails.
|
|
315
|
+
*/
|
|
316
|
+
async function publishSchedulerRequest(mqClient, request) {
|
|
317
|
+
if (!mqClient || typeof mqClient.publish !== 'function' || typeof mqClient.isConnected !== 'function') {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`[mq-client-core/scheduler-request] Missing client - Expected: a connected @onlineapps/mq-client-core BaseClient, so `
|
|
320
|
+
+ `the registration reaches ${schedulerRequestQueue}. `
|
|
321
|
+
+ 'Fix: pass the client the composition root connected.'
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!mqClient.isConnected()) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`[mq-client-core/scheduler-request] The client is not connected - Expected: a registration reaches `
|
|
328
|
+
+ `${schedulerRequestQueue} before the service starts taking work; a schedule nobody `
|
|
329
|
+
+ 'registered is a run that never happens. Fix: connect the client first.'
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!request || !SENDABLE_KINDS.includes(request.kind) || typeof request.idempotency_key !== 'string') {
|
|
334
|
+
throw new Error(
|
|
335
|
+
'[mq-client-core/scheduler-request] Not a request this library built - Expected: one of '
|
|
336
|
+
+ `${SENDABLE_KINDS.join(', ')}, got kind ${JSON.stringify(request && request.kind)}. `
|
|
337
|
+
+ 'Fix: build the message with buildRecurringRequest(), buildEventRequest() or '
|
|
338
|
+
+ 'buildCancelRequest() before sending it.'
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
await mqClient.publish(schedulerRequestQueue, request);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`[mq-client-core/scheduler-request] Could not send "${request.idempotency_key}" (${request.kind}) `
|
|
347
|
+
+ `on ${schedulerRequestQueue}: ${error.message} - Expected: the request to reach the `
|
|
348
|
+
+ 'scheduler; a request nobody received is work that will not happen. '
|
|
349
|
+
+ 'Fix: act on the reason above - the boot stops here rather than running a service whose '
|
|
350
|
+
+ 'schedules were silently not registered.',
|
|
351
|
+
{ cause: error }
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* A port one service holds: the three things it says, with WHO is speaking already decided.
|
|
358
|
+
*
|
|
359
|
+
* TWO CALLERS, TWO DOORS. The wrapper builds `ctx.scheduler` over this factory at boot and fills
|
|
360
|
+
* the tenancy of each call from the envelope of the run. The operator CLIs of biz-ingest
|
|
361
|
+
* (`scripts/run-{poll,fetch-source-item,retry-pending-items}.js`) do NOT boot the wrapper - a
|
|
362
|
+
* bootstrap there would start consumers, the registry and a heartbeat - and today they are the
|
|
363
|
+
* only way a document of the pilot comes into being, so they call this factory directly with the
|
|
364
|
+
* service identity and the tenancy of the row they are working on. That is why tenancy is
|
|
365
|
+
* explicit on every call rather than held by the port: one port, two kinds of caller, and no
|
|
366
|
+
* hidden context that the CLI would have to fake (BIZ-ingest measurement, 2026-09-23).
|
|
367
|
+
*
|
|
368
|
+
* EAGER ON PURPOSE. The client and the name of the queue are decided HERE, when the port is
|
|
369
|
+
* made, so a service that announces facts falls over at boot rather than at its first document.
|
|
370
|
+
* (On a pin whose `queueConfig` declares no `scheduler` section this module already refuses at
|
|
371
|
+
* require time, which is earlier still.) And because a defect of the wiring must never answer in
|
|
372
|
+
* place of the operation, every refusal names the slot it is about and how to fix it.
|
|
373
|
+
*
|
|
374
|
+
* @param {Object} args
|
|
375
|
+
* @param {Object} args.mqClient - A `BaseClient` (connected by the time a verb is called).
|
|
376
|
+
* @param {string} args.requestedBy - The service this port speaks for; it becomes `requested_by`
|
|
377
|
+
* on every request and `event.service` on every fact.
|
|
378
|
+
* @returns {{registerRecurring: Function, publishEvent: Function, cancel: Function}}
|
|
379
|
+
* @throws {Error} When a slot is missing or unusable - at construction, never later.
|
|
380
|
+
*/
|
|
381
|
+
function createSchedulerPublisher({ mqClient, requestedBy } = {}) {
|
|
382
|
+
if (!mqClient || typeof mqClient.publish !== 'function' || typeof mqClient.isConnected !== 'function') {
|
|
383
|
+
throw new Error(
|
|
384
|
+
'[mq-client-core/scheduler-request] Cannot build the scheduler port: slot "mqClient" is '
|
|
385
|
+
+ 'missing or cannot publish - Expected: the @onlineapps/mq-client-core BaseClient this '
|
|
386
|
+
+ `service connects, so a request reaches ${schedulerRequestQueue}. This is decided now, at `
|
|
387
|
+
+ 'construction, because a service that announces facts must fail at boot rather than at '
|
|
388
|
+
+ 'its first document. Fix: pass the client the composition root built.'
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const by = (() => {
|
|
393
|
+
try {
|
|
394
|
+
return requireRequestedBy(requestedBy);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
'[mq-client-core/scheduler-request] Cannot build the scheduler port: slot "requestedBy" is '
|
|
398
|
+
+ `missing - ${error.message} `
|
|
399
|
+
+ 'Fix: pass the platform name of the service this port speaks for (config.serviceName).'
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
})();
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
/**
|
|
406
|
+
* Re-assert one recurrence. The id is the schedule's identity, so a repeat upserts.
|
|
407
|
+
*
|
|
408
|
+
* @param {Object} args - `{ id, cookbookRef, cron, timezone, tenantId, workspaceId, input?, catchUp? }`
|
|
409
|
+
* @returns {Promise<void>}
|
|
410
|
+
*/
|
|
411
|
+
async registerRecurring(record = {}) {
|
|
412
|
+
// As strict as the declaration it becomes (`additionalProperties: false` in
|
|
413
|
+
// `./config/recurrencesSchema.js`): a key nobody reads is a typo or a second definition of
|
|
414
|
+
// something, and dropping it in silence hides both (`architecture-principles.md` §3).
|
|
415
|
+
for (const key of Object.keys(record)) {
|
|
416
|
+
if (!RECURRING_PORT_KEYS.includes(key)) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
`[mq-client-core/scheduler-request] registerRecurring got a key the request does not declare ("${key}") - `
|
|
419
|
+
+ 'Expected: exactly { id, cookbookRef, cron, timezone, tenantId, workspaceId, input?, catchUp? }. '
|
|
420
|
+
+ 'Fix: remove the key from the record the service returns / the ctx.scheduler call.'
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const { id, cookbookRef, cron, timezone, tenantId, workspaceId, input, catchUp } = record;
|
|
426
|
+
const declaration = { id, cookbook_ref: cookbookRef, cron, timezone };
|
|
427
|
+
if (input !== undefined) declaration.input = input;
|
|
428
|
+
if (catchUp !== undefined) declaration.catch_up = catchUp;
|
|
429
|
+
|
|
430
|
+
await publishSchedulerRequest(mqClient, buildRecurringRequest({
|
|
431
|
+
declaration,
|
|
432
|
+
tenantId,
|
|
433
|
+
workspaceId,
|
|
434
|
+
requestedBy: by
|
|
435
|
+
}));
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Announce one fact. The service is this port's; the key is the caller's (the identity of the
|
|
440
|
+
* thing that happened - a job's uuid, not a clock reading), because the scheduler decides a
|
|
441
|
+
* one-shot item once by it.
|
|
442
|
+
*
|
|
443
|
+
* @param {Object} args - `{ name, idempotencyKey, tenantId, workspaceId, input? }`
|
|
444
|
+
* @returns {Promise<void>}
|
|
445
|
+
*/
|
|
446
|
+
async publishEvent({ name, idempotencyKey, tenantId, workspaceId, input } = {}) {
|
|
447
|
+
await publishSchedulerRequest(mqClient, buildEventRequest({
|
|
448
|
+
event: { service: by, name },
|
|
449
|
+
idempotencyKey,
|
|
450
|
+
tenantId,
|
|
451
|
+
workspaceId,
|
|
452
|
+
requestedBy: by,
|
|
453
|
+
input
|
|
454
|
+
}));
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Take back an item this service registered, by its id.
|
|
459
|
+
*
|
|
460
|
+
* @param {Object} args - `{ id }`
|
|
461
|
+
* @returns {Promise<void>}
|
|
462
|
+
*/
|
|
463
|
+
async cancel({ id } = {}) {
|
|
464
|
+
await publishSchedulerRequest(mqClient, buildCancelRequest({
|
|
465
|
+
idempotencyKey: id,
|
|
466
|
+
requestedBy: by
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
module.exports = {
|
|
473
|
+
schedulerRequestQueue,
|
|
474
|
+
SENDABLE_KINDS,
|
|
475
|
+
validateRecurrences,
|
|
476
|
+
buildRecurringRequest,
|
|
477
|
+
buildEventRequest,
|
|
478
|
+
buildCancelRequest,
|
|
479
|
+
publishSchedulerRequest,
|
|
480
|
+
createSchedulerPublisher
|
|
481
|
+
};
|