@onlineapps/conn-orch-registry 4.0.3 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/docs/REGISTRY_CLIENT_GUIDE.md +30 -11
- package/examples/basicUsage.js +7 -1
- package/examples/event-consumer-example.js +7 -1
- package/package.json +2 -2
- package/src/queueManager.js +34 -2
- package/src/registryClient.js +156 -28
package/README.md
CHANGED
|
@@ -76,7 +76,15 @@ await client.register({
|
|
|
76
76
|
health: '/health'
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
// The give-up budget is required: how many missed beats mean the service can no
|
|
80
|
+
// longer prove it is alive, and what happens then. Neither is defaulted here.
|
|
81
|
+
client.startHeartbeat({
|
|
82
|
+
maxFailures: 5,
|
|
83
|
+
onFatal: (err) => {
|
|
84
|
+
logger.error(err.message);
|
|
85
|
+
process.exit(1); // the restart policy boots it again and it re-registers
|
|
86
|
+
}
|
|
87
|
+
});
|
|
80
88
|
|
|
81
89
|
// Graceful shutdown
|
|
82
90
|
process.on('SIGINT', async () => {
|
|
@@ -17,14 +17,14 @@ Microservice Registry Backend
|
|
|
17
17
|
| (setup connection) | |
|
|
18
18
|
| | |
|
|
19
19
|
|-- register() ----------->| |
|
|
20
|
-
| (
|
|
21
|
-
|
|
|
20
|
+
| (endpoints + | |
|
|
21
|
+
| operations + meta) |-- validate() --------->|
|
|
22
22
|
| | (check if tested) |
|
|
23
23
|
| |<-- validation result --|
|
|
24
24
|
| | |
|
|
25
25
|
|<-- register.confirmed ---| |
|
|
26
26
|
| | |
|
|
27
|
-
|-- startHeartbeat()
|
|
27
|
+
|-- startHeartbeat({…}) -->| |
|
|
28
28
|
| (if validated OK) | |
|
|
29
29
|
```
|
|
30
30
|
|
|
@@ -70,13 +70,15 @@ async function initializeService() {
|
|
|
70
70
|
author: 'Development Team',
|
|
71
71
|
documentation: 'https://docs.example.com/users'
|
|
72
72
|
},
|
|
73
|
-
health: '/health'
|
|
74
|
-
spec: openApiSpec // Optional: OpenAPI specification object
|
|
73
|
+
health: '/health'
|
|
75
74
|
});
|
|
76
75
|
|
|
77
|
-
// Step 3: If registration successful, start heartbeat
|
|
76
|
+
// Step 3: If registration successful, start heartbeat with its give-up budget
|
|
78
77
|
if (registrationResult.success) {
|
|
79
|
-
registryClient.startHeartbeat(
|
|
78
|
+
registryClient.startHeartbeat({
|
|
79
|
+
maxFailures: 5,
|
|
80
|
+
onFatal: (err) => { console.error(err.message); process.exit(1); }
|
|
81
|
+
});
|
|
80
82
|
console.log('Service registered and activated');
|
|
81
83
|
} else {
|
|
82
84
|
console.error('Registration failed:', registrationResult.message);
|
|
@@ -135,11 +137,25 @@ against the Operations Registry contract and either confirms or rejects.
|
|
|
135
137
|
|
|
136
138
|
**Returns:** Registration result object with `success` status
|
|
137
139
|
|
|
138
|
-
#### `startHeartbeat()`
|
|
139
|
-
Starts sending periodic heartbeat messages. Should only be called
|
|
140
|
+
#### `startHeartbeat({ maxFailures, onFatal })`
|
|
141
|
+
Starts sending periodic heartbeat messages at the cadence the constructor was given. Should only be called
|
|
142
|
+
after successful registration.
|
|
143
|
+
|
|
144
|
+
Both options are REQUIRED and neither is defaulted:
|
|
145
|
+
|
|
146
|
+
- `maxFailures` — a positive whole number of **consecutive** failed beats. One success resets the count.
|
|
147
|
+
- `onFatal(err)` — called once, with the error naming the count and the last cause, at the moment the budget
|
|
148
|
+
is spent. The loop is already stopped when it runs.
|
|
149
|
+
|
|
150
|
+
A loop with no budget beats forever into a broker that refuses it, so the service keeps running while every
|
|
151
|
+
reader of the registry sees it as stale. What a dead heartbeat MEANS (stop the process, page someone) is the
|
|
152
|
+
caller's decision — this client counts and reports, it never decides.
|
|
153
|
+
|
|
154
|
+
The cadence is not an argument here: it has one owner, the constructor option `heartbeatInterval`
|
|
155
|
+
(owner confirmation `api/docs/governance/confirmations/biz-health-freshness.md` 001 point 3).
|
|
140
156
|
|
|
141
157
|
#### `stopHeartbeat()`
|
|
142
|
-
Stops the heartbeat timer.
|
|
158
|
+
Stops the heartbeat timer. Idempotent: calling it twice, or before any `startHeartbeat()`, does nothing.
|
|
143
159
|
|
|
144
160
|
#### `async subscribeToChanges()`
|
|
145
161
|
Subscribes to registry change events (optional).
|
|
@@ -184,7 +200,10 @@ registryClient.startHeartbeat(); // Started immediately
|
|
|
184
200
|
await registryClient.init();
|
|
185
201
|
const result = await registryClient.register(serviceInfo); // NEW: Registration required
|
|
186
202
|
if (result.success) {
|
|
187
|
-
registryClient.startHeartbeat(
|
|
203
|
+
registryClient.startHeartbeat({ // Only after validation
|
|
204
|
+
maxFailures: 5,
|
|
205
|
+
onFatal: (err) => { console.error(err.message); process.exit(1); }
|
|
206
|
+
});
|
|
188
207
|
}
|
|
189
208
|
```
|
|
190
209
|
|
package/examples/basicUsage.js
CHANGED
|
@@ -71,7 +71,13 @@ registryClient.on(EVENTS.ERROR, (err) => {
|
|
|
71
71
|
await registryClient.register(await loadServiceSpec());
|
|
72
72
|
console.log('Register message published.');
|
|
73
73
|
|
|
74
|
-
registryClient.startHeartbeat(
|
|
74
|
+
registryClient.startHeartbeat({
|
|
75
|
+
maxFailures: 5,
|
|
76
|
+
onFatal: (err) => {
|
|
77
|
+
console.error(err.message);
|
|
78
|
+
process.exit(1); // the restart policy boots it again and it re-registers
|
|
79
|
+
}
|
|
80
|
+
});
|
|
75
81
|
console.log('Heartbeat loop started.');
|
|
76
82
|
|
|
77
83
|
const shutdown = async () => {
|
|
@@ -63,7 +63,13 @@ async function main() {
|
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
// Start heartbeat
|
|
66
|
-
client.startHeartbeat(
|
|
66
|
+
client.startHeartbeat({
|
|
67
|
+
maxFailures: 5,
|
|
68
|
+
onFatal: (err) => {
|
|
69
|
+
console.error(err.message);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
67
73
|
|
|
68
74
|
// OPT-IN: Subscribe to registry changes
|
|
69
75
|
console.log('Subscribing to registry changes...');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/conn-orch-registry",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.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": [
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@onlineapps/logger-contract": "2.0.0",
|
|
40
|
-
"@onlineapps/mq-client-core": "
|
|
40
|
+
"@onlineapps/mq-client-core": "4.0.0",
|
|
41
41
|
"uuid": "^9.0.1"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
package/src/queueManager.js
CHANGED
|
@@ -90,11 +90,25 @@ class QueueManager {
|
|
|
90
90
|
// The core's refusal says "the configured host" without naming it — which
|
|
91
91
|
// host is exactly what a reader of a boot failure needs, so it is added
|
|
92
92
|
// here, redacted, with the core's error as the cause.
|
|
93
|
-
|
|
93
|
+
//
|
|
94
|
+
// The CLASSIFICATION the source put on the failure travels with the rewrap.
|
|
95
|
+
// `@onlineapps/mq-client-core` owns the question "did the broker answer and
|
|
96
|
+
// refuse?" (`REFUSAL_REPLY_CODES`, `_isBrokerRefusal()`) and states the
|
|
97
|
+
// answer as `code: 'MQ_CONNECTION_FATAL'` / `reason: 'broker-refused'`;
|
|
98
|
+
// `new Error(msg, { cause })` carries neither, so the marker died here and
|
|
99
|
+
// `@onlineapps/service-wrapper` — which ends its boot on `error.code` — was
|
|
100
|
+
// left with an unclassified failure and called a refusal transient (d.532,
|
|
101
|
+
// measured on biz-meta). Nothing is classified HERE: what the source left
|
|
102
|
+
// unmarked stays unmarked, exactly as `@onlineapps/conn-infra-mq` does it
|
|
103
|
+
// in `ConnectorMQClient` (d.498b).
|
|
104
|
+
const refusal = new Error(
|
|
94
105
|
`[QueueManager] Failed to connect to RabbitMQ at ${this.safeAmqpUrl} - ${error.message}. `
|
|
95
106
|
+ 'Fix: check broker health and the credentials in the AMQP URI.',
|
|
96
107
|
{ cause: error }
|
|
97
108
|
);
|
|
109
|
+
refusal.code = error.code;
|
|
110
|
+
refusal.reason = error.reason;
|
|
111
|
+
throw refusal;
|
|
98
112
|
}
|
|
99
113
|
}
|
|
100
114
|
|
|
@@ -137,12 +151,30 @@ class QueueManager {
|
|
|
137
151
|
await this.client.assertQueue(q);
|
|
138
152
|
} catch (assertErr) {
|
|
139
153
|
const errorMsg = assertErr.message || String(assertErr);
|
|
140
|
-
|
|
154
|
+
// The CLASSIFICATION the source put on the failure travels with the
|
|
155
|
+
// rewrap, exactly as it does in `init()` a few methods up (d.532). This
|
|
156
|
+
// was the THIRD path and the one d.532 did not reach: FÁZE 0.8 declares
|
|
157
|
+
// the business queues, and `new Error(msg, { cause })` carries no own
|
|
158
|
+
// property, so whatever the core decided about this failure died here and
|
|
159
|
+
// `@onlineapps/service-wrapper` — which reads `error.code` — was handed an
|
|
160
|
+
// unclassified one (d.579).
|
|
161
|
+
//
|
|
162
|
+
// Nothing is classified HERE: what the source left unmarked stays
|
|
163
|
+
// unmarked. This package neither reads AMQP reply codes nor matches on
|
|
164
|
+
// message text; the question "did the broker answer and refuse?" belongs
|
|
165
|
+
// to `@onlineapps/mq-client-core` (`REFUSAL_REPLY_CODES`,
|
|
166
|
+
// `_isBrokerRefusal()`), and a second copy of that list here would be free
|
|
167
|
+
// to drift from the one the broker is actually talked to through
|
|
168
|
+
// (`change-discipline.md` § One rail per concern).
|
|
169
|
+
const refusal = new Error(
|
|
141
170
|
`[QueueManager] Failed to assert queue ${q} - ${errorMsg} (code ${assertErr.code || 'N/A'}). `
|
|
142
171
|
+ 'Fix: a 406 PRECONDITION-FAILED means the queue exists with different arguments; '
|
|
143
172
|
+ 'align them or let its owner declare it.',
|
|
144
173
|
{ cause: assertErr }
|
|
145
174
|
);
|
|
175
|
+
refusal.code = assertErr.code;
|
|
176
|
+
refusal.reason = assertErr.reason;
|
|
177
|
+
throw refusal;
|
|
146
178
|
}
|
|
147
179
|
this.logger.debug('[QueueManager] Queue asserted', {
|
|
148
180
|
serviceName: this.serviceName,
|
package/src/registryClient.js
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
* registryClient.js
|
|
3
3
|
*
|
|
4
4
|
* ServiceRegistryClient for communication between a microservice (via Agent)
|
|
5
|
-
* and the central registry. Sends the
|
|
6
|
-
* `register` message
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* and the central registry. Sends the service's `operations` map in the
|
|
6
|
+
* `register` message and periodic heartbeat messages. Receives registration
|
|
7
|
+
* responses (register.confirmed / register.rejected) and revalidation requests
|
|
8
|
+
* from the registry.
|
|
9
9
|
*
|
|
10
10
|
* Legacy `apiDescription` / `apiDescriptionRequest` round-trips have been
|
|
11
|
-
* removed — the
|
|
11
|
+
* removed — what the registry validates travels in the `register` message
|
|
12
|
+
* itself, as `operations` (schema-v3). The `spec` key that used to carry an
|
|
13
|
+
* OpenAPI document beside it left in d.562: nothing ever read it.
|
|
12
14
|
*
|
|
13
15
|
* Uses QueueManager to manage AMQP queues. Emits events through EventEmitter.
|
|
14
16
|
*
|
|
@@ -33,6 +35,32 @@ const { queueConfig } = require('@onlineapps/mq-client-core');
|
|
|
33
35
|
const DEFAULTS = require('./defaults');
|
|
34
36
|
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Keys `register()` REFUSES, with the reason the caller needs to act on (d.562).
|
|
40
|
+
*
|
|
41
|
+
* All three used to travel on every registration and nothing ever read them:
|
|
42
|
+
* `api/infra/api_services_registry/src/listeners/registry.listener.js`
|
|
43
|
+
* destructures the `register` message without them, and no other reader exists on
|
|
44
|
+
* the platform (`api/docs/biz/30-operations/registration-wire.md` §2.1).
|
|
45
|
+
*
|
|
46
|
+
* They are refused rather than ignored: a key silently dropped leaves the caller
|
|
47
|
+
* believing something travelled. And they are refused under BOTH spellings each
|
|
48
|
+
* was accepted by (`token` / `validationToken`, `secret` / `tokenSecret`) — two
|
|
49
|
+
* names for one field is the defect this table closes, not a compatibility list.
|
|
50
|
+
*/
|
|
51
|
+
const RETIRED_SERVICE_INFO_KEYS = Object.freeze({
|
|
52
|
+
spec: 'the OpenAPI document a registry used to dereference over HTTP; biz containers expose no HTTP '
|
|
53
|
+
+ '(ADR 0005) and what the registry validates today is `operations` (registration-wire.md §3)',
|
|
54
|
+
token: 'part of the JWT token API that left @onlineapps/service-validator-core in d.344; a validation '
|
|
55
|
+
+ 'run is proven by `validationProof` / `validationData` and the certificate the validator signs',
|
|
56
|
+
validationToken: 'part of the JWT token API that left @onlineapps/service-validator-core in d.344; a '
|
|
57
|
+
+ 'validation run is proven by `validationProof` / `validationData` and the certificate the validator signs',
|
|
58
|
+
secret: 'the shared secret of that same token API, whose last reader (HTTP POST /validate) was deleted '
|
|
59
|
+
+ '2026-08-22; nothing verifies a token with it, and a broker message is no place for a secret',
|
|
60
|
+
tokenSecret: 'the shared secret of that same token API, whose last reader (HTTP POST /validate) was '
|
|
61
|
+
+ 'deleted 2026-08-22; nothing verifies a token with it, and a broker message is no place for a secret'
|
|
62
|
+
});
|
|
63
|
+
|
|
36
64
|
|
|
37
65
|
class ServiceRegistryClient extends EventEmitter {
|
|
38
66
|
/**
|
|
@@ -190,11 +218,22 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
190
218
|
} catch (consumeErr) {
|
|
191
219
|
// Not logged before throwing: the thrown message already names the service,
|
|
192
220
|
// the queue and the cause.
|
|
193
|
-
|
|
221
|
+
//
|
|
222
|
+
// The CLASSIFICATION the source put on the failure travels with the rewrap,
|
|
223
|
+
// for the same reason it does in `QueueManager.init()`: a queue the broker
|
|
224
|
+
// refused with 406 PRECONDITION-FAILED is answered identically on every
|
|
225
|
+
// attempt, and `@onlineapps/service-wrapper` ends FÁZE 0.7 permanently on
|
|
226
|
+
// `error.code` rather than on this sentence (d.532). `new Error(msg,
|
|
227
|
+
// { cause })` carries no own property, so the marker used to die here.
|
|
228
|
+
// Nothing is classified HERE — what the source left unmarked stays unmarked.
|
|
229
|
+
const refusal = new Error(
|
|
194
230
|
`[RegistryClient] ${this.serviceName}: Failed to start consumer on ${this.serviceRegistryQueue} - `
|
|
195
231
|
+ `${consumeErr.message}. Fix: check RabbitMQ health; without this consumer no registry reply is ever read.`,
|
|
196
232
|
{ cause: consumeErr }
|
|
197
233
|
);
|
|
234
|
+
refusal.code = consumeErr.code;
|
|
235
|
+
refusal.reason = consumeErr.reason;
|
|
236
|
+
throw refusal;
|
|
198
237
|
} finally {
|
|
199
238
|
if (consumeRegistryTimeout) clearTimeout(consumeRegistryTimeout);
|
|
200
239
|
}
|
|
@@ -426,7 +465,6 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
426
465
|
* @param {Array} serviceInfo.endpoints - API endpoints provided by the service
|
|
427
466
|
* @param {Object} serviceInfo.metadata - Additional service metadata
|
|
428
467
|
* @param {string} serviceInfo.health - Health check endpoint
|
|
429
|
-
* @param {Object} serviceInfo.spec - OpenAPI specification
|
|
430
468
|
* @param {(boolean|null)} [serviceInfo.deployable] - Manifest-conformance verdict of the
|
|
431
469
|
* caller's last validation run: `true`, `false`, or `null` when the run never measured
|
|
432
470
|
* it. Emitted only when the caller states one; see the `deployable` block below.
|
|
@@ -450,6 +488,19 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
450
488
|
);
|
|
451
489
|
}
|
|
452
490
|
|
|
491
|
+
// A retired key is refused BY NAME, before anything is published: dropping it
|
|
492
|
+
// in silence would leave the caller believing it travelled. The value is never
|
|
493
|
+
// echoed - two of these keys carried a secret.
|
|
494
|
+
for (const key of Object.keys(serviceInfo)) {
|
|
495
|
+
if (Object.prototype.hasOwnProperty.call(RETIRED_SERVICE_INFO_KEYS, key)) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`[RegistryClient] ${this.serviceName}: serviceInfo carries the retired key "${key}" - `
|
|
498
|
+
+ `${RETIRED_SERVICE_INFO_KEYS[key]}. `
|
|
499
|
+
+ `Fix: delete "${key}" from the object passed to register().`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
453
504
|
// FAIL-FAST: a client that was never initialized, or that has been closed, is not
|
|
454
505
|
// usable. It used to silently call queueManager.init() here, which re-opened a
|
|
455
506
|
// channel WITHOUT restarting the response consumer started by init() - so the
|
|
@@ -475,15 +526,13 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
475
526
|
operations: serviceInfo.operations || {},
|
|
476
527
|
metadata: serviceInfo.metadata || {},
|
|
477
528
|
health: serviceInfo.health || '/health',
|
|
478
|
-
spec: serviceInfo.spec || null,
|
|
479
|
-
validationToken: serviceInfo.token || serviceInfo.validationToken,
|
|
480
|
-
tokenSecret: serviceInfo.secret || serviceInfo.tokenSecret,
|
|
481
529
|
responseQueue: this.serviceRegistryQueue, // Queue for registry to send response
|
|
482
530
|
timestamp: new Date().toISOString()
|
|
483
531
|
};
|
|
484
532
|
|
|
485
533
|
// Propagate workspaceScoped flag to the registry so consumers can discover
|
|
486
|
-
// which services are workspace-scoped.
|
|
534
|
+
// which services are workspace-scoped.
|
|
535
|
+
// @see api/docs/biz/60-templates/onboarding-checklist.md §4
|
|
487
536
|
if (typeof serviceInfo.workspaceScoped === 'boolean') {
|
|
488
537
|
msg.workspaceScoped = serviceInfo.workspaceScoped;
|
|
489
538
|
}
|
|
@@ -732,36 +781,115 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
732
781
|
}
|
|
733
782
|
|
|
734
783
|
/**
|
|
735
|
-
* Starts periodic heartbeat messages
|
|
784
|
+
* Starts periodic heartbeat messages, and gives up after a budget of
|
|
785
|
+
* consecutive failures.
|
|
786
|
+
*
|
|
787
|
+
* A heartbeat loop with no budget is worse than none: the beats stop reaching
|
|
788
|
+
* the registry, the loop logs and reschedules forever, and the service keeps
|
|
789
|
+
* running while every consumer of `registry:services` reads it as stale. Until
|
|
790
|
+
* 2026-09-15 that budget lived in `@onlineapps/service-wrapper`, in a second
|
|
791
|
+
* loop around `sendHeartbeat()` — two loops for one cadence
|
|
792
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
793
|
+
*
|
|
794
|
+
* The cadence is NOT an argument here. It has one owner and it is the
|
|
795
|
+
* constructor's `heartbeatInterval` (owner confirmation
|
|
796
|
+
* docs/governance/confirmations/biz-health-freshness.md 001 point 3); taking it
|
|
797
|
+
* again per call would be the second number that confirmation refuses.
|
|
798
|
+
*
|
|
799
|
+
* Neither option is defaulted. How many missed beats mean death, and what death
|
|
800
|
+
* means, are decisions of the caller that owns the process — this client only
|
|
801
|
+
* counts and reports (`.claude/rules/architecture-principles.md` §3, §4).
|
|
802
|
+
*
|
|
803
|
+
* @param {Object} options
|
|
804
|
+
* @param {number} options.maxFailures - REQUIRED. Consecutive failed beats the
|
|
805
|
+
* service may accumulate before it can no longer prove it is alive. A
|
|
806
|
+
* positive whole number; one success resets the count to zero.
|
|
807
|
+
* @param {function(Error): void} options.onFatal - REQUIRED. Called exactly once,
|
|
808
|
+
* with the §5 error naming the count and the last cause, at the moment the
|
|
809
|
+
* budget is spent. The loop is already stopped when it is called.
|
|
736
810
|
*/
|
|
737
|
-
startHeartbeat() {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
811
|
+
startHeartbeat(options) {
|
|
812
|
+
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
|
|
813
|
+
throw new Error(
|
|
814
|
+
`[RegistryClient] ${this.serviceName}: Missing heartbeat options - startHeartbeat({ maxFailures, onFatal }) `
|
|
815
|
+
+ `requires both, got ${JSON.stringify(options) === undefined ? 'undefined' : JSON.stringify(options)}. `
|
|
816
|
+
+ 'Expected: an object naming the give-up budget and the handler that receives the fatal error. '
|
|
817
|
+
+ 'Fix: call client.startHeartbeat({ maxFailures, onFatal }); this loop carries no default budget and '
|
|
818
|
+
+ 'decides nothing about the service.'
|
|
819
|
+
);
|
|
820
|
+
}
|
|
745
821
|
|
|
746
|
-
|
|
747
|
-
|
|
822
|
+
const { maxFailures, onFatal } = options;
|
|
823
|
+
|
|
824
|
+
// Checked before the timer exists, so a wrong budget fails the boot naming the
|
|
825
|
+
// option rather than killing the service on beat one (§4, fail-fast).
|
|
826
|
+
if (!Number.isInteger(maxFailures) || maxFailures <= 0) {
|
|
827
|
+
throw new Error(
|
|
828
|
+
`[RegistryClient] ${this.serviceName}: Invalid maxFailures - expected a positive whole number of consecutive `
|
|
829
|
+
+ `failed beats, got ${JSON.stringify(maxFailures) === undefined ? 'undefined' : JSON.stringify(maxFailures)}. `
|
|
830
|
+
+ 'Expected: the budget its owner decided. Fix: pass the number of beats the service may miss before it is '
|
|
831
|
+
+ 'no longer alive; at 0 the loop would give up on the first blip, which is the opposite of a budget.'
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (typeof onFatal !== 'function') {
|
|
836
|
+
throw new Error(
|
|
837
|
+
`[RegistryClient] ${this.serviceName}: Invalid onFatal - expected a function, got `
|
|
838
|
+
+ `${JSON.stringify(onFatal) === undefined ? 'undefined' : JSON.stringify(onFatal)}. `
|
|
839
|
+
+ 'Expected: the caller decides what a dead heartbeat means (stop the service, page someone); this client '
|
|
840
|
+
+ 'only reports that the budget is spent. '
|
|
841
|
+
+ 'Fix: pass onFatal: (err) => { /* shut the service down so it re-registers on restart */ }.'
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
let consecutiveFailures = 0;
|
|
846
|
+
|
|
847
|
+
// ONE accounting path for every beat, the immediate one included: a first beat
|
|
848
|
+
// counted outside the budget would make `maxFailures` mean "N + 1 for the first
|
|
849
|
+
// failure" — a number the caller did not set.
|
|
850
|
+
const beat = async () => {
|
|
748
851
|
try {
|
|
749
852
|
await this.sendHeartbeat();
|
|
853
|
+
consecutiveFailures = 0;
|
|
750
854
|
} catch (err) {
|
|
855
|
+
consecutiveFailures += 1;
|
|
751
856
|
this.logger.error('[RegistryClient] Heartbeat failed', {
|
|
752
857
|
serviceName: this.serviceName,
|
|
753
|
-
error: err.message
|
|
858
|
+
error: err.message,
|
|
859
|
+
consecutiveFailures,
|
|
860
|
+
maxFailures
|
|
754
861
|
});
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
862
|
+
|
|
863
|
+
if (consecutiveFailures >= maxFailures) {
|
|
864
|
+
// Stopped BEFORE onFatal runs: the handler tears the process down, and a
|
|
865
|
+
// timer still armed would fire one more beat into a closing service.
|
|
866
|
+
this.stopHeartbeat();
|
|
867
|
+
onFatal(new Error(
|
|
868
|
+
`[RegistryClient] ${this.serviceName}: Registry heartbeat failed ${consecutiveFailures} times in a row - `
|
|
869
|
+
+ `the service can no longer prove it is alive; last cause: ${err.message} `
|
|
870
|
+
+ `Expected: a beat reaches ${this.registryQueue} within the budget of ${maxFailures}. `
|
|
871
|
+
+ 'Fix: check the registry service and the queue it consumes, then restart this service so it '
|
|
872
|
+
+ 'registers again.',
|
|
873
|
+
{ cause: err }
|
|
874
|
+
));
|
|
759
875
|
}
|
|
760
876
|
}
|
|
761
877
|
};
|
|
762
878
|
|
|
763
|
-
//
|
|
879
|
+
// Repeat at the configured cadence with a recursive timeout, so two beats can
|
|
880
|
+
// never run in parallel; `heartbeatTimer === null` is how a stop (from
|
|
881
|
+
// stopHeartbeat, or from the budget above) refuses the next schedule.
|
|
882
|
+
const heartbeatLoop = async () => {
|
|
883
|
+
await beat();
|
|
884
|
+
if (this.heartbeatTimer !== null) {
|
|
885
|
+
this.heartbeatTimer = setTimeout(heartbeatLoop, this.heartbeatInterval);
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
// Armed before the immediate beat is awaited, so the budget can clear it: the
|
|
890
|
+
// beat's rejection is handled in a microtask, i.e. after this line has run.
|
|
764
891
|
this.heartbeatTimer = setTimeout(heartbeatLoop, this.heartbeatInterval);
|
|
892
|
+
beat();
|
|
765
893
|
}
|
|
766
894
|
|
|
767
895
|
/**
|