@onlineapps/conn-orch-registry 6.0.3 → 8.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/API.md +852 -0
- package/CHANGELOG.md +621 -0
- package/README.md +55 -12
- package/docs/REGISTRY_CLIENT_GUIDE.md +2 -2
- package/examples/basicUsage.js +58 -64
- package/examples/env.js +59 -0
- package/examples/event-consumer-example.js +56 -44
- package/package.json +7 -5
- package/src/registryClient.js +309 -36
- package/src/registryEventConsumer.js +14 -2
package/src/registryClient.js
CHANGED
|
@@ -35,6 +35,20 @@ const { queueConfig } = require('@onlineapps/mq-client-core');
|
|
|
35
35
|
const DEFAULTS = require('./defaults');
|
|
36
36
|
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The two ways a discovery read can fail that say nothing about the answer — the
|
|
40
|
+
* projection did not answer in time, or the client already knows Redis is gone. Both
|
|
41
|
+
* are typed HERE, where the failure is known, as `TRANSIENT` (d.1093; the pattern
|
|
42
|
+
* mq-client-core uses since d.683: a bare error from the transport is typed by the
|
|
43
|
+
* site that receives it, `src/utils/publishErrors.js` `TransientPublishError`). The
|
|
44
|
+
* L1 classifier reads `type` and never learns what Redis is; cookbook-router carries
|
|
45
|
+
* `type` from `cause` onto `SERVICE_DISCOVERY_FAILED`.
|
|
46
|
+
*/
|
|
47
|
+
const DISCOVERY_FAILURE_CODES = Object.freeze({
|
|
48
|
+
timeout: 'REGISTRY_DISCOVERY_TIMEOUT',
|
|
49
|
+
unavailable: 'REGISTRY_DISCOVERY_UNAVAILABLE'
|
|
50
|
+
});
|
|
51
|
+
|
|
38
52
|
/**
|
|
39
53
|
* Keys `register()` REFUSES, with the reason the caller needs to act on
|
|
40
54
|
* (d.562, extended by d.592).
|
|
@@ -68,6 +82,38 @@ const RETIRED_SERVICE_INFO_KEYS = Object.freeze({
|
|
|
68
82
|
+ 'destructure it, so deployability is read from that CI job and never from a registration message'
|
|
69
83
|
});
|
|
70
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The keys `register()` ACCEPTS — the whole input contract of the call (d.702).
|
|
87
|
+
*
|
|
88
|
+
* It is the caller's half of `api/docs/biz/30-operations/registration-wire.md`
|
|
89
|
+
* §2.1: the five register-message fields a CALLER supplies, plus `timeout`, which
|
|
90
|
+
* is this call's own response budget and reaches no message. The rest of §2.1
|
|
91
|
+
* (`id`, `type`, `serviceName`, `version`, `specificationEndpoint`,
|
|
92
|
+
* `responseQueue`, `timestamp`, `validationProof`, `validationData`) is client
|
|
93
|
+
* state bound in the constructor, never caller input — a caller cannot set it and
|
|
94
|
+
* this table must not invite one to try.
|
|
95
|
+
*
|
|
96
|
+
* Any other key is refused BY NAME, for the same reason the table above exists:
|
|
97
|
+
* until d.702 `register()` refused six retired keys and dropped everything else in
|
|
98
|
+
* silence, so `helth`, `current_step` or `name` bought a confirmed registration
|
|
99
|
+
* whose message never carried the value. A key silently dropped leaves the caller
|
|
100
|
+
* believing something travelled, and a typo in an optional key is exactly the
|
|
101
|
+
* shape nobody notices (`architecture-principles.md` §3 no fallbacks, §4
|
|
102
|
+
* fail-fast).
|
|
103
|
+
*
|
|
104
|
+
* `RETIRED_SERVICE_INFO_KEYS` is the subset that can still say WHY the key went;
|
|
105
|
+
* ONE membership check reads both tables, so there is no second list to drift
|
|
106
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
107
|
+
*/
|
|
108
|
+
const ACCEPTED_SERVICE_INFO_KEYS = Object.freeze({
|
|
109
|
+
endpoints: 'the `endpoints` array of the register message (§2.1); `[]` for every v3 service',
|
|
110
|
+
operations: 'the complete `operations` map the registry validates (§3)',
|
|
111
|
+
metadata: 'the `{ version, description }` object stored beside the spec (§2.1)',
|
|
112
|
+
health: 'the legacy health path of the register message; liveness travels by heartbeat (§2.2)',
|
|
113
|
+
workspaceScoped: 'the boolean emitted only when the service declares it (§2.1)',
|
|
114
|
+
timeout: 'how long THIS call waits for the registry verdict; it reaches no message'
|
|
115
|
+
});
|
|
116
|
+
|
|
71
117
|
|
|
72
118
|
class ServiceRegistryClient extends EventEmitter {
|
|
73
119
|
/**
|
|
@@ -82,12 +128,19 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
82
128
|
* @param {number} opts.heartbeatInterval - Milliseconds between heartbeats. REQUIRED: the
|
|
83
129
|
* cadence has ONE owner and it is not a library default (owner confirmation
|
|
84
130
|
* docs/governance/confirmations/biz-health-freshness.md 001 point 3); ServiceWrapper
|
|
85
|
-
*
|
|
131
|
+
* reads it from the environment key BIZ_HEARTBEAT_INTERVAL_MS and passes it here. The
|
|
132
|
+
* config key `wrapper.registry.heartbeatInterval` is retired and the wrapper refuses it.
|
|
86
133
|
* @param {string} [opts.apiQueue='api_services_queuer'] - Queue name for heartbeat and API traffic
|
|
87
134
|
* @param {string} [opts.registryQueue='registry.register'] - Queue name for registry messages
|
|
88
135
|
* @param {Object} [opts.validationProof=null] - Validation proof object {hash, data} (injected from outside)
|
|
89
136
|
* @param {number} [opts.registrationTimeoutMs] - How long register() waits for the registry response
|
|
90
137
|
* before rejecting. Default comes from ./defaults.js (30000 ms); never read from the environment here.
|
|
138
|
+
* @param {number} opts.discoveryTimeoutMs - How long ONE discovery read (`getService()`)
|
|
139
|
+
* may wait for the registry projection before it is refused as `TRANSIENT`. REQUIRED,
|
|
140
|
+
* no module default: the value belongs to the platform (ServiceWrapper reads it from
|
|
141
|
+
* the platform env) and bounds how long a workflow step can hang on a Redis that is
|
|
142
|
+
* down or half-open (d.1090, d.1093). Enforced as a race beside the command, never as
|
|
143
|
+
* node-redis' `AbortSignal` (see `getService()` for why).
|
|
91
144
|
* @param {Object} opts.logger - Logger with info/warn/error/debug. Required and validated here:
|
|
92
145
|
* registration, heartbeat and revalidation events are what this client exists to report, and
|
|
93
146
|
* until 2026-09-05 they went to stdout through bare printing while this parameter was accepted
|
|
@@ -95,7 +148,7 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
95
148
|
* docs/governance/confirmations/connector-logger-contract.md 001 + 002.
|
|
96
149
|
*/
|
|
97
150
|
constructor({ amqpUrl, serviceName, version, specificationEndpoint,
|
|
98
|
-
heartbeatInterval, apiQueue = 'api_services_queuer', registryQueue = 'registry.register',
|
|
151
|
+
heartbeatInterval, discoveryTimeoutMs, instanceId, apiQueue = 'api_services_queuer', registryQueue = 'registry.register',
|
|
99
152
|
redis = null, storageConfig = {}, validationProof = null, logger,
|
|
100
153
|
registryKeyPrefix = DEFAULTS.registryKeyPrefix,
|
|
101
154
|
registrationTimeoutMs = DEFAULTS.registrationTimeoutMs }) {
|
|
@@ -118,6 +171,32 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
118
171
|
+ `(module default ${DEFAULTS.registrationTimeoutMs}).`
|
|
119
172
|
);
|
|
120
173
|
}
|
|
174
|
+
// WHICH RUNNING COPY. The service NAME says which service; until d.817 nothing said
|
|
175
|
+
// which copy, so a short-lived foreign instance deregistered the live one and three
|
|
176
|
+
// services then beat into `unknown` for nine hours (INFRA batch 398, from production
|
|
177
|
+
// logs; owner decision `registry-instance-identity` 001).
|
|
178
|
+
//
|
|
179
|
+
// INJECTED, never made here (§1 DI, §3 no fallback): the wrapper makes ONE value per
|
|
180
|
+
// process at boot and hands the same one to `monitoring-core` as `serviceInstanceId`,
|
|
181
|
+
// so a second birthplace here would give one running copy two identities. Rule 11 —
|
|
182
|
+
// no default, no `|| uuidv4()`, no transition period.
|
|
183
|
+
if (typeof instanceId !== 'string' || instanceId === '') {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`[RegistryClient] ${serviceName}: Missing instanceId - got ${JSON.stringify(instanceId)}. `
|
|
186
|
+
+ 'Expected: a non-empty string naming THIS running copy of the service, the same value for the '
|
|
187
|
+
+ 'whole process; the service name says which service, this says which copy. '
|
|
188
|
+
+ 'Fix: pass the instance id the wrapper made once at boot (SERVICE_INSTANCE_ID when the '
|
|
189
|
+
+ 'deployment gives one, otherwise a UUID v4) - this client never makes one, because the same '
|
|
190
|
+
+ 'value also reaches monitoring-core as serviceInstanceId.'
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
this.instanceId = instanceId;
|
|
194
|
+
// Where the registry answers THIS copy's registration (conf
|
|
195
|
+
// `registry-instance-identity` 001 (c), d.817c) — carried as `responseQueue`.
|
|
196
|
+
// The shared `<service>.registry` (`serviceRegistryQueue`, set in `init()`)
|
|
197
|
+
// carries the registry's own prompts to every copy.
|
|
198
|
+
this.instanceResponseQueue = `${serviceName}.registry.${instanceId}`;
|
|
199
|
+
|
|
121
200
|
this.registrationTimeoutMs = registrationTimeoutMs;
|
|
122
201
|
this.serviceName = serviceName;
|
|
123
202
|
this.version = version;
|
|
@@ -136,7 +215,8 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
136
215
|
`[RegistryClient] ${serviceName}: Missing constructor option - heartbeatInterval is required. `
|
|
137
216
|
+ 'Expected: the cadence its one owner decided (docs/governance/confirmations/biz-health-freshness.md 001 '
|
|
138
217
|
+ 'point 3), passed as a positive number of milliseconds. '
|
|
139
|
-
+ 'Fix: pass
|
|
218
|
+
+ 'Fix: pass heartbeatInterval; ServiceWrapper reads it from env BIZ_HEARTBEAT_INTERVAL_MS '
|
|
219
|
+
+ '(the config key wrapper.registry.heartbeatInterval is retired and refused).'
|
|
140
220
|
);
|
|
141
221
|
}
|
|
142
222
|
if (typeof heartbeatInterval !== 'number' || !Number.isFinite(heartbeatInterval) || heartbeatInterval <= 0) {
|
|
@@ -145,8 +225,23 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
145
225
|
+ `got ${JSON.stringify(heartbeatInterval)}. Fix: pass the cadence as a number; a string is not parsed here.`
|
|
146
226
|
);
|
|
147
227
|
}
|
|
228
|
+
if (discoveryTimeoutMs === undefined) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`[RegistryClient] ${serviceName}: Missing constructor option - discoveryTimeoutMs is required. `
|
|
231
|
+
+ 'Expected: how long (ms) one discovery read of the registry projection may wait before it is '
|
|
232
|
+
+ 'refused as TRANSIENT, as a positive number. '
|
|
233
|
+
+ 'Fix: pass discoveryTimeoutMs; ServiceWrapper reads it from the platform env (shared.env).'
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (typeof discoveryTimeoutMs !== 'number' || !Number.isFinite(discoveryTimeoutMs) || discoveryTimeoutMs <= 0) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`[RegistryClient] ${serviceName}: Invalid discoveryTimeoutMs - expected a positive number of milliseconds, `
|
|
239
|
+
+ `got ${JSON.stringify(discoveryTimeoutMs)}. Fix: pass the deadline as a number; a string is not parsed here.`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
148
242
|
this.specificationEndpoint = specificationEndpoint;
|
|
149
243
|
this.heartbeatInterval = heartbeatInterval;
|
|
244
|
+
this.discoveryTimeoutMs = discoveryTimeoutMs;
|
|
150
245
|
this.apiQueue = apiQueue;
|
|
151
246
|
this.registryQueue = registryQueue;
|
|
152
247
|
this.queueManager = new QueueManager(amqpUrl, serviceName, this.logger);
|
|
@@ -195,8 +290,42 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
195
290
|
// agreeing today, and 406 PRECONDITION-FAILED the day the two drift apart
|
|
196
291
|
// (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
197
292
|
const consumerStartTime = Date.now();
|
|
293
|
+
// TWO queues, two jobs (conf `registry-instance-identity` 001 (c), d.817c):
|
|
294
|
+
// - `<service>.registry`, shared by every copy — the registry's own prompts
|
|
295
|
+
// (`revalidate`; `api_services_registry` `revalidationRequester.js` sends to
|
|
296
|
+
// `<serviceName>.registry`);
|
|
297
|
+
// - `<service>.registry.<instance_id>` — the answers to THIS copy's registration,
|
|
298
|
+
// named in `register`'s `responseQueue`, so a live copy can no longer eat the
|
|
299
|
+
// confirmation meant for another.
|
|
300
|
+
// Both have a dead-letter route in `queueConfig` (the per-instance template landed
|
|
301
|
+
// first, d.817b — the order conf 002 made binding), so `consume()` accepts both.
|
|
198
302
|
this.serviceRegistryQueue = `${this.serviceName}.registry`;
|
|
199
303
|
|
|
304
|
+
for (const queue of [this.serviceRegistryQueue, this.instanceResponseQueue]) {
|
|
305
|
+
await this._startRegistryConsumer(queue);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// One line for the whole boot of this client: which queues it talks on, and how
|
|
309
|
+
// long the consumers took to attach. Replaces the four FÁZE 0.5/0.6 markers.
|
|
310
|
+
this.logger.info('[RegistryClient] Initialized', {
|
|
311
|
+
serviceName: this.serviceName,
|
|
312
|
+
version: this.version,
|
|
313
|
+
registryQueue: this.registryQueue,
|
|
314
|
+
promptQueue: this.serviceRegistryQueue,
|
|
315
|
+
responseQueue: this.instanceResponseQueue,
|
|
316
|
+
consumerStartupMs: Date.now() - consumerStartTime
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Attach this client's handler to one registry queue, within a bounded wait.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} queue - `<service>.registry` or `<service>.registry.<instance_id>`
|
|
324
|
+
* @returns {Promise<void>}
|
|
325
|
+
* @throws {Error} naming the queue, carrying the source's classification
|
|
326
|
+
* @private
|
|
327
|
+
*/
|
|
328
|
+
async _startRegistryConsumer(queue) {
|
|
200
329
|
const CONSUME_TIMEOUT = 5000; // 5 seconds
|
|
201
330
|
// `requeueOnError: false` is the budget this consumer has always had, said in
|
|
202
331
|
// the core's word: ONE attempt. The difference is where a failure goes — the
|
|
@@ -206,7 +335,7 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
206
335
|
// nothing itself (@onlineapps/mq-client-core README § consume() — the delivery
|
|
207
336
|
// contract).
|
|
208
337
|
const consumeRegistryPromise = this.queueManager.consume(
|
|
209
|
-
|
|
338
|
+
queue,
|
|
210
339
|
msg => this._handleRegistryMessage(msg),
|
|
211
340
|
{ requeueOnError: false }
|
|
212
341
|
);
|
|
@@ -214,7 +343,7 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
214
343
|
const consumeRegistryTimeoutPromise = new Promise((_, reject) => {
|
|
215
344
|
consumeRegistryTimeout = setTimeout(() => {
|
|
216
345
|
reject(new Error(
|
|
217
|
-
`[RegistryClient] consume() timeout for ${
|
|
346
|
+
`[RegistryClient] consume() timeout for ${queue} after ${CONSUME_TIMEOUT}ms - `
|
|
218
347
|
+ 'the broker never confirmed the consumer. Fix: check RabbitMQ responsiveness and that the queue exists.'
|
|
219
348
|
));
|
|
220
349
|
}, CONSUME_TIMEOUT);
|
|
@@ -234,7 +363,7 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
234
363
|
// { cause })` carries no own property, so the marker used to die here.
|
|
235
364
|
// Nothing is classified HERE — what the source left unmarked stays unmarked.
|
|
236
365
|
const refusal = new Error(
|
|
237
|
-
`[RegistryClient] ${this.serviceName}: Failed to start consumer on ${
|
|
366
|
+
`[RegistryClient] ${this.serviceName}: Failed to start consumer on ${queue} - `
|
|
238
367
|
+ `${consumeErr.message}. Fix: check RabbitMQ health; without this consumer no registry reply is ever read.`,
|
|
239
368
|
{ cause: consumeErr }
|
|
240
369
|
);
|
|
@@ -244,16 +373,6 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
244
373
|
} finally {
|
|
245
374
|
if (consumeRegistryTimeout) clearTimeout(consumeRegistryTimeout);
|
|
246
375
|
}
|
|
247
|
-
|
|
248
|
-
// One line for the whole boot of this client: which queues it talks on, and how
|
|
249
|
-
// long the consumer took to attach. Replaces the four FÁZE 0.5/0.6 markers.
|
|
250
|
-
this.logger.info('[RegistryClient] Initialized', {
|
|
251
|
-
serviceName: this.serviceName,
|
|
252
|
-
version: this.version,
|
|
253
|
-
registryQueue: this.registryQueue,
|
|
254
|
-
responseQueue: this.serviceRegistryQueue,
|
|
255
|
-
consumerStartupMs: Date.now() - consumerStartTime
|
|
256
|
-
});
|
|
257
376
|
}
|
|
258
377
|
|
|
259
378
|
/**
|
|
@@ -468,11 +587,17 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
468
587
|
* Registers the service with the registry.
|
|
469
588
|
* Registry will validate if the service is properly tested and valid.
|
|
470
589
|
* Upon successful validation, the service will be activated and can start heartbeats.
|
|
471
|
-
* @param {Object} serviceInfo - Service registration information
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
* @param {
|
|
590
|
+
* @param {Object} serviceInfo - Service registration information. It accepts EXACTLY the keys
|
|
591
|
+
* below (`ACCEPTED_SERVICE_INFO_KEYS`) — any other key is refused by name, and a retired one
|
|
592
|
+
* (`RETIRED_SERVICE_INFO_KEYS`) with the reason it went. Nothing is dropped in silence: a key
|
|
593
|
+
* this call ignored would leave the caller believing it travelled.
|
|
594
|
+
* @param {Array} [serviceInfo.endpoints] - API endpoints provided by the service; `[]` for v3 services
|
|
595
|
+
* @param {Object} [serviceInfo.operations] - The complete `operations` map the registry validates
|
|
596
|
+
* @param {Object} [serviceInfo.metadata] - `{ version, description }` stored beside the spec
|
|
597
|
+
* @param {string} [serviceInfo.health] - Legacy health path; liveness travels by heartbeat
|
|
598
|
+
* @param {boolean} [serviceInfo.workspaceScoped] - Emitted only when the service declares it
|
|
599
|
+
* @param {number} [serviceInfo.timeout] - How long THIS call waits for the registry verdict
|
|
600
|
+
* (default: the constructor's registrationTimeoutMs). It reaches no message.
|
|
476
601
|
* @returns {Promise<{success: boolean, message: string, serviceName: string, version: string,
|
|
477
602
|
* registrationId: (string|undefined), validated: boolean, certificate: (Object|null),
|
|
478
603
|
* errorCode: (string|null|undefined)}>} The registry verdict. `errorCode` is the
|
|
@@ -492,9 +617,12 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
492
617
|
);
|
|
493
618
|
}
|
|
494
619
|
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
620
|
+
// ONE membership check over the two tables above, before anything is published.
|
|
621
|
+
// A key the contract does not name is refused BY NAME: dropping it in silence
|
|
622
|
+
// would leave the caller believing it travelled. A retired key keeps its own
|
|
623
|
+
// reason - it is the subset that can still say what replaced it. Neither
|
|
624
|
+
// refusal echoes the value: two of the retired keys carried a secret, and an
|
|
625
|
+
// unknown key can carry anything.
|
|
498
626
|
for (const key of Object.keys(serviceInfo)) {
|
|
499
627
|
if (Object.prototype.hasOwnProperty.call(RETIRED_SERVICE_INFO_KEYS, key)) {
|
|
500
628
|
throw new Error(
|
|
@@ -503,6 +631,15 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
503
631
|
+ `Fix: delete "${key}" from the object passed to register().`
|
|
504
632
|
);
|
|
505
633
|
}
|
|
634
|
+
if (!Object.prototype.hasOwnProperty.call(ACCEPTED_SERVICE_INFO_KEYS, key)) {
|
|
635
|
+
throw new Error(
|
|
636
|
+
`[RegistryClient] ${this.serviceName}: serviceInfo key "${key}" is not part of the registration `
|
|
637
|
+
+ `contract - Expected one of: ${Object.keys(ACCEPTED_SERVICE_INFO_KEYS).join(', ')} `
|
|
638
|
+
+ '(api/docs/biz/30-operations/registration-wire.md §2.1). '
|
|
639
|
+
+ `Fix: delete "${key}" from the object passed to register(), or send the value under the key `
|
|
640
|
+
+ 'the contract names for it.'
|
|
641
|
+
);
|
|
642
|
+
}
|
|
506
643
|
}
|
|
507
644
|
|
|
508
645
|
// FAIL-FAST: a client that was never initialized, or that has been closed, is not
|
|
@@ -519,9 +656,32 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
519
656
|
}
|
|
520
657
|
|
|
521
658
|
const msgId = uuidv4();
|
|
659
|
+
|
|
660
|
+
// FAIL-FAST: one request id, one pending registration. `pendingRegistrations` and
|
|
661
|
+
// `pendingTimeouts` are keyed by this id, and until d.760b a second call under an id
|
|
662
|
+
// that was still pending simply set() over both - which dropped the earlier timer's
|
|
663
|
+
// handle. Nothing could reach it afterwards: the registry's answer clears by id
|
|
664
|
+
// (_handleRegistryMessage), and close() walks the table, so both found only the newer
|
|
665
|
+
// handle and the older timer ran to its full 30 s. Cancelling the earlier registration
|
|
666
|
+
// here instead would be a fallback (§3): its caller is still awaiting an answer that
|
|
667
|
+
// belongs to it, and this class cannot know which of the two calls is the mistake.
|
|
668
|
+
if (this.pendingRegistrations && this.pendingRegistrations.has(msgId)) {
|
|
669
|
+
throw new Error(
|
|
670
|
+
`[RegistryClient] ${this.serviceName}: Duplicate request id "${msgId}" - a registration with this id `
|
|
671
|
+
+ 'is still pending. Expected: one register() in flight per request id; it is settled by the registry '
|
|
672
|
+
+ `answer, by the ${this.registrationTimeoutMs}ms timeout or by close(). `
|
|
673
|
+
+ 'Fix: await the register() already in flight before starting another one, and - if the ids repeat '
|
|
674
|
+
+ 'because uuid.v4 is stubbed - give each registration its own id.'
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
522
678
|
// @see api/docs/biz/30-operations/registration-wire.md §2 MQ wire format
|
|
523
679
|
const msg = {
|
|
524
680
|
id: msgId,
|
|
681
|
+
// WHICH REQUEST (`id`, per call) and WHICH COPY (`instance_id`, per process) are
|
|
682
|
+
// two identities with two lifetimes — d.817, owner `registry-instance-identity` 001.
|
|
683
|
+
// snake_case is the one spelling the wire has (`workflow-message-aliases` 001).
|
|
684
|
+
instance_id: this.instanceId,
|
|
525
685
|
type: 'register',
|
|
526
686
|
serviceName: this.serviceName,
|
|
527
687
|
version: this.version,
|
|
@@ -530,7 +690,7 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
530
690
|
operations: serviceInfo.operations || {},
|
|
531
691
|
metadata: serviceInfo.metadata || {},
|
|
532
692
|
health: serviceInfo.health || '/health',
|
|
533
|
-
responseQueue: this.
|
|
693
|
+
responseQueue: this.instanceResponseQueue, // THIS copy's answer queue (conf registry-instance-identity 001 (c))
|
|
534
694
|
timestamp: new Date().toISOString()
|
|
535
695
|
};
|
|
536
696
|
|
|
@@ -572,7 +732,7 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
572
732
|
reject(new Error(
|
|
573
733
|
`[RegistryClient] ${this.serviceName}: Registration timed out - no response from the registry `
|
|
574
734
|
+ `within ${timeout}ms. Expected: api_services_registry consumes '${this.registryQueue}' and answers `
|
|
575
|
-
+ `on '${this.
|
|
735
|
+
+ `on '${this.instanceResponseQueue}'. Fix: start api_services_registry (docker compose up -d `
|
|
576
736
|
+ 'api_services_registry) or raise registrationTimeoutMs.'
|
|
577
737
|
));
|
|
578
738
|
}
|
|
@@ -627,7 +787,7 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
627
787
|
serviceName: this.serviceName,
|
|
628
788
|
version: this.version,
|
|
629
789
|
registryQueue: this.registryQueue,
|
|
630
|
-
responseQueue: this.
|
|
790
|
+
responseQueue: this.instanceResponseQueue,
|
|
631
791
|
requestId: msgId,
|
|
632
792
|
hasValidationProof: !!this.validationProof,
|
|
633
793
|
proofHash: this.validationProof
|
|
@@ -693,14 +853,25 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
693
853
|
* where it published it, and that the heartbeat timer is stopped. A publish that
|
|
694
854
|
* fails REJECTS; reaching the return means the broker took the message.
|
|
695
855
|
*
|
|
856
|
+
* The heartbeat loop stops FIRST, before anything is published. A heartbeat is
|
|
857
|
+
* the claim "this copy is alive"; a caller that deregisters has decided it is
|
|
858
|
+
* leaving, and that decision does not wait for the broker. Until d.1083 the loop
|
|
859
|
+
* stopped only after a successful publish, so a rejected deregistration left it
|
|
860
|
+
* beating for a service on its way out.
|
|
861
|
+
*
|
|
696
862
|
* @returns {Promise<{requestId: string, serviceName: string, version: string,
|
|
697
863
|
* publishedTo: string, heartbeatStopped: boolean, publishedAt: string}>}
|
|
698
864
|
*/
|
|
699
865
|
async deregister() {
|
|
866
|
+
this.stopHeartbeat();
|
|
867
|
+
|
|
700
868
|
const msg = {
|
|
701
869
|
id: uuidv4(),
|
|
702
870
|
type: 'deregister',
|
|
703
871
|
serviceName: this.serviceName,
|
|
872
|
+
// The reason this key exists at all: the registry deletes only on a match, so a
|
|
873
|
+
// short-lived foreign copy can no longer deregister the live one (d.817).
|
|
874
|
+
instance_id: this.instanceId,
|
|
704
875
|
version: this.version,
|
|
705
876
|
timestamp: new Date().toISOString()
|
|
706
877
|
};
|
|
@@ -720,9 +891,6 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
720
891
|
|
|
721
892
|
this.emit('deregisterSent', msg);
|
|
722
893
|
|
|
723
|
-
// Stop heartbeat if running
|
|
724
|
-
this.stopHeartbeat();
|
|
725
|
-
|
|
726
894
|
return {
|
|
727
895
|
requestId: msg.id,
|
|
728
896
|
serviceName: this.serviceName,
|
|
@@ -752,6 +920,9 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
752
920
|
id: uuidv4(),
|
|
753
921
|
type: 'heartbeat',
|
|
754
922
|
serviceName: this.serviceName,
|
|
923
|
+
// The beat says which copy is alive, so the registry can keep its availability
|
|
924
|
+
// episodes per instance instead of per name (d.817, INFRA half).
|
|
925
|
+
instance_id: this.instanceId,
|
|
755
926
|
version: this.version,
|
|
756
927
|
specificationEndpoint: this.specificationEndpoint,
|
|
757
928
|
timestamp: new Date().toISOString()
|
|
@@ -778,9 +949,10 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
778
949
|
*
|
|
779
950
|
* A heartbeat loop with no budget is worse than none: the beats stop reaching
|
|
780
951
|
* the registry, the loop logs and reschedules forever, and the service keeps
|
|
781
|
-
* running while every consumer of `registry:services` reads it as stale.
|
|
782
|
-
*
|
|
783
|
-
* loop
|
|
952
|
+
* running while every consumer of `registry:services` reads it as stale. The
|
|
953
|
+
* budget lives here and nowhere else: `@onlineapps/service-wrapper` arms this
|
|
954
|
+
* loop and runs none of its own (service-wrapper d.1076, its CHANGELOG) — two
|
|
955
|
+
* loops for one cadence would be two rails for one concern
|
|
784
956
|
* (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
785
957
|
*
|
|
786
958
|
* The cadence is NOT an argument here. It has one owner and it is the
|
|
@@ -801,6 +973,24 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
801
973
|
* budget is spent. The loop is already stopped when it is called.
|
|
802
974
|
*/
|
|
803
975
|
startHeartbeat(options) {
|
|
976
|
+
// FAIL-FAST: one client, one loop. Checked before the options, because the state
|
|
977
|
+
// of the receiver is the problem here whatever the arguments say. Until d.760c the
|
|
978
|
+
// line at the end of this method wrote the new handle over `heartbeatTimer` and the
|
|
979
|
+
// earlier loop lost the only reference to itself: it re-arms while that field is
|
|
980
|
+
// not null, and the field now belonged to the newer loop, so `stopHeartbeat()`
|
|
981
|
+
// cancelled one loop and the other went on beating for a service that had stopped.
|
|
982
|
+
// Replacing the running loop silently would be a fallback (§3) - the caller that
|
|
983
|
+
// started it is not asking for it to be discarded, and this class cannot know which
|
|
984
|
+
// of the two callers is the mistake.
|
|
985
|
+
if (this.heartbeatTimer) {
|
|
986
|
+
throw new Error(
|
|
987
|
+
`[RegistryClient] ${this.serviceName}: A heartbeat loop is already running - starting a second one would `
|
|
988
|
+
+ 'overwrite the timer handle of the first, which would then beat on with nothing able to stop it. '
|
|
989
|
+
+ 'Expected: one heartbeat loop per client; stop it with stopHeartbeat() first. '
|
|
990
|
+
+ 'Fix: call client.stopHeartbeat() before starting a new loop, or keep the loop that is already running.'
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
804
994
|
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
|
|
805
995
|
throw new Error(
|
|
806
996
|
`[RegistryClient] ${this.serviceName}: Missing heartbeat options - startHeartbeat({ maxFailures, onFatal }) `
|
|
@@ -886,12 +1076,21 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
886
1076
|
|
|
887
1077
|
/**
|
|
888
1078
|
* Stops periodic heartbeat messages.
|
|
1079
|
+
*
|
|
1080
|
+
* One scheduler, one cancel: `startHeartbeat()` arms the loop with a recursive
|
|
1081
|
+
* `setTimeout` and nothing in this class has created an interval since that
|
|
1082
|
+
* rewrite (d.503), so `clearTimeout` is the whole job. A `clearInterval` on the
|
|
1083
|
+
* same handle stood beside it until d.752, under a comment offering to cancel
|
|
1084
|
+
* "whether it's from setTimeout or setInterval" — a choice the class does not
|
|
1085
|
+
* have, and one that told every reader the loop might be an interval.
|
|
1086
|
+
*
|
|
1087
|
+
* Setting `heartbeatTimer` to null is the second half and not a formality: the
|
|
1088
|
+
* loop reads it before arming the next beat, so a beat already in flight sees
|
|
1089
|
+
* the stop and schedules nothing.
|
|
889
1090
|
*/
|
|
890
1091
|
stopHeartbeat() {
|
|
891
1092
|
if (this.heartbeatTimer) {
|
|
892
|
-
// Clear timeout regardless of whether it's from setTimeout or setInterval
|
|
893
1093
|
clearTimeout(this.heartbeatTimer);
|
|
894
|
-
clearInterval(this.heartbeatTimer);
|
|
895
1094
|
this.heartbeatTimer = null;
|
|
896
1095
|
}
|
|
897
1096
|
}
|
|
@@ -970,7 +1169,38 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
970
1169
|
}
|
|
971
1170
|
|
|
972
1171
|
const key = `${this.registryKeyPrefix}services`;
|
|
973
|
-
|
|
1172
|
+
// THE DEADLINE IS A RACE, NOT AN AbortSignal (d.1093). Without a deadline `hGet`
|
|
1173
|
+
// never settles while Redis is down or half-open (d.1088, d.1090). node-redis 4.7.1
|
|
1174
|
+
// does offer `commandOptions({ signal })`, but aborting a command the server has
|
|
1175
|
+
// already received removes its queue node a second time (the abort listener stays
|
|
1176
|
+
// attached after the command is sent), the queue length goes negative, and the next
|
|
1177
|
+
// `flushAll` — a socket error with `disableOfflineQueue`, or `disconnect()` — throws
|
|
1178
|
+
// `TypeError: Cannot read properties of undefined (reading 'reject')`; from the socket
|
|
1179
|
+
// error handler that is an uncaught exception that ends the process (measured d.1093,
|
|
1180
|
+
// fake RESP server). The race leaves the command in the queue: a late reply is still
|
|
1181
|
+
// matched to it, and its late settlement is caught below, never unhandled.
|
|
1182
|
+
const lookup = this.redis.hGet(key, serviceName);
|
|
1183
|
+
let timer;
|
|
1184
|
+
let timedOut = false;
|
|
1185
|
+
const deadline = new Promise((resolve) => {
|
|
1186
|
+
timer = setTimeout(() => { timedOut = true; resolve(); }, this.discoveryTimeoutMs);
|
|
1187
|
+
});
|
|
1188
|
+
let raw;
|
|
1189
|
+
try {
|
|
1190
|
+
raw = await Promise.race([lookup, deadline]);
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
throw this._typeDiscoveryFailure(err, { key, serviceName });
|
|
1193
|
+
} finally {
|
|
1194
|
+
clearTimeout(timer);
|
|
1195
|
+
}
|
|
1196
|
+
if (timedOut) {
|
|
1197
|
+
lookup.then(() => {}, (late) => {
|
|
1198
|
+
this.logger.debug(
|
|
1199
|
+
`[RegistryClient] getService - abandoned lookup of "${serviceName}" settled after the deadline: ${late.message}`
|
|
1200
|
+
);
|
|
1201
|
+
});
|
|
1202
|
+
throw this._discoveryTimeout({ key, serviceName });
|
|
1203
|
+
}
|
|
974
1204
|
if (!raw) {
|
|
975
1205
|
return null;
|
|
976
1206
|
}
|
|
@@ -994,6 +1224,49 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
994
1224
|
};
|
|
995
1225
|
}
|
|
996
1226
|
|
|
1227
|
+
/**
|
|
1228
|
+
* The lookup did not end within `discoveryTimeoutMs`. Nothing failed underneath, so
|
|
1229
|
+
* the error carries no `cause`.
|
|
1230
|
+
* @private
|
|
1231
|
+
* @returns {Error}
|
|
1232
|
+
*/
|
|
1233
|
+
_discoveryTimeout({ key, serviceName }) {
|
|
1234
|
+
const failure = new Error(
|
|
1235
|
+
`[RegistryClient] getService - Registry projection did not answer the lookup of "${serviceName}" `
|
|
1236
|
+
+ `within ${this.discoveryTimeoutMs} ms (key ${key}). Expected: a reply from the Redis the registry projects into. `
|
|
1237
|
+
+ 'Fix: make that Redis reachable to this service; the step is retried as TRANSIENT.'
|
|
1238
|
+
);
|
|
1239
|
+
failure.code = DISCOVERY_FAILURE_CODES.timeout;
|
|
1240
|
+
failure.type = 'TRANSIENT';
|
|
1241
|
+
failure.serviceName = serviceName;
|
|
1242
|
+
return failure;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Types a rejected discovery read by what THIS client knows, never by the text of the
|
|
1247
|
+
* rejection (node-redis errors carry no `code` and no `name`, measured d.1090): a client
|
|
1248
|
+
* that reports it is not ready means Redis is gone (offline with `disableOfflineQueue`,
|
|
1249
|
+
* socket closed under the command). Anything else — a reply error from a connected
|
|
1250
|
+
* Redis — is not a transport verdict and passes unchanged.
|
|
1251
|
+
* @private
|
|
1252
|
+
* @returns {Error}
|
|
1253
|
+
*/
|
|
1254
|
+
_typeDiscoveryFailure(err, { key, serviceName }) {
|
|
1255
|
+
if (this.redis.isReady !== false) {
|
|
1256
|
+
return err;
|
|
1257
|
+
}
|
|
1258
|
+
const failure = new Error(
|
|
1259
|
+
`[RegistryClient] getService - Registry projection is unreachable for the lookup of "${serviceName}" `
|
|
1260
|
+
+ `(key ${key}) - ${err.message}. Expected: a connected client of the Redis the registry projects into. `
|
|
1261
|
+
+ 'Fix: make that Redis reachable to this service; the step is retried as TRANSIENT.',
|
|
1262
|
+
{ cause: err }
|
|
1263
|
+
);
|
|
1264
|
+
failure.code = DISCOVERY_FAILURE_CODES.unavailable;
|
|
1265
|
+
failure.type = 'TRANSIENT';
|
|
1266
|
+
failure.serviceName = serviceName;
|
|
1267
|
+
return failure;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
997
1270
|
/**
|
|
998
1271
|
* Releases resources: stops heartbeat and closes connection.
|
|
999
1272
|
* @returns {Promise<void>}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
const EventEmitter = require('events');
|
|
12
12
|
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
13
|
+
const { queueConfig } = require('@onlineapps/mq-client-core');
|
|
13
14
|
|
|
14
15
|
// The storage connector arrives by INJECTION. There used to be an implicit
|
|
15
16
|
// default here — `require('@onlineapps/connector-storage')` inside a try/catch
|
|
@@ -88,8 +89,19 @@ class RegistryEventConsumer extends EventEmitter {
|
|
|
88
89
|
// Reusing it would cause 406 PRECONDITION_FAILED when different queue args (TTL/max-length) are applied.
|
|
89
90
|
this.eventsQueueName = `${serviceName}.registry.events`;
|
|
90
91
|
|
|
91
|
-
// Exchange name for registry events
|
|
92
|
-
|
|
92
|
+
// Exchange name for registry events - from the library that declares the whole
|
|
93
|
+
// registry section of the topology, never spelled here. Until d.809 this was the
|
|
94
|
+
// one wire name of this package written twice: a rename in `queueConfig` would
|
|
95
|
+
// have left this consumer subscribed to an exchange nobody publishes to, with
|
|
96
|
+
// nothing red anywhere (`change-discipline.md` § One rail per concern).
|
|
97
|
+
//
|
|
98
|
+
// Only the NAME. The pinned 4.0.1 declares `registry.changes` as
|
|
99
|
+
// `{ type: 'exchange', durable: true }` with no `exchangeType`; the tree has
|
|
100
|
+
// `exchangeType: 'fanout'` (d.712) and is unreleased, so reading the type from the
|
|
101
|
+
// library today would read `undefined` and defaulting it here would be a fallback
|
|
102
|
+
// (§3). The `'fanout'` in subscribeToChanges() therefore stays a literal until
|
|
103
|
+
// mq-client-core is released.
|
|
104
|
+
this.exchangeName = queueConfig.queueName('registry', 'changes');
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
/**
|