@onlineapps/conn-orch-registry 6.0.3 → 8.0.0

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