@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 CHANGED
@@ -76,7 +76,15 @@ await client.register({
76
76
  health: '/health'
77
77
  });
78
78
 
79
- client.startHeartbeat();
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
- | (full spec: endpoints | |
21
- | + operations + meta) |-- validate() --------->|
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 after successful registration.
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(); // Only after validation
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
 
@@ -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": "4.0.3",
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": "3.2.0",
40
+ "@onlineapps/mq-client-core": "4.0.0",
41
41
  "uuid": "^9.0.1"
42
42
  },
43
43
  "devDependencies": {
@@ -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
- throw new Error(
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
- throw new Error(
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,
@@ -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 full service specification in the
6
- * `register` message (including the `operations` map) and periodic heartbeat
7
- * messages. Receives registration responses (register.confirmed /
8
- * register.rejected) and revalidation requests from the registry.
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 full spec is part of the `register` message itself.
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
- throw new Error(
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. See biz-service-onboarding.md §4.
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
- // Send immediately after init
739
- this.sendHeartbeat().catch(err => {
740
- this.logger.error('[RegistryClient] Initial heartbeat failed', {
741
- serviceName: this.serviceName,
742
- error: err.message
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
- // Repeat at the configured interval using a safe recursive timeout to avoid parallel runs
747
- const heartbeatLoop = async () => {
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
- } finally {
756
- // Schedule next heartbeat only after previous one finished (or failed)
757
- if (this.heartbeatTimer !== null) {
758
- this.heartbeatTimer = setTimeout(heartbeatLoop, this.heartbeatInterval);
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
- // Use setTimeout instead of setInterval for better control
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
  /**