@onlineapps/conn-orch-registry 6.0.3 → 7.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 +831 -0
- package/CHANGELOG.md +538 -0
- package/README.md +35 -7
- package/docs/REGISTRY_CLIENT_GUIDE.md +2 -2
- package/package.json +5 -3
- package/src/registryClient.js +183 -27
- package/src/registryEventConsumer.js +14 -2
|
@@ -31,7 +31,7 @@ Microservice Registry Backend
|
|
|
31
31
|
> The legacy `apiDescriptionRequest` / `sendApiDescription` round-trip has
|
|
32
32
|
> been removed — the full service specification (including the `operations`
|
|
33
33
|
> map) travels as part of the `register` message. See
|
|
34
|
-
> [
|
|
34
|
+
> [registration-wire.md §2.1](../../../../docs/biz/30-operations/registration-wire.md).
|
|
35
35
|
|
|
36
36
|
## Usage
|
|
37
37
|
|
|
@@ -131,7 +131,7 @@ against the Operations Registry contract and either confirms or rejects.
|
|
|
131
131
|
|
|
132
132
|
**Parameters:**
|
|
133
133
|
- `serviceInfo.endpoints` - Array of API endpoints
|
|
134
|
-
- `serviceInfo.operations` - Operations map (see [
|
|
134
|
+
- `serviceInfo.operations` - Operations map (see [registration-wire.md §3](../../../../docs/biz/30-operations/registration-wire.md))
|
|
135
135
|
- `serviceInfo.metadata` - Service metadata object
|
|
136
136
|
- `serviceInfo.health` - Health check endpoint
|
|
137
137
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/conn-orch-registry",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "conn-orch-registry provides the core communication mechanism for microservices in this environment. It enables them to interact with a services_registry to receive and fulfill tasks by submitting heartbeats or their API descriptions.",
|
|
6
6
|
"keywords": [
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
"docs/",
|
|
19
19
|
"examples/",
|
|
20
20
|
"README.md",
|
|
21
|
-
"LICENSE"
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"CHANGELOG.md",
|
|
23
|
+
"API.md"
|
|
22
24
|
],
|
|
23
25
|
"oa": {
|
|
24
26
|
"category": "orchestration"
|
|
@@ -37,7 +39,7 @@
|
|
|
37
39
|
},
|
|
38
40
|
"dependencies": {
|
|
39
41
|
"@onlineapps/logger-contract": "2.0.0",
|
|
40
|
-
"@onlineapps/mq-client-core": "
|
|
42
|
+
"@onlineapps/mq-client-core": "5.0.0",
|
|
41
43
|
"uuid": "^9.0.1"
|
|
42
44
|
},
|
|
43
45
|
"devDependencies": {
|
package/src/registryClient.js
CHANGED
|
@@ -68,6 +68,38 @@ const RETIRED_SERVICE_INFO_KEYS = Object.freeze({
|
|
|
68
68
|
+ 'destructure it, so deployability is read from that CI job and never from a registration message'
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The keys `register()` ACCEPTS — the whole input contract of the call (d.702).
|
|
73
|
+
*
|
|
74
|
+
* It is the caller's half of `api/docs/biz/30-operations/registration-wire.md`
|
|
75
|
+
* §2.1: the five register-message fields a CALLER supplies, plus `timeout`, which
|
|
76
|
+
* is this call's own response budget and reaches no message. The rest of §2.1
|
|
77
|
+
* (`id`, `type`, `serviceName`, `version`, `specificationEndpoint`,
|
|
78
|
+
* `responseQueue`, `timestamp`, `validationProof`, `validationData`) is client
|
|
79
|
+
* state bound in the constructor, never caller input — a caller cannot set it and
|
|
80
|
+
* this table must not invite one to try.
|
|
81
|
+
*
|
|
82
|
+
* Any other key is refused BY NAME, for the same reason the table above exists:
|
|
83
|
+
* until d.702 `register()` refused six retired keys and dropped everything else in
|
|
84
|
+
* silence, so `helth`, `current_step` or `name` bought a confirmed registration
|
|
85
|
+
* whose message never carried the value. A key silently dropped leaves the caller
|
|
86
|
+
* believing something travelled, and a typo in an optional key is exactly the
|
|
87
|
+
* shape nobody notices (`architecture-principles.md` §3 no fallbacks, §4
|
|
88
|
+
* fail-fast).
|
|
89
|
+
*
|
|
90
|
+
* `RETIRED_SERVICE_INFO_KEYS` is the subset that can still say WHY the key went;
|
|
91
|
+
* ONE membership check reads both tables, so there is no second list to drift
|
|
92
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
93
|
+
*/
|
|
94
|
+
const ACCEPTED_SERVICE_INFO_KEYS = Object.freeze({
|
|
95
|
+
endpoints: 'the `endpoints` array of the register message (§2.1); `[]` for every v3 service',
|
|
96
|
+
operations: 'the complete `operations` map the registry validates (§3)',
|
|
97
|
+
metadata: 'the `{ version, description }` object stored beside the spec (§2.1)',
|
|
98
|
+
health: 'the legacy health path of the register message; liveness travels by heartbeat (§2.2)',
|
|
99
|
+
workspaceScoped: 'the boolean emitted only when the service declares it (§2.1)',
|
|
100
|
+
timeout: 'how long THIS call waits for the registry verdict; it reaches no message'
|
|
101
|
+
});
|
|
102
|
+
|
|
71
103
|
|
|
72
104
|
class ServiceRegistryClient extends EventEmitter {
|
|
73
105
|
/**
|
|
@@ -95,7 +127,7 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
95
127
|
* docs/governance/confirmations/connector-logger-contract.md 001 + 002.
|
|
96
128
|
*/
|
|
97
129
|
constructor({ amqpUrl, serviceName, version, specificationEndpoint,
|
|
98
|
-
heartbeatInterval, apiQueue = 'api_services_queuer', registryQueue = 'registry.register',
|
|
130
|
+
heartbeatInterval, instanceId, apiQueue = 'api_services_queuer', registryQueue = 'registry.register',
|
|
99
131
|
redis = null, storageConfig = {}, validationProof = null, logger,
|
|
100
132
|
registryKeyPrefix = DEFAULTS.registryKeyPrefix,
|
|
101
133
|
registrationTimeoutMs = DEFAULTS.registrationTimeoutMs }) {
|
|
@@ -118,6 +150,32 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
118
150
|
+ `(module default ${DEFAULTS.registrationTimeoutMs}).`
|
|
119
151
|
);
|
|
120
152
|
}
|
|
153
|
+
// WHICH RUNNING COPY. The service NAME says which service; until d.817 nothing said
|
|
154
|
+
// which copy, so a short-lived foreign instance deregistered the live one and three
|
|
155
|
+
// services then beat into `unknown` for nine hours (INFRA batch 398, from production
|
|
156
|
+
// logs; owner decision `registry-instance-identity` 001).
|
|
157
|
+
//
|
|
158
|
+
// INJECTED, never made here (§1 DI, §3 no fallback): the wrapper makes ONE value per
|
|
159
|
+
// process at boot and hands the same one to `monitoring-core` as `serviceInstanceId`,
|
|
160
|
+
// so a second birthplace here would give one running copy two identities. Rule 11 —
|
|
161
|
+
// no default, no `|| uuidv4()`, no transition period.
|
|
162
|
+
if (typeof instanceId !== 'string' || instanceId === '') {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`[RegistryClient] ${serviceName}: Missing instanceId - got ${JSON.stringify(instanceId)}. `
|
|
165
|
+
+ 'Expected: a non-empty string naming THIS running copy of the service, the same value for the '
|
|
166
|
+
+ 'whole process; the service name says which service, this says which copy. '
|
|
167
|
+
+ 'Fix: pass the instance id the wrapper made once at boot (SERVICE_INSTANCE_ID when the '
|
|
168
|
+
+ 'deployment gives one, otherwise a UUID v4) - this client never makes one, because the same '
|
|
169
|
+
+ 'value also reaches monitoring-core as serviceInstanceId.'
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
this.instanceId = instanceId;
|
|
173
|
+
// Where the registry answers THIS copy's registration (conf
|
|
174
|
+
// `registry-instance-identity` 001 (c), d.817c) — carried as `responseQueue`.
|
|
175
|
+
// The shared `<service>.registry` (`serviceRegistryQueue`, set in `init()`)
|
|
176
|
+
// carries the registry's own prompts to every copy.
|
|
177
|
+
this.instanceResponseQueue = `${serviceName}.registry.${instanceId}`;
|
|
178
|
+
|
|
121
179
|
this.registrationTimeoutMs = registrationTimeoutMs;
|
|
122
180
|
this.serviceName = serviceName;
|
|
123
181
|
this.version = version;
|
|
@@ -195,8 +253,42 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
195
253
|
// agreeing today, and 406 PRECONDITION-FAILED the day the two drift apart
|
|
196
254
|
// (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
197
255
|
const consumerStartTime = Date.now();
|
|
256
|
+
// TWO queues, two jobs (conf `registry-instance-identity` 001 (c), d.817c):
|
|
257
|
+
// - `<service>.registry`, shared by every copy — the registry's own prompts
|
|
258
|
+
// (`revalidate`; `api_services_registry` `revalidationRequester.js` sends to
|
|
259
|
+
// `<serviceName>.registry`);
|
|
260
|
+
// - `<service>.registry.<instance_id>` — the answers to THIS copy's registration,
|
|
261
|
+
// named in `register`'s `responseQueue`, so a live copy can no longer eat the
|
|
262
|
+
// confirmation meant for another.
|
|
263
|
+
// Both have a dead-letter route in `queueConfig` (the per-instance template landed
|
|
264
|
+
// first, d.817b — the order conf 002 made binding), so `consume()` accepts both.
|
|
198
265
|
this.serviceRegistryQueue = `${this.serviceName}.registry`;
|
|
199
266
|
|
|
267
|
+
for (const queue of [this.serviceRegistryQueue, this.instanceResponseQueue]) {
|
|
268
|
+
await this._startRegistryConsumer(queue);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// One line for the whole boot of this client: which queues it talks on, and how
|
|
272
|
+
// long the consumers took to attach. Replaces the four FÁZE 0.5/0.6 markers.
|
|
273
|
+
this.logger.info('[RegistryClient] Initialized', {
|
|
274
|
+
serviceName: this.serviceName,
|
|
275
|
+
version: this.version,
|
|
276
|
+
registryQueue: this.registryQueue,
|
|
277
|
+
promptQueue: this.serviceRegistryQueue,
|
|
278
|
+
responseQueue: this.instanceResponseQueue,
|
|
279
|
+
consumerStartupMs: Date.now() - consumerStartTime
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Attach this client's handler to one registry queue, within a bounded wait.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} queue - `<service>.registry` or `<service>.registry.<instance_id>`
|
|
287
|
+
* @returns {Promise<void>}
|
|
288
|
+
* @throws {Error} naming the queue, carrying the source's classification
|
|
289
|
+
* @private
|
|
290
|
+
*/
|
|
291
|
+
async _startRegistryConsumer(queue) {
|
|
200
292
|
const CONSUME_TIMEOUT = 5000; // 5 seconds
|
|
201
293
|
// `requeueOnError: false` is the budget this consumer has always had, said in
|
|
202
294
|
// the core's word: ONE attempt. The difference is where a failure goes — the
|
|
@@ -206,7 +298,7 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
206
298
|
// nothing itself (@onlineapps/mq-client-core README § consume() — the delivery
|
|
207
299
|
// contract).
|
|
208
300
|
const consumeRegistryPromise = this.queueManager.consume(
|
|
209
|
-
|
|
301
|
+
queue,
|
|
210
302
|
msg => this._handleRegistryMessage(msg),
|
|
211
303
|
{ requeueOnError: false }
|
|
212
304
|
);
|
|
@@ -214,7 +306,7 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
214
306
|
const consumeRegistryTimeoutPromise = new Promise((_, reject) => {
|
|
215
307
|
consumeRegistryTimeout = setTimeout(() => {
|
|
216
308
|
reject(new Error(
|
|
217
|
-
`[RegistryClient] consume() timeout for ${
|
|
309
|
+
`[RegistryClient] consume() timeout for ${queue} after ${CONSUME_TIMEOUT}ms - `
|
|
218
310
|
+ 'the broker never confirmed the consumer. Fix: check RabbitMQ responsiveness and that the queue exists.'
|
|
219
311
|
));
|
|
220
312
|
}, CONSUME_TIMEOUT);
|
|
@@ -234,7 +326,7 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
234
326
|
// { cause })` carries no own property, so the marker used to die here.
|
|
235
327
|
// Nothing is classified HERE — what the source left unmarked stays unmarked.
|
|
236
328
|
const refusal = new Error(
|
|
237
|
-
`[RegistryClient] ${this.serviceName}: Failed to start consumer on ${
|
|
329
|
+
`[RegistryClient] ${this.serviceName}: Failed to start consumer on ${queue} - `
|
|
238
330
|
+ `${consumeErr.message}. Fix: check RabbitMQ health; without this consumer no registry reply is ever read.`,
|
|
239
331
|
{ cause: consumeErr }
|
|
240
332
|
);
|
|
@@ -244,16 +336,6 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
244
336
|
} finally {
|
|
245
337
|
if (consumeRegistryTimeout) clearTimeout(consumeRegistryTimeout);
|
|
246
338
|
}
|
|
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
339
|
}
|
|
258
340
|
|
|
259
341
|
/**
|
|
@@ -468,11 +550,17 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
468
550
|
* Registers the service with the registry.
|
|
469
551
|
* Registry will validate if the service is properly tested and valid.
|
|
470
552
|
* Upon successful validation, the service will be activated and can start heartbeats.
|
|
471
|
-
* @param {Object} serviceInfo - Service registration information
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
* @param {
|
|
553
|
+
* @param {Object} serviceInfo - Service registration information. It accepts EXACTLY the keys
|
|
554
|
+
* below (`ACCEPTED_SERVICE_INFO_KEYS`) — any other key is refused by name, and a retired one
|
|
555
|
+
* (`RETIRED_SERVICE_INFO_KEYS`) with the reason it went. Nothing is dropped in silence: a key
|
|
556
|
+
* this call ignored would leave the caller believing it travelled.
|
|
557
|
+
* @param {Array} [serviceInfo.endpoints] - API endpoints provided by the service; `[]` for v3 services
|
|
558
|
+
* @param {Object} [serviceInfo.operations] - The complete `operations` map the registry validates
|
|
559
|
+
* @param {Object} [serviceInfo.metadata] - `{ version, description }` stored beside the spec
|
|
560
|
+
* @param {string} [serviceInfo.health] - Legacy health path; liveness travels by heartbeat
|
|
561
|
+
* @param {boolean} [serviceInfo.workspaceScoped] - Emitted only when the service declares it
|
|
562
|
+
* @param {number} [serviceInfo.timeout] - How long THIS call waits for the registry verdict
|
|
563
|
+
* (default: the constructor's registrationTimeoutMs). It reaches no message.
|
|
476
564
|
* @returns {Promise<{success: boolean, message: string, serviceName: string, version: string,
|
|
477
565
|
* registrationId: (string|undefined), validated: boolean, certificate: (Object|null),
|
|
478
566
|
* errorCode: (string|null|undefined)}>} The registry verdict. `errorCode` is the
|
|
@@ -492,9 +580,12 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
492
580
|
);
|
|
493
581
|
}
|
|
494
582
|
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
583
|
+
// ONE membership check over the two tables above, before anything is published.
|
|
584
|
+
// A key the contract does not name is refused BY NAME: dropping it in silence
|
|
585
|
+
// would leave the caller believing it travelled. A retired key keeps its own
|
|
586
|
+
// reason - it is the subset that can still say what replaced it. Neither
|
|
587
|
+
// refusal echoes the value: two of the retired keys carried a secret, and an
|
|
588
|
+
// unknown key can carry anything.
|
|
498
589
|
for (const key of Object.keys(serviceInfo)) {
|
|
499
590
|
if (Object.prototype.hasOwnProperty.call(RETIRED_SERVICE_INFO_KEYS, key)) {
|
|
500
591
|
throw new Error(
|
|
@@ -503,6 +594,15 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
503
594
|
+ `Fix: delete "${key}" from the object passed to register().`
|
|
504
595
|
);
|
|
505
596
|
}
|
|
597
|
+
if (!Object.prototype.hasOwnProperty.call(ACCEPTED_SERVICE_INFO_KEYS, key)) {
|
|
598
|
+
throw new Error(
|
|
599
|
+
`[RegistryClient] ${this.serviceName}: serviceInfo key "${key}" is not part of the registration `
|
|
600
|
+
+ `contract - Expected one of: ${Object.keys(ACCEPTED_SERVICE_INFO_KEYS).join(', ')} `
|
|
601
|
+
+ '(api/docs/biz/30-operations/registration-wire.md §2.1). '
|
|
602
|
+
+ `Fix: delete "${key}" from the object passed to register(), or send the value under the key `
|
|
603
|
+
+ 'the contract names for it.'
|
|
604
|
+
);
|
|
605
|
+
}
|
|
506
606
|
}
|
|
507
607
|
|
|
508
608
|
// FAIL-FAST: a client that was never initialized, or that has been closed, is not
|
|
@@ -519,9 +619,32 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
519
619
|
}
|
|
520
620
|
|
|
521
621
|
const msgId = uuidv4();
|
|
622
|
+
|
|
623
|
+
// FAIL-FAST: one request id, one pending registration. `pendingRegistrations` and
|
|
624
|
+
// `pendingTimeouts` are keyed by this id, and until d.760b a second call under an id
|
|
625
|
+
// that was still pending simply set() over both - which dropped the earlier timer's
|
|
626
|
+
// handle. Nothing could reach it afterwards: the registry's answer clears by id
|
|
627
|
+
// (_handleRegistryMessage), and close() walks the table, so both found only the newer
|
|
628
|
+
// handle and the older timer ran to its full 30 s. Cancelling the earlier registration
|
|
629
|
+
// here instead would be a fallback (§3): its caller is still awaiting an answer that
|
|
630
|
+
// belongs to it, and this class cannot know which of the two calls is the mistake.
|
|
631
|
+
if (this.pendingRegistrations && this.pendingRegistrations.has(msgId)) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
`[RegistryClient] ${this.serviceName}: Duplicate request id "${msgId}" - a registration with this id `
|
|
634
|
+
+ 'is still pending. Expected: one register() in flight per request id; it is settled by the registry '
|
|
635
|
+
+ `answer, by the ${this.registrationTimeoutMs}ms timeout or by close(). `
|
|
636
|
+
+ 'Fix: await the register() already in flight before starting another one, and - if the ids repeat '
|
|
637
|
+
+ 'because uuid.v4 is stubbed - give each registration its own id.'
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
522
641
|
// @see api/docs/biz/30-operations/registration-wire.md §2 MQ wire format
|
|
523
642
|
const msg = {
|
|
524
643
|
id: msgId,
|
|
644
|
+
// WHICH REQUEST (`id`, per call) and WHICH COPY (`instance_id`, per process) are
|
|
645
|
+
// two identities with two lifetimes — d.817, owner `registry-instance-identity` 001.
|
|
646
|
+
// snake_case is the one spelling the wire has (`workflow-message-aliases` 001).
|
|
647
|
+
instance_id: this.instanceId,
|
|
525
648
|
type: 'register',
|
|
526
649
|
serviceName: this.serviceName,
|
|
527
650
|
version: this.version,
|
|
@@ -530,7 +653,7 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
530
653
|
operations: serviceInfo.operations || {},
|
|
531
654
|
metadata: serviceInfo.metadata || {},
|
|
532
655
|
health: serviceInfo.health || '/health',
|
|
533
|
-
responseQueue: this.
|
|
656
|
+
responseQueue: this.instanceResponseQueue, // THIS copy's answer queue (conf registry-instance-identity 001 (c))
|
|
534
657
|
timestamp: new Date().toISOString()
|
|
535
658
|
};
|
|
536
659
|
|
|
@@ -572,7 +695,7 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
572
695
|
reject(new Error(
|
|
573
696
|
`[RegistryClient] ${this.serviceName}: Registration timed out - no response from the registry `
|
|
574
697
|
+ `within ${timeout}ms. Expected: api_services_registry consumes '${this.registryQueue}' and answers `
|
|
575
|
-
+ `on '${this.
|
|
698
|
+
+ `on '${this.instanceResponseQueue}'. Fix: start api_services_registry (docker compose up -d `
|
|
576
699
|
+ 'api_services_registry) or raise registrationTimeoutMs.'
|
|
577
700
|
));
|
|
578
701
|
}
|
|
@@ -627,7 +750,7 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
627
750
|
serviceName: this.serviceName,
|
|
628
751
|
version: this.version,
|
|
629
752
|
registryQueue: this.registryQueue,
|
|
630
|
-
responseQueue: this.
|
|
753
|
+
responseQueue: this.instanceResponseQueue,
|
|
631
754
|
requestId: msgId,
|
|
632
755
|
hasValidationProof: !!this.validationProof,
|
|
633
756
|
proofHash: this.validationProof
|
|
@@ -701,6 +824,9 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
701
824
|
id: uuidv4(),
|
|
702
825
|
type: 'deregister',
|
|
703
826
|
serviceName: this.serviceName,
|
|
827
|
+
// The reason this key exists at all: the registry deletes only on a match, so a
|
|
828
|
+
// short-lived foreign copy can no longer deregister the live one (d.817).
|
|
829
|
+
instance_id: this.instanceId,
|
|
704
830
|
version: this.version,
|
|
705
831
|
timestamp: new Date().toISOString()
|
|
706
832
|
};
|
|
@@ -752,6 +878,9 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
752
878
|
id: uuidv4(),
|
|
753
879
|
type: 'heartbeat',
|
|
754
880
|
serviceName: this.serviceName,
|
|
881
|
+
// The beat says which copy is alive, so the registry can keep its availability
|
|
882
|
+
// episodes per instance instead of per name (d.817, INFRA half).
|
|
883
|
+
instance_id: this.instanceId,
|
|
755
884
|
version: this.version,
|
|
756
885
|
specificationEndpoint: this.specificationEndpoint,
|
|
757
886
|
timestamp: new Date().toISOString()
|
|
@@ -801,6 +930,24 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
801
930
|
* budget is spent. The loop is already stopped when it is called.
|
|
802
931
|
*/
|
|
803
932
|
startHeartbeat(options) {
|
|
933
|
+
// FAIL-FAST: one client, one loop. Checked before the options, because the state
|
|
934
|
+
// of the receiver is the problem here whatever the arguments say. Until d.760c the
|
|
935
|
+
// line at the end of this method wrote the new handle over `heartbeatTimer` and the
|
|
936
|
+
// earlier loop lost the only reference to itself: it re-arms while that field is
|
|
937
|
+
// not null, and the field now belonged to the newer loop, so `stopHeartbeat()`
|
|
938
|
+
// cancelled one loop and the other went on beating for a service that had stopped.
|
|
939
|
+
// Replacing the running loop silently would be a fallback (§3) - the caller that
|
|
940
|
+
// started it is not asking for it to be discarded, and this class cannot know which
|
|
941
|
+
// of the two callers is the mistake.
|
|
942
|
+
if (this.heartbeatTimer) {
|
|
943
|
+
throw new Error(
|
|
944
|
+
`[RegistryClient] ${this.serviceName}: A heartbeat loop is already running - starting a second one would `
|
|
945
|
+
+ 'overwrite the timer handle of the first, which would then beat on with nothing able to stop it. '
|
|
946
|
+
+ 'Expected: one heartbeat loop per client; stop it with stopHeartbeat() first. '
|
|
947
|
+
+ 'Fix: call client.stopHeartbeat() before starting a new loop, or keep the loop that is already running.'
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
|
|
804
951
|
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
|
|
805
952
|
throw new Error(
|
|
806
953
|
`[RegistryClient] ${this.serviceName}: Missing heartbeat options - startHeartbeat({ maxFailures, onFatal }) `
|
|
@@ -886,12 +1033,21 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
886
1033
|
|
|
887
1034
|
/**
|
|
888
1035
|
* Stops periodic heartbeat messages.
|
|
1036
|
+
*
|
|
1037
|
+
* One scheduler, one cancel: `startHeartbeat()` arms the loop with a recursive
|
|
1038
|
+
* `setTimeout` and nothing in this class has created an interval since that
|
|
1039
|
+
* rewrite (d.503), so `clearTimeout` is the whole job. A `clearInterval` on the
|
|
1040
|
+
* same handle stood beside it until d.752, under a comment offering to cancel
|
|
1041
|
+
* "whether it's from setTimeout or setInterval" — a choice the class does not
|
|
1042
|
+
* have, and one that told every reader the loop might be an interval.
|
|
1043
|
+
*
|
|
1044
|
+
* Setting `heartbeatTimer` to null is the second half and not a formality: the
|
|
1045
|
+
* loop reads it before arming the next beat, so a beat already in flight sees
|
|
1046
|
+
* the stop and schedules nothing.
|
|
889
1047
|
*/
|
|
890
1048
|
stopHeartbeat() {
|
|
891
1049
|
if (this.heartbeatTimer) {
|
|
892
|
-
// Clear timeout regardless of whether it's from setTimeout or setInterval
|
|
893
1050
|
clearTimeout(this.heartbeatTimer);
|
|
894
|
-
clearInterval(this.heartbeatTimer);
|
|
895
1051
|
this.heartbeatTimer = null;
|
|
896
1052
|
}
|
|
897
1053
|
}
|
|
@@ -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
|
/**
|