@onlineapps/conn-orch-registry 4.0.3 → 6.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 +163 -43
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": "6.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,39 @@ 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
|
|
40
|
+
* (d.562, extended by d.592).
|
|
41
|
+
*
|
|
42
|
+
* Each of them used to travel on every registration and nothing ever read them:
|
|
43
|
+
* `api/infra/api_services_registry/src/listeners/registry.listener.js`
|
|
44
|
+
* destructures the `register` message without them, and no other reader exists on
|
|
45
|
+
* the platform (`api/docs/biz/30-operations/registration-wire.md` §2.1).
|
|
46
|
+
*
|
|
47
|
+
* They are refused rather than ignored: a key silently dropped leaves the caller
|
|
48
|
+
* believing something travelled. And they are refused under BOTH spellings each
|
|
49
|
+
* was accepted by (`token` / `validationToken`, `secret` / `tokenSecret`) — two
|
|
50
|
+
* names for one field is the defect this table closes, not a compatibility list.
|
|
51
|
+
*/
|
|
52
|
+
const RETIRED_SERVICE_INFO_KEYS = Object.freeze({
|
|
53
|
+
spec: 'the OpenAPI document a registry used to dereference over HTTP; biz containers expose no HTTP '
|
|
54
|
+
+ '(ADR 0005) and what the registry validates today is `operations` (registration-wire.md §3)',
|
|
55
|
+
token: 'part of the JWT token API that left @onlineapps/service-validator-core in d.344; a validation '
|
|
56
|
+
+ 'run is proven by `validationProof` / `validationData` and the certificate the validator signs',
|
|
57
|
+
validationToken: 'part of the JWT token API that left @onlineapps/service-validator-core in d.344; a '
|
|
58
|
+
+ 'validation run is proven by `validationProof` / `validationData` and the certificate the validator signs',
|
|
59
|
+
secret: 'the shared secret of that same token API, whose last reader (HTTP POST /validate) was deleted '
|
|
60
|
+
+ '2026-08-22; nothing verifies a token with it, and a broker message is no place for a secret',
|
|
61
|
+
tokenSecret: 'the shared secret of that same token API, whose last reader (HTTP POST /validate) was '
|
|
62
|
+
+ 'deleted 2026-08-22; nothing verifies a token with it, and a broker message is no place for a secret',
|
|
63
|
+
deployable: 'the manifest-conformance verdict of a boot-time validation run (owner decision 2026-09-17, '
|
|
64
|
+
+ 'confirmation `api/docs/governance/confirmations/biz-service-manifest.md` 011). A running container is '
|
|
65
|
+
+ 'not a git checkout and carries neither compose nor README, so the verdict it could reach there says '
|
|
66
|
+
+ 'nothing about the repository; deployability is proven per commit by the CI job `validate-uniform` '
|
|
67
|
+
+ '(confirmation 010). The registry never projected the key either — registry.listener.js does not '
|
|
68
|
+
+ 'destructure it, so deployability is read from that CI job and never from a registration message'
|
|
69
|
+
});
|
|
70
|
+
|
|
36
71
|
|
|
37
72
|
class ServiceRegistryClient extends EventEmitter {
|
|
38
73
|
/**
|
|
@@ -190,11 +225,22 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
190
225
|
} catch (consumeErr) {
|
|
191
226
|
// Not logged before throwing: the thrown message already names the service,
|
|
192
227
|
// the queue and the cause.
|
|
193
|
-
|
|
228
|
+
//
|
|
229
|
+
// The CLASSIFICATION the source put on the failure travels with the rewrap,
|
|
230
|
+
// for the same reason it does in `QueueManager.init()`: a queue the broker
|
|
231
|
+
// refused with 406 PRECONDITION-FAILED is answered identically on every
|
|
232
|
+
// attempt, and `@onlineapps/service-wrapper` ends FÁZE 0.7 permanently on
|
|
233
|
+
// `error.code` rather than on this sentence (d.532). `new Error(msg,
|
|
234
|
+
// { cause })` carries no own property, so the marker used to die here.
|
|
235
|
+
// Nothing is classified HERE — what the source left unmarked stays unmarked.
|
|
236
|
+
const refusal = new Error(
|
|
194
237
|
`[RegistryClient] ${this.serviceName}: Failed to start consumer on ${this.serviceRegistryQueue} - `
|
|
195
238
|
+ `${consumeErr.message}. Fix: check RabbitMQ health; without this consumer no registry reply is ever read.`,
|
|
196
239
|
{ cause: consumeErr }
|
|
197
240
|
);
|
|
241
|
+
refusal.code = consumeErr.code;
|
|
242
|
+
refusal.reason = consumeErr.reason;
|
|
243
|
+
throw refusal;
|
|
198
244
|
} finally {
|
|
199
245
|
if (consumeRegistryTimeout) clearTimeout(consumeRegistryTimeout);
|
|
200
246
|
}
|
|
@@ -426,10 +472,6 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
426
472
|
* @param {Array} serviceInfo.endpoints - API endpoints provided by the service
|
|
427
473
|
* @param {Object} serviceInfo.metadata - Additional service metadata
|
|
428
474
|
* @param {string} serviceInfo.health - Health check endpoint
|
|
429
|
-
* @param {Object} serviceInfo.spec - OpenAPI specification
|
|
430
|
-
* @param {(boolean|null)} [serviceInfo.deployable] - Manifest-conformance verdict of the
|
|
431
|
-
* caller's last validation run: `true`, `false`, or `null` when the run never measured
|
|
432
|
-
* it. Emitted only when the caller states one; see the `deployable` block below.
|
|
433
475
|
* @param {number} serviceInfo.timeout - Timeout for registration response (default: 30000ms)
|
|
434
476
|
* @returns {Promise<{success: boolean, message: string, serviceName: string, version: string,
|
|
435
477
|
* registrationId: (string|undefined), validated: boolean, certificate: (Object|null),
|
|
@@ -450,6 +492,19 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
450
492
|
);
|
|
451
493
|
}
|
|
452
494
|
|
|
495
|
+
// A retired key is refused BY NAME, before anything is published: dropping it
|
|
496
|
+
// in silence would leave the caller believing it travelled. The value is never
|
|
497
|
+
// echoed - two of these keys carried a secret.
|
|
498
|
+
for (const key of Object.keys(serviceInfo)) {
|
|
499
|
+
if (Object.prototype.hasOwnProperty.call(RETIRED_SERVICE_INFO_KEYS, key)) {
|
|
500
|
+
throw new Error(
|
|
501
|
+
`[RegistryClient] ${this.serviceName}: serviceInfo carries the retired key "${key}" - `
|
|
502
|
+
+ `${RETIRED_SERVICE_INFO_KEYS[key]}. `
|
|
503
|
+
+ `Fix: delete "${key}" from the object passed to register().`
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
453
508
|
// FAIL-FAST: a client that was never initialized, or that has been closed, is not
|
|
454
509
|
// usable. It used to silently call queueManager.init() here, which re-opened a
|
|
455
510
|
// channel WITHOUT restarting the response consumer started by init() - so the
|
|
@@ -475,31 +530,17 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
475
530
|
operations: serviceInfo.operations || {},
|
|
476
531
|
metadata: serviceInfo.metadata || {},
|
|
477
532
|
health: serviceInfo.health || '/health',
|
|
478
|
-
spec: serviceInfo.spec || null,
|
|
479
|
-
validationToken: serviceInfo.token || serviceInfo.validationToken,
|
|
480
|
-
tokenSecret: serviceInfo.secret || serviceInfo.tokenSecret,
|
|
481
533
|
responseQueue: this.serviceRegistryQueue, // Queue for registry to send response
|
|
482
534
|
timestamp: new Date().toISOString()
|
|
483
535
|
};
|
|
484
536
|
|
|
485
537
|
// Propagate workspaceScoped flag to the registry so consumers can discover
|
|
486
|
-
// which services are workspace-scoped.
|
|
538
|
+
// which services are workspace-scoped.
|
|
539
|
+
// @see api/docs/biz/60-templates/onboarding-checklist.md §4
|
|
487
540
|
if (typeof serviceInfo.workspaceScoped === 'boolean') {
|
|
488
541
|
msg.workspaceScoped = serviceInfo.workspaceScoped;
|
|
489
542
|
}
|
|
490
543
|
|
|
491
|
-
// The manifest-conformance verdict, verbatim from the caller: `true`, `false`,
|
|
492
|
-
// or `null` when validation stopped before step 7 measured it. The registry
|
|
493
|
-
// projects it into `infrastructure:health:<service>` beside `status`
|
|
494
|
-
// (owner confirmation `api/docs/governance/confirmations/biz-service-manifest.md`
|
|
495
|
-
// 001 §4), so `null` is a value this message must be able to carry — hence a
|
|
496
|
-
// presence check and not a truthiness one. A caller that sends no verdict
|
|
497
|
-
// (anything other than ServiceWrapper) puts no key on the wire; nothing is
|
|
498
|
-
// substituted for it.
|
|
499
|
-
if ('deployable' in serviceInfo) {
|
|
500
|
-
msg.deployable = serviceInfo.deployable;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
544
|
// Include validation proof if loaded
|
|
504
545
|
// Structure: { validationProof: "hash", validationData: { ... } }
|
|
505
546
|
// See: @onlineapps/service-validator-core/README.md#validation-proof-structure
|
|
@@ -732,36 +773,115 @@ class ServiceRegistryClient extends EventEmitter {
|
|
|
732
773
|
}
|
|
733
774
|
|
|
734
775
|
/**
|
|
735
|
-
* Starts periodic heartbeat messages
|
|
776
|
+
* Starts periodic heartbeat messages, and gives up after a budget of
|
|
777
|
+
* consecutive failures.
|
|
778
|
+
*
|
|
779
|
+
* A heartbeat loop with no budget is worse than none: the beats stop reaching
|
|
780
|
+
* the registry, the loop logs and reschedules forever, and the service keeps
|
|
781
|
+
* running while every consumer of `registry:services` reads it as stale. Until
|
|
782
|
+
* 2026-09-15 that budget lived in `@onlineapps/service-wrapper`, in a second
|
|
783
|
+
* loop around `sendHeartbeat()` — two loops for one cadence
|
|
784
|
+
* (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
785
|
+
*
|
|
786
|
+
* The cadence is NOT an argument here. It has one owner and it is the
|
|
787
|
+
* constructor's `heartbeatInterval` (owner confirmation
|
|
788
|
+
* docs/governance/confirmations/biz-health-freshness.md 001 point 3); taking it
|
|
789
|
+
* again per call would be the second number that confirmation refuses.
|
|
790
|
+
*
|
|
791
|
+
* Neither option is defaulted. How many missed beats mean death, and what death
|
|
792
|
+
* means, are decisions of the caller that owns the process — this client only
|
|
793
|
+
* counts and reports (`.claude/rules/architecture-principles.md` §3, §4).
|
|
794
|
+
*
|
|
795
|
+
* @param {Object} options
|
|
796
|
+
* @param {number} options.maxFailures - REQUIRED. Consecutive failed beats the
|
|
797
|
+
* service may accumulate before it can no longer prove it is alive. A
|
|
798
|
+
* positive whole number; one success resets the count to zero.
|
|
799
|
+
* @param {function(Error): void} options.onFatal - REQUIRED. Called exactly once,
|
|
800
|
+
* with the §5 error naming the count and the last cause, at the moment the
|
|
801
|
+
* budget is spent. The loop is already stopped when it is called.
|
|
736
802
|
*/
|
|
737
|
-
startHeartbeat() {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
803
|
+
startHeartbeat(options) {
|
|
804
|
+
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
|
|
805
|
+
throw new Error(
|
|
806
|
+
`[RegistryClient] ${this.serviceName}: Missing heartbeat options - startHeartbeat({ maxFailures, onFatal }) `
|
|
807
|
+
+ `requires both, got ${JSON.stringify(options) === undefined ? 'undefined' : JSON.stringify(options)}. `
|
|
808
|
+
+ 'Expected: an object naming the give-up budget and the handler that receives the fatal error. '
|
|
809
|
+
+ 'Fix: call client.startHeartbeat({ maxFailures, onFatal }); this loop carries no default budget and '
|
|
810
|
+
+ 'decides nothing about the service.'
|
|
811
|
+
);
|
|
812
|
+
}
|
|
745
813
|
|
|
746
|
-
|
|
747
|
-
|
|
814
|
+
const { maxFailures, onFatal } = options;
|
|
815
|
+
|
|
816
|
+
// Checked before the timer exists, so a wrong budget fails the boot naming the
|
|
817
|
+
// option rather than killing the service on beat one (§4, fail-fast).
|
|
818
|
+
if (!Number.isInteger(maxFailures) || maxFailures <= 0) {
|
|
819
|
+
throw new Error(
|
|
820
|
+
`[RegistryClient] ${this.serviceName}: Invalid maxFailures - expected a positive whole number of consecutive `
|
|
821
|
+
+ `failed beats, got ${JSON.stringify(maxFailures) === undefined ? 'undefined' : JSON.stringify(maxFailures)}. `
|
|
822
|
+
+ 'Expected: the budget its owner decided. Fix: pass the number of beats the service may miss before it is '
|
|
823
|
+
+ 'no longer alive; at 0 the loop would give up on the first blip, which is the opposite of a budget.'
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (typeof onFatal !== 'function') {
|
|
828
|
+
throw new Error(
|
|
829
|
+
`[RegistryClient] ${this.serviceName}: Invalid onFatal - expected a function, got `
|
|
830
|
+
+ `${JSON.stringify(onFatal) === undefined ? 'undefined' : JSON.stringify(onFatal)}. `
|
|
831
|
+
+ 'Expected: the caller decides what a dead heartbeat means (stop the service, page someone); this client '
|
|
832
|
+
+ 'only reports that the budget is spent. '
|
|
833
|
+
+ 'Fix: pass onFatal: (err) => { /* shut the service down so it re-registers on restart */ }.'
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
let consecutiveFailures = 0;
|
|
838
|
+
|
|
839
|
+
// ONE accounting path for every beat, the immediate one included: a first beat
|
|
840
|
+
// counted outside the budget would make `maxFailures` mean "N + 1 for the first
|
|
841
|
+
// failure" — a number the caller did not set.
|
|
842
|
+
const beat = async () => {
|
|
748
843
|
try {
|
|
749
844
|
await this.sendHeartbeat();
|
|
845
|
+
consecutiveFailures = 0;
|
|
750
846
|
} catch (err) {
|
|
847
|
+
consecutiveFailures += 1;
|
|
751
848
|
this.logger.error('[RegistryClient] Heartbeat failed', {
|
|
752
849
|
serviceName: this.serviceName,
|
|
753
|
-
error: err.message
|
|
850
|
+
error: err.message,
|
|
851
|
+
consecutiveFailures,
|
|
852
|
+
maxFailures
|
|
754
853
|
});
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
854
|
+
|
|
855
|
+
if (consecutiveFailures >= maxFailures) {
|
|
856
|
+
// Stopped BEFORE onFatal runs: the handler tears the process down, and a
|
|
857
|
+
// timer still armed would fire one more beat into a closing service.
|
|
858
|
+
this.stopHeartbeat();
|
|
859
|
+
onFatal(new Error(
|
|
860
|
+
`[RegistryClient] ${this.serviceName}: Registry heartbeat failed ${consecutiveFailures} times in a row - `
|
|
861
|
+
+ `the service can no longer prove it is alive; last cause: ${err.message} `
|
|
862
|
+
+ `Expected: a beat reaches ${this.registryQueue} within the budget of ${maxFailures}. `
|
|
863
|
+
+ 'Fix: check the registry service and the queue it consumes, then restart this service so it '
|
|
864
|
+
+ 'registers again.',
|
|
865
|
+
{ cause: err }
|
|
866
|
+
));
|
|
759
867
|
}
|
|
760
868
|
}
|
|
761
869
|
};
|
|
762
870
|
|
|
763
|
-
//
|
|
871
|
+
// Repeat at the configured cadence with a recursive timeout, so two beats can
|
|
872
|
+
// never run in parallel; `heartbeatTimer === null` is how a stop (from
|
|
873
|
+
// stopHeartbeat, or from the budget above) refuses the next schedule.
|
|
874
|
+
const heartbeatLoop = async () => {
|
|
875
|
+
await beat();
|
|
876
|
+
if (this.heartbeatTimer !== null) {
|
|
877
|
+
this.heartbeatTimer = setTimeout(heartbeatLoop, this.heartbeatInterval);
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
// Armed before the immediate beat is awaited, so the budget can clear it: the
|
|
882
|
+
// beat's rejection is handled in a microtask, i.e. after this line has run.
|
|
764
883
|
this.heartbeatTimer = setTimeout(heartbeatLoop, this.heartbeatInterval);
|
|
884
|
+
beat();
|
|
765
885
|
}
|
|
766
886
|
|
|
767
887
|
/**
|