@onlineapps/conn-orch-registry 7.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/API.md CHANGED
@@ -405,6 +405,7 @@ Events (see src/events.js):
405
405
  * [.subscribeToChanges()](#module_@onlineapps/conn-orch-registry/src/registryClient..ServiceRegistryClient+subscribeToChanges) ⇒ <code>Promise.&lt;void&gt;</code>
406
406
  * [.getService(serviceName)](#module_@onlineapps/conn-orch-registry/src/registryClient..ServiceRegistryClient+getService) ⇒ <code>Promise.&lt;(ServiceSummary\|null)&gt;</code>
407
407
  * [.close()](#module_@onlineapps/conn-orch-registry/src/registryClient..ServiceRegistryClient+close) ⇒ <code>Promise.&lt;void&gt;</code>
408
+ * [~DISCOVERY_FAILURE_CODES](#module_@onlineapps/conn-orch-registry/src/registryClient..DISCOVERY_FAILURE_CODES)
408
409
  * [~RETIRED_SERVICE_INFO_KEYS](#module_@onlineapps/conn-orch-registry/src/registryClient..RETIRED_SERVICE_INFO_KEYS)
409
410
  * [~ACCEPTED_SERVICE_INFO_KEYS](#module_@onlineapps/conn-orch-registry/src/registryClient..ACCEPTED_SERVICE_INFO_KEYS)
410
411
  * [~ServiceSummary](#module_@onlineapps/conn-orch-registry/src/registryClient..ServiceSummary) : <code>Object</code>
@@ -438,11 +439,12 @@ Events (see src/events.js):
438
439
  | opts.serviceName | <code>string</code> | | Name of the service (e.g., 'invoicing') |
439
440
  | opts.version | <code>string</code> | | Version of the service (e.g., '1.2.0') |
440
441
  | opts.specificationEndpoint | <code>string</code> | | Path the `register` and `heartbeat` messages carry. REQUIRED: the wire format declares it required (api/docs/biz/30-operations/registration-wire.md §2.1) and the registry stores what it is sent, so a default here would write a path nobody configured into `registry:services`. |
441
- | opts.heartbeatInterval | <code>number</code> | | Milliseconds between heartbeats. REQUIRED: the cadence has ONE owner and it is not a library default (owner confirmation docs/governance/confirmations/biz-health-freshness.md 001 point 3); ServiceWrapper passes `wrapper.registry.heartbeatInterval`, itself fed from BIZ_HEARTBEAT_INTERVAL_MS. |
442
+ | opts.heartbeatInterval | <code>number</code> | | Milliseconds between heartbeats. REQUIRED: the cadence has ONE owner and it is not a library default (owner confirmation docs/governance/confirmations/biz-health-freshness.md 001 point 3); ServiceWrapper reads it from the environment key BIZ_HEARTBEAT_INTERVAL_MS and passes it here. The config key `wrapper.registry.heartbeatInterval` is retired and the wrapper refuses it. |
442
443
  | [opts.apiQueue] | <code>string</code> | <code>&quot;&#x27;api_services_queuer&#x27;&quot;</code> | Queue name for heartbeat and API traffic |
443
444
  | [opts.registryQueue] | <code>string</code> | <code>&quot;&#x27;registry.register&#x27;&quot;</code> | Queue name for registry messages |
444
445
  | [opts.validationProof] | <code>Object</code> | <code></code> | Validation proof object {hash, data} (injected from outside) |
445
446
  | [opts.registrationTimeoutMs] | <code>number</code> | | How long register() waits for the registry response before rejecting. Default comes from ./defaults.js (30000 ms); never read from the environment here. |
447
+ | opts.discoveryTimeoutMs | <code>number</code> | | How long ONE discovery read (`getService()`) may wait for the registry projection before it is refused as `TRANSIENT`. REQUIRED, no module default: the value belongs to the platform (ServiceWrapper reads it from the platform env) and bounds how long a workflow step can hang on a Redis that is down or half-open (d.1090, d.1093). Enforced as a race beside the command, never as node-redis' `AbortSignal` (see `getService()` for why). |
446
448
  | opts.logger | <code>Object</code> | | Logger with info/warn/error/debug. Required and validated here: registration, heartbeat and revalidation events are what this client exists to report, and until 2026-09-05 they went to stdout through bare printing while this parameter was accepted and never read. Owner confirmation: docs/governance/confirmations/connector-logger-contract.md 001 + 002. |
447
449
 
448
450
  <a name="module_@onlineapps/conn-orch-registry/src/registryClient..ServiceRegistryClient+init"></a>
@@ -494,6 +496,12 @@ What this call knows, and therefore what it returns: the request it published,
494
496
  where it published it, and that the heartbeat timer is stopped. A publish that
495
497
  fails REJECTS; reaching the return means the broker took the message.
496
498
 
499
+ The heartbeat loop stops FIRST, before anything is published. A heartbeat is
500
+ the claim "this copy is alive"; a caller that deregisters has decided it is
501
+ leaving, and that decision does not wait for the broker. Until d.1083 the loop
502
+ stopped only after a successful publish, so a rejected deregistration left it
503
+ beating for a service on its way out.
504
+
497
505
  **Kind**: instance method of [<code>ServiceRegistryClient</code>](#module_@onlineapps/conn-orch-registry/src/registryClient..ServiceRegistryClient)
498
506
  <a name="module_@onlineapps/conn-orch-registry/src/registryClient..ServiceRegistryClient+unregister"></a>
499
507
 
@@ -518,9 +526,10 @@ consecutive failures.
518
526
 
519
527
  A heartbeat loop with no budget is worse than none: the beats stop reaching
520
528
  the registry, the loop logs and reschedules forever, and the service keeps
521
- running while every consumer of `registry:services` reads it as stale. Until
522
- 2026-09-15 that budget lived in `@onlineapps/service-wrapper`, in a second
523
- loop around `sendHeartbeat()` — two loops for one cadence
529
+ running while every consumer of `registry:services` reads it as stale. The
530
+ budget lives here and nowhere else: `@onlineapps/service-wrapper` arms this
531
+ loop and runs none of its own (service-wrapper d.1076, its CHANGELOG) — two
532
+ loops for one cadence would be two rails for one concern
524
533
  (`.claude/rules/change-discipline.md` § One rail per concern).
525
534
 
526
535
  The cadence is NOT an argument here. It has one owner and it is the
@@ -579,6 +588,18 @@ Creates event consumer and starts listening to registry.changes exchange
579
588
  Releases resources: stops heartbeat and closes connection.
580
589
 
581
590
  **Kind**: instance method of [<code>ServiceRegistryClient</code>](#module_@onlineapps/conn-orch-registry/src/registryClient..ServiceRegistryClient)
591
+ <a name="module_@onlineapps/conn-orch-registry/src/registryClient..DISCOVERY_FAILURE_CODES"></a>
592
+
593
+ ### @onlineapps/conn-orch-registry/src/registryClient~DISCOVERY\_FAILURE\_CODES
594
+ The two ways a discovery read can fail that say nothing about the answer — the
595
+ projection did not answer in time, or the client already knows Redis is gone. Both
596
+ are typed HERE, where the failure is known, as `TRANSIENT` (d.1093; the pattern
597
+ mq-client-core uses since d.683: a bare error from the transport is typed by the
598
+ site that receives it, `src/utils/publishErrors.js` `TransientPublishError`). The
599
+ L1 classifier reads `type` and never learns what Redis is; cookbook-router carries
600
+ `type` from `cause` onto `SERVICE_DISCOVERY_FAILED`.
601
+
602
+ **Kind**: inner constant of [<code>@onlineapps/conn-orch-registry/src/registryClient</code>](#module_@onlineapps/conn-orch-registry/src/registryClient)
582
603
  <a name="module_@onlineapps/conn-orch-registry/src/registryClient..RETIRED_SERVICE_INFO_KEYS"></a>
583
604
 
584
605
  ### @onlineapps/conn-orch-registry/src/registryClient~RETIRED\_SERVICE\_INFO\_KEYS
package/CHANGELOG.md CHANGED
@@ -4,6 +4,144 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [9.0.0] — 2026-10-12
8
+
9
+ ### Changed — BREAKING — zděděné chování `@onlineapps/mq-client-core` 6.0.0 (platí s pinem 6.0.0, který přijde s vydáním jádra; d.1361, d.1363)
10
+
11
+ `QueueManager` je tenký průchod k `BaseClient` jádra — `publish()` (`src/queueManager.js:225-226`)
12
+ a `consume()` (`:242-243`) předávají volání i odmítnutí beze změny. Z jádra tenhle balíček nic
13
+ znovu nevydává (`src/index.js`: `ServiceRegistryClient`, `RegistryEventConsumer`, `EVENTS`) a
14
+ konfiguraci jádra nepropouští (`BaseClient` se staví s pevnými klíči `type`, `host`,
15
+ `serviceName`, `logger`, `src/queueManager.js:73-78`), takže odebrání `criticalHealthShutdown`
16
+ a `health:shutdown` (jádro d.1275) volajícího nezasáhne. Co z CHANGELOGu jádra projde k
17
+ volajícímu veřejnými metodami `ServiceRegistryClient`, jakmile se pin `@onlineapps/mq-client-core`
18
+ zvedne z 5.1.0 na 6.0.0 (zdejší kód se nemění):
19
+
20
+ - **`sendHeartbeat()` a `deregister()` propouštějí `PublishError` jádra neobalený**
21
+ (`src/registryClient.js:931`, `:880`; volby `{ persistent: true }`, tedy výchozí
22
+ `bufferOnFailure: true`). S jádrem 6.0.0 (d.1300): publish, jehož čekání na obnovu skončí
23
+ standby (vyčerpaný cyklus), odmítne `PublishError` s `buffered: true`, `sent: false` — dřív
24
+ bez `buffered` — a zprávu DRŽÍ v bufferu; přehraje ji po příštím `reconnected`/`unblocked`.
25
+ Skončí-li obnova fatálně, nese chyba `code: 'MQ_CONNECTION_FATAL'` a `reason`
26
+ (`'cycles-spent'` | `'broker-refused'`) přímo na sobě; nic se nedrží. Volající, který po
27
+ odmítnutí posílal heartbeat nebo deregistraci znovu sám, ji teď může poslat dvakrát.
28
+ Smyčka `startHeartbeat()` počítá odmítnutý beat jako selhání dál stejně (obal s `cause`).
29
+ - **`init()` předává `code`/`reason` chyby `consume()` na svůj obal** (`src/registryClient.js:305`
30
+ → `_startRegistryConsumer`, `:370-371`). S jádrem 6.0.0 (d.1297) nese chyba `consume()` po
31
+ fatálním cyklu `code: 'MQ_CONNECTION_FATAL'` a `reason` NAHOŘE (dřív jen v `cause`, obal měl
32
+ `code` `undefined`). Volající, který podle `error.code` rozhoduje — `@onlineapps/service-wrapper`
33
+ v bootu (`ServiceWrapper.js`, větve `error.code !== 'MQ_CONNECTION_FATAL'`) — bere týž výpadek
34
+ jako TRVALÝ konec bootu, ne přechodný. Standby, `disconnect()` a `reconnectEnabled: false`
35
+ `code` dál nenesou.
36
+ - **`register()` — zpráva držená přes standby se přehraje i po odmítnutí** (`src/registryClient.js:777`,
37
+ `:798-801`). `register()` chybu publishe obaluje (`Failed to send registration message: <věta
38
+ jádra>`, bez `code`) a čekající registraci zruší; zprávu `register`, kterou jádro 6.0.0 po
39
+ standby drží (d.1300), ale po reconnectu přehraje — registr tak dostane registraci, o které
40
+ volající ví, že selhala; její odpověď skončí řádkem WARN
41
+ „Registration response has no matching pending request“ (`:519`). Sousední cesta (přechodná chyba →
42
+ čekání na běžící reconnect → selhání) držela zprávu už v 5.1.0; 6.0.0 přidává standby.
43
+
44
+ Precedent: `@onlineapps/conn-infra-mq` `[Unreleased]` „BREAKING — zděděné chování
45
+ `@onlineapps/mq-client-core` 6.0.0“ a `@onlineapps/conn-orch-cookbook` `[7.0.0]`. Rozhodnutí
46
+ leada d.1361: major 9.0.0.
47
+
48
+ ### Tests — brána „broker pod alarmem“ IT tieru má jedno tělo v repu (d.1326)
49
+
50
+ - `refuseBrokerUnderAlarm` z `tests/integration/setup.js` (originál, d.775/d.783) přesunut do
51
+ `api/scripts/tests/brokerAlarm.js` vedle `sweepOrphanQueues.js` (d.1320), kam z tohoto tieru
52
+ odešel i sweep; `setup.js` ho volá `refuseBrokerUnderAlarm({ ask: mgmtReader(config), context,
53
+ where })` — čtenář zůstává zdejší `mgmtReader` (rozpočet `withinBudget`). Věta odmítnutí beze
54
+ změny tvrzení (`environmentProbe.integration.test.js`: `[conn-orch-registry/integration] The
55
+ broker is under a resource alarm and is blocking publishers`, `memory on rabbit@…`, `HTTP 503
56
+ from <host:port>`); `Fix` nově jmenuje i `rabbitmq-diagnostics -q alarms`.
57
+ - Čtyři otázky `change-discipline.md` § Removing (za všechny tři kopie): četl je jen `setup.js`
58
+ každého tieru (+ unit testy wrapperu a mq-client-core); psal je každý tier zvlášť (tři
59
+ implementace, měřeno `diff`); nahrazuje je jedno tělo s injektovaným čtenářem; porušení se
60
+ pozná větou alarmu v globalSetup tieru před prvním testem (IT `environmentProbe` to drží stubem).
61
+
62
+ ## [8.0.0] — 2026-10-03
63
+
64
+ pin: `@onlineapps/mq-client-core` 5.0.0 → 5.1.0
65
+
66
+ ### Changed — BREAKING: `discoveryTimeoutMs` je povinný, `getService()` má termín a typuje selhání (d.1093)
67
+
68
+ - Nový povinný parametr konstruktoru `discoveryTimeoutMs` (kladné číslo ms, bez výchozí hodnoty;
69
+ chybějící i neplatná hodnota se odmítne při konstrukci). Hodnota patří platformnímu env, čte ji
70
+ `@onlineapps/service-wrapper`.
71
+ - `getService()` dřív při ležícím nebo půlotevřeném Redisu čekal bez termínu (node-redis drží příkaz
72
+ v offline frontě nebo čeká na odpověď, d.1088, d.1090) a krok workflow visel, dokud broker po
73
+ `consumer_timeout` nezavřel kanál a zpráva neexpirovala do DLQ. Nově:
74
+ - dotaz, který neskončí do `discoveryTimeoutMs` → `Error` s `code: 'REGISTRY_DISCOVERY_TIMEOUT'`,
75
+ `type: 'TRANSIENT'`, `serviceName`, bez `cause` (nic pod ním neselhalo);
76
+ - odmítnutí, když klient hlásí `isReady === false` (offline s `disableOfflineQueue`, socket zavřený
77
+ pod příkazem) → `code: 'REGISTRY_DISCOVERY_UNAVAILABLE'`, `type: 'TRANSIENT'`, původní chyba v `cause`;
78
+ - jiné odmítnutí (chybová odpověď připojeného Redisu) projde beze změny.
79
+ Typuje se tady, kde je selhání známé (vzor mq-client-core d.683); `@onlineapps/cookbook-router`
80
+ převezme `type` z `cause`, klasifikátor L1 Redis nezná.
81
+ - **Proč termín závodem a ne `AbortSignal`:** node-redis 4.7.1 (`@redis/client` 1.6.1) nabízí
82
+ `commandOptions({ signal })`, ale abort příkazu, který už odešel na server, odebere jeho uzel z fronty
83
+ podruhé (posluchač abortu zůstane po odeslání připojený), délka fronty klesne pod nulu a další
84
+ `flushAll` — chyba socketu při `disableOfflineQueue`, nebo `disconnect()` — hodí
85
+ `TypeError: Cannot read properties of undefined (reading 'reject')`; z handleru chyby socketu je to
86
+ nezachycená výjimka, která ukončí proces (změřeno d.1093 proti falešnému RESP serveru). Závod příkaz
87
+ nechá ve frontě: pozdní odpověď se spáruje správně a jeho pozdní odmítnutí se zachytí a zaloguje
88
+ (`debug`), nikdy nezůstane neošetřené.
89
+ - devDependency `redis` 4.7.1 (přesně) pro integrační sadu `tests/integration/discoveryDeadline.integration.test.js`
90
+ (živý node-redis proti falešnému RESP serveru; dev Redis se nedotýká).
91
+
92
+ ### Fixed — `deregister()` zastaví heartbeat PŘED publikací, ne až po ní (d.1083)
93
+
94
+ Smyčka se dřív zastavovala až po úspěšné publikaci zprávy `deregister`; když publikace selhala,
95
+ volání odmítlo, ale smyčka tepala dál za kopii, která se odhlašuje. `stopHeartbeat()` je teď první
96
+ krok `deregister()`, ať publikace dopadne jakkoli. Úspěšná deregistrace se chová stejně jako dřív
97
+ (zpráva odejde, `heartbeatStopped: true`). Sada `tests/unit/registryClient.deregisterStopsHeartbeat.test.js`.
98
+ `@onlineapps/service-wrapper` zastavuje smyčku v `shutdown()` sám před `unregister()` (d.1076), pro něj
99
+ je změna neutrální.
100
+
101
+ ### Documentation — docblocky `startHeartbeat` a konstruktoru (`heartbeatInterval`) říkají pravdu (d.1083)
102
+
103
+ `startHeartbeat`: rozpočet selhání žije jen tady, wrapper smyčku zapíná a vlastní nemá (odkaz na
104
+ service-wrapper d.1076 a jeho CHANGELOG místo ručně psaného data). `opts.heartbeatInterval`: wrapper
105
+ kadenci čte z env `BIZ_HEARTBEAT_INTERVAL_MS`; klíč `wrapper.registry.heartbeatInterval` je zrušený
106
+ a wrapper ho odmítá. `API.md` přegenerován (`npm run docs`). Tutéž pravdu teď říká i `Fix:` hlášky
107
+ konstruktoru při chybějícím `heartbeatInterval` (dřív radila právě zrušený klíč; test
108
+ `registryClient.registryAnswer.test.js` tvrdí celou `Fix:` větu), `README.md` (vlastník kadence)
109
+ a hlavička sady `registryClient.registryAnswer.test.js`.
110
+
111
+ ### Documentation — `examples/` srovnané na kontrakt 8.0.0 (d.1187)
112
+
113
+ Oba příklady stavěly klienta, kterého konstruktor odmítne: chyběl `instanceId` (d.817) a
114
+ `discoveryTimeoutMs` (d.1093), `event-consumer-example.js` navíc `specificationEndpoint` a
115
+ `heartbeatInterval` (d.278c). Registrovaly operace ve tvaru v2 (`endpoint`, `method`, neprázdné
116
+ `endpoints`), který registr odmítá (`schema-v3.md` § Keys the v3 schema rejects). Nově:
117
+ - každá povinná volba je zapsaná výslovně; nastavení se čte z env podle jména (klíče platformy:
118
+ `RABBITMQ_URL`, `REDIS_URL`, `SERVICE_INSTANCE_ID`, `BIZ_HEARTBEAT_INTERVAL_MS`,
119
+ `REGISTRY_DISCOVERY_TIMEOUT_MS`, …) přes `examples/env.js`, který chybějící, prázdný nebo
120
+ nečíselný klíč odmítne s `Fix:`; žádný `||` fallback (dřív `REDIS_HOST || 'localhost'`,
121
+ `MINIO_ACCESS_KEY || 'minioadmin'` aj.);
122
+ - operace ve tvaru v3 (`handler`, `bundle_scope`, `mutates`, `resource_type`, `minRole`, `input`,
123
+ `output`), `endpoints: []`, `metadata: { version, description }`;
124
+ - `register()` se větví podle verdiktu registru (`success`, `errorCode`), ne podle událostí
125
+ `registerConfirmed`/`registerRejected`, které klient nevysílá; ukončení volá `deregister()` před
126
+ `close()`;
127
+ - `event-consumer-example.js` běží v režimu jen-index bez `storageConfig`: `subscribeToChanges()`
128
+ staví konzumenta bez `StorageConnector` a úplný `storageConfig` by konstruktor
129
+ `RegistryEventConsumer` odmítl; Redis je node-redis (`createClient`), tentýž klient, kterým
130
+ `getService()` čte projekci.
131
+ - devDependency `ioredis` odebrána: jejím jediným čtenářem byl `event-consumer-example.js`.
132
+ - `README.md` § Quick Start čte `instanceId` přes `requireEnv` jako příklady, ne jako holé
133
+ `process.env.SERVICE_INSTANCE_ID` (d.1187b); `amqpUrl` přes `requireEnv('RABBITMQ_URL', …)` místo
134
+ holého `process.env.AMQP_URL`, `heartbeatInterval` a `discoveryTimeoutMs` z klíčů platformy
135
+ `BIZ_HEARTBEAT_INTERVAL_MS` a `REGISTRY_DISCOVERY_TIMEOUT_MS` místo čísel napsaných v kódu;
136
+ § Configuration už netvrdí, že se konfigurace dá předat proměnnými prostředí (klient žádnou
137
+ nečte) (d.1187c).
138
+
139
+ Nová sada `tests/unit/examples.contract.test.js`: každý příklad projde `node --check`, nenese `||`
140
+ ani `??`, nedeklaruje klíč operace v2 a konstruktoru předá všechny povinné volby jménem, žádnou jako
141
+ holé `process.env`. Seznam povinných voleb sada sama ověří proti konstruktoru (bez kterékoli z nich
142
+ konstrukce selže a hláška ji jmenuje), takže nová povinná volba sadu shodí dřív, než ji příklad tiše
143
+ vynechá. Příklady sada nespouští (připojují se k RabbitMQ a Redisu).
144
+
7
145
  ## [7.0.0] — 2026-09-27
8
146
 
9
147
  pin: `@onlineapps/mq-client-core` 4.2.0 → 5.0.0
package/README.md CHANGED
@@ -6,7 +6,7 @@ Uniform: [library/orchestration](../conn-orch-validator/manifests/library.manife
6
6
 
7
7
  Duty sections that apply:
8
8
 
9
- - `all`: L-MAIN, L-ENGINES, L-TESTS, L-TEST-SCRIPT, L-PACK-TESTS, L-PINS, L-NO-FILE-RANGE, L-CHANGELOG, L-README, L-README-REGION, L-CONSUMER
9
+ - `all`: L-MAIN, L-ENGINES, L-TESTS, L-TEST-SCRIPT, L-PACK-TESTS, L-PINS, L-NO-FILE-RANGE, L-CHANGELOG, L-PACK-CHANGELOG, L-README, L-README-REGION, L-CONSUMER
10
10
  <!-- END GENERATED: library-uniform -->
11
11
 
12
12
  # @onlineapps/conn-orch-registry
@@ -56,14 +56,17 @@ yarn add @onlineapps/conn-orch-registry
56
56
  // NOTE: Do NOT call dotenv.config() in libraries. Export environment variables explicitly.
57
57
 
58
58
  const { ServiceRegistryClient, EVENTS } = require('@onlineapps/conn-orch-registry');
59
+ // requireEnv(name, description), requirePositiveIntegerEnv(name, description) — see examples/env.js
59
60
 
60
61
  const client = new ServiceRegistryClient({
61
- amqpUrl: process.env.AMQP_URL,
62
+ amqpUrl: requireEnv('RABBITMQ_URL', 'RabbitMQ URL, amqp://user:pass@host:port'),
62
63
  serviceName: 'invoicing',
63
64
  version: '1.0.0',
64
65
  specificationEndpoint: '/api/v1/specification', // required — see § Configuration
65
- heartbeatInterval: 30000, // required — see § Configuration
66
- instanceId: process.env.SERVICE_INSTANCE_ID, // required — THIS running copy; see § Configuration
66
+ heartbeatInterval: requirePositiveIntegerEnv('BIZ_HEARTBEAT_INTERVAL_MS', 'milliseconds between heartbeats'), // required; see § Configuration
67
+ instanceId: requireEnv('SERVICE_INSTANCE_ID', 'id of THIS running copy, one value per process'), // required; see § Configuration
68
+ discoveryTimeoutMs: requirePositiveIntegerEnv('REGISTRY_DISCOVERY_TIMEOUT_MS', 'deadline in ms of one getService() read'), // required; see § Configuration
69
+ redis, // node-redis client getService() reads the projection with
67
70
  logger // required — see § Logger contract below
68
71
  });
69
72
 
@@ -98,7 +101,7 @@ process.on('SIGINT', async () => {
98
101
 
99
102
  ## 📄 Configuration
100
103
 
101
- Configuration can be provided via environment variables or constructor options:
104
+ Configuration is passed as constructor options; the client reads no environment variable:
102
105
 
103
106
  | Option | Description | Default |
104
107
  | ----------------------- | --------------------------------------------- | -------------------- |
@@ -108,6 +111,7 @@ Configuration can be provided via environment variables or constructor options:
108
111
  | `specificationEndpoint` | Path the `register`/`heartbeat` messages carry (**required**) | — |
109
112
  | `heartbeatInterval` | Milliseconds between heartbeats (**required**) | — |
110
113
  | `instanceId` | Non-empty string naming THIS running copy, one value per process (**required**) | — |
114
+ | `discoveryTimeoutMs` | Deadline (ms) of one `getService()` read of the registry projection (**required**) | — |
111
115
  | `apiQueue` | Queue name for heartbeat/API messages | `api_services_queuer` |
112
116
  | `registryQueue` | Queue name for registry requests | `registry.register` |
113
117
 
@@ -119,13 +123,24 @@ whenever the caller forgets the first (architecture-principles §3):
119
123
  ([registration-wire.md](/api/docs/biz/30-operations/registration-wire.md) §2.1) and the
120
124
  registry stores what it is sent, so a substituted path lands in `registry:services` for
121
125
  a service that never declared it;
122
- * the heartbeat cadence has ONE owner — `wrapper.registry.heartbeatInterval`, fed from
123
- `BIZ_HEARTBEAT_INTERVAL_MS` (owner confirmation
126
+ * the heartbeat cadence has ONE owner — the environment key `BIZ_HEARTBEAT_INTERVAL_MS`,
127
+ which `ServiceWrapper` reads and passes to this client; the config key
128
+ `wrapper.registry.heartbeatInterval` is retired and refused (owner confirmation
124
129
  [`biz-health-freshness.md`](/api/docs/governance/confirmations/biz-health-freshness.md)
125
130
  001 point 3) — and the freshness window the monitoring side computes is a multiple of it.
126
131
 
127
132
  Both are refused at construction, by value, naming the fix.
128
133
 
134
+ **`discoveryTimeoutMs` carries no default** (d.1093). Without a deadline `getService()`
135
+ waited for as long as Redis was down or half-open, and the workflow step waiting on it hung
136
+ until the broker expired its message. The value belongs to the platform env, which
137
+ `ServiceWrapper` reads and passes here. A lookup that does not end in time is refused with
138
+ `code: 'REGISTRY_DISCOVERY_TIMEOUT'`, and a client that already knows Redis is gone
139
+ (`isReady === false`) with `code: 'REGISTRY_DISCOVERY_UNAVAILABLE'` — both `type: 'TRANSIENT'`,
140
+ so the step is retried. The deadline is a race beside the command, not node-redis'
141
+ `AbortSignal`: aborting a command node-redis 4.7.1 has already sent corrupts its command
142
+ queue (see the CHANGELOG entry of d.1093).
143
+
129
144
  **`instanceId` carries no default either** (d.817, owner confirmation
130
145
  `registry-instance-identity` 001). The service name says which service, the instance id
131
146
  says which copy; `register`, `heartbeat` and `deregister` carry it as `instance_id`, and
@@ -1,58 +1,43 @@
1
1
  /**
2
2
  * basicUsage.js
3
3
  *
4
- * Example demonstrating full lifecycle of ServiceRegistryClient:
5
- * 1. Load environment variables
6
- * 2. Instantiate client
7
- * 3. Initialize (setup queues and start listening for responses)
8
- * 4. Register with the full service specification (endpoints + operations)
9
- * 5. Start heartbeat loop
10
- * 6. Graceful shutdown on process signals
4
+ * The lifecycle of ServiceRegistryClient:
5
+ * 1. read the settings from the environment (examples/env.js)
6
+ * 2. construct the client with every required option
7
+ * 3. init() — the response queues and their consumers
8
+ * 4. register() the service specification and branch on the registry's verdict
9
+ * 5. startHeartbeat() with its failure budget
10
+ * 6. on SIGINT/SIGTERM: deregister() this copy, then close()
11
11
  *
12
- * See: api/docs/biz/30-operations/registration-wire.md §2 MQ wire format
12
+ * Environment: RABBITMQ_URL, SERVICE_NAME, SERVICE_VERSION, SPECIFICATION_ENDPOINT,
13
+ * SERVICE_INSTANCE_ID, BIZ_HEARTBEAT_INTERVAL_MS, REGISTRY_DISCOVERY_TIMEOUT_MS.
14
+ *
15
+ * @see README.md § Configuration
16
+ * @see api/docs/biz/30-operations/registration-wire.md §2 MQ wire format
13
17
  */
14
18
 
15
- function requireEnv(name, description) {
16
- const value = process.env[name];
17
- if (!value || String(value).trim() === '') {
18
- throw new Error(`[conn-orch-registry][example] Missing environment variable - ${name} is required. ${description || ''}`);
19
- }
20
- return String(value).trim();
21
- }
22
-
23
19
  // Requiring the package by its own name does not resolve from inside the package
24
- // (no self-reference `exports` field), so the example loads the sources directly —
25
- // same as examples/event-consumer-example.js. Consumers require
26
- // '@onlineapps/conn-orch-registry'; see README § Quick Start.
20
+ // (no self-reference `exports` field), so the example loads the sources directly.
21
+ // Consumers require '@onlineapps/conn-orch-registry'; see README § Quick Start.
27
22
  const { ServiceRegistryClient, EVENTS } = require('../src/index');
23
+ const { requireEnv, requirePositiveIntegerEnv } = require('./env');
28
24
 
29
- // The logger is a hard requirement of every constructor in this package
30
- // (README § Logger contract). An example script has no service logger, so it passes
31
- // `console` explicitly — it implements info/warn/error/debug, and choosing it here is
32
- // the caller's decision, not a fallback inside the library.
25
+ // The logger is required by every constructor in this package (README § Logger
26
+ // contract). An example script has no service logger, so it passes `console`
27
+ // explicitly — it implements info/warn/error/debug.
33
28
  const registryClient = new ServiceRegistryClient({
34
29
  logger: console,
35
- amqpUrl: requireEnv('AMQP_URL', 'RabbitMQ URL (IPv4-only recommended: amqp://user:pass@127.0.0.1:PORT)'),
36
- serviceName: requireEnv('SERVICE_NAME', 'Service logical name'),
37
- version: requireEnv('SERVICE_VERSION', 'Service version (SemVer)'),
38
- // Both REQUIRED since d.278c — the client substitutes neither, so the example
39
- // demands them the same way it demands the URL and the service name.
40
- specificationEndpoint: requireEnv('SPECIFICATION_ENDPOINT', 'Path the register message carries (e.g. /api/v1/specification)'),
41
- heartbeatInterval: parseInt(requireEnv('HEARTBEAT_INTERVAL', 'Milliseconds between heartbeats'), 10),
42
- apiQueue: process.env.API_QUEUE,
43
- registryQueue: process.env.REGISTRY_QUEUE
30
+ amqpUrl: requireEnv('RABBITMQ_URL', 'RabbitMQ URL, amqp://user:pass@host:port'),
31
+ serviceName: requireEnv('SERVICE_NAME', 'service logical name'),
32
+ version: requireEnv('SERVICE_VERSION', 'service version, SemVer'),
33
+ specificationEndpoint: requireEnv('SPECIFICATION_ENDPOINT', 'path the register and heartbeat messages carry, e.g. /api/v1/specification'),
34
+ instanceId: requireEnv('SERVICE_INSTANCE_ID', 'id of THIS running copy, one value per process'),
35
+ heartbeatInterval: requirePositiveIntegerEnv('BIZ_HEARTBEAT_INTERVAL_MS', 'milliseconds between heartbeats'),
36
+ discoveryTimeoutMs: requirePositiveIntegerEnv('REGISTRY_DISCOVERY_TIMEOUT_MS', 'deadline in ms of one getService() read')
44
37
  });
45
38
 
46
39
  registryClient.on(EVENTS.HEARTBEAT_SENT, (msg) => {
47
- console.log(`[Heartbeat] Sent at ${msg.timestamp} for ${msg.serviceName}@${msg.version}`);
48
- });
49
-
50
- registryClient.on(EVENTS.REGISTER_CONFIRMED, (msg) => {
51
- console.log(`[Register] Confirmed for ${msg.serviceName || registryClient.serviceName}`);
52
- });
53
-
54
- registryClient.on(EVENTS.REGISTER_REJECTED, (msg) => {
55
- console.error('[Register] Rejected', msg);
40
+ console.log(`[Heartbeat] Sent at ${msg.timestamp} for ${msg.serviceName}@${msg.version} (${msg.instance_id})`);
56
41
  });
57
42
 
58
43
  registryClient.on(EVENTS.REVALIDATE, (payload) => {
@@ -68,8 +53,15 @@ registryClient.on(EVENTS.ERROR, (err) => {
68
53
  await registryClient.init();
69
54
  console.log('RegistryClient initialized: queues are ready.');
70
55
 
71
- await registryClient.register(await loadServiceSpec());
72
- console.log('Register message published.');
56
+ // register() resolves with the registry's verdict; `errorCode` is the machine
57
+ // reason of a refusal (registration-wire.md §8).
58
+ const verdict = await registryClient.register(serviceSpecification(registryClient.version));
59
+ if (verdict.success !== true) {
60
+ console.error(`[Register] Refused: ${verdict.errorCode} - ${verdict.message}`);
61
+ await registryClient.close();
62
+ process.exit(1);
63
+ }
64
+ console.log(`[Register] Confirmed for ${verdict.serviceName}@${verdict.version}`);
73
65
 
74
66
  registryClient.startHeartbeat({
75
67
  maxFailures: 5,
@@ -82,53 +74,55 @@ registryClient.on(EVENTS.ERROR, (err) => {
82
74
 
83
75
  const shutdown = async () => {
84
76
  console.log('Shutting down RegistryClient...');
77
+ // deregister() stops the heartbeat first; the registry deletes only the
78
+ // registration whose instance_id matches this copy.
79
+ await registryClient.deregister();
85
80
  await registryClient.close();
86
81
  process.exit(0);
87
82
  };
88
83
  process.on('SIGINT', shutdown);
89
84
  process.on('SIGTERM', shutdown);
90
85
  } catch (err) {
91
- console.error('Failed to initialize RegistryClient', err);
86
+ console.error('Failed to run RegistryClient', err);
92
87
  process.exit(1);
93
88
  }
94
89
  })();
95
90
 
96
91
  /**
97
- * Helper: assemble the service specification published in the register message.
98
- * In a real service this is typically loaded from config/service/operations.json
99
- * plus the endpoint manifest.
92
+ * The service specification the register message carries. A real service sends the
93
+ * `operations` map of its config/service/operations.json; every operation is a v3
94
+ * declaration.
100
95
  *
101
- * @see api/docs/biz/30-operations/registration-wire.md §3 Operation schema
96
+ * @param {string} version - the service version the metadata repeats
97
+ * @see api/docs/biz/30-operations/schema-v3.md § Per-operation keys
98
+ * @see api/docs/biz/30-operations/registration-wire.md §2.1
102
99
  */
103
- async function loadServiceSpec() {
100
+ function serviceSpecification(version) {
104
101
  return {
105
- endpoints: [
106
- { path: '/invoices', method: 'POST' },
107
- { path: '/invoices/:id', method: 'GET' }
108
- ],
102
+ endpoints: [],
109
103
  operations: {
110
104
  'create-invoice': {
111
105
  description: 'Create a new invoice',
112
- endpoint: '/invoices',
113
- method: 'POST',
106
+ handler: 'handlers/invoices#createInvoice',
107
+ bundle_scope: 'workspace',
114
108
  mutates: true,
115
109
  resource_type: 'invoice',
116
110
  minRole: 'EDITOR',
117
- input: {},
118
- output: {}
111
+ input: { type: 'object' },
112
+ output: { type: 'object' }
119
113
  },
120
114
  'get-invoice': {
121
- description: 'Retrieve invoice by ID',
122
- endpoint: '/invoices/:id',
123
- method: 'GET',
115
+ description: 'Retrieve an invoice by id',
116
+ handler: 'handlers/invoices#getInvoice',
117
+ bundle_scope: 'workspace',
124
118
  mutates: false,
125
- resource_type: null,
119
+ resource_type: 'invoice',
126
120
  minRole: 'VIEWER',
127
- input: {},
128
- output: {}
121
+ input: { type: 'object' },
122
+ output: { type: 'object' }
129
123
  }
130
124
  },
131
- metadata: {},
125
+ metadata: { version, description: 'Invoice example' },
132
126
  health: '/health'
133
127
  };
134
128
  }
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * env.js — how the examples read their settings: by key name, and a key that is
5
+ * missing or malformed stops the example naming the key and the fix. No default
6
+ * stands in for an unset key (architecture-principles.md §3, §5).
7
+ *
8
+ * The key names are the platform's own (`config/env-templates/shared.env`), so an
9
+ * example runs with the same environment a biz service gets.
10
+ */
11
+
12
+ /**
13
+ * @param {string} name - environment key
14
+ * @param {string} description - what the value is, for the error message
15
+ * @returns {string} the trimmed value
16
+ * @throws {Error} when the key is unset or blank
17
+ */
18
+ function requireEnv(name, description) {
19
+ const value = process.env[name];
20
+ if (value === undefined) {
21
+ throw new Error(
22
+ `[conn-orch-registry][example] Missing environment variable - ${name} is required (${description}). `
23
+ + `Fix: export ${name} before running the example.`
24
+ );
25
+ }
26
+ if (value.trim() === '') {
27
+ throw new Error(
28
+ `[conn-orch-registry][example] Empty environment variable - ${name} is set but blank (${description}). `
29
+ + `Fix: export ${name} with a value.`
30
+ );
31
+ }
32
+ return value.trim();
33
+ }
34
+
35
+ /**
36
+ * @param {string} name - environment key
37
+ * @param {string} description - what the value is, for the error message
38
+ * @returns {number} a positive whole number
39
+ * @throws {Error} when the key is unset, blank, not a whole number or not positive
40
+ */
41
+ function requirePositiveIntegerEnv(name, description) {
42
+ const raw = requireEnv(name, description);
43
+ const value = Number(raw);
44
+ if (!Number.isInteger(value)) {
45
+ throw new Error(
46
+ `[conn-orch-registry][example] Invalid environment variable - ${name} is ${JSON.stringify(raw)}. `
47
+ + `Expected: a positive whole number (${description}). Fix: export ${name} as a whole number.`
48
+ );
49
+ }
50
+ if (value <= 0) {
51
+ throw new Error(
52
+ `[conn-orch-registry][example] Invalid environment variable - ${name} is ${value}. `
53
+ + `Expected: a positive whole number (${description}). Fix: export ${name} above zero.`
54
+ );
55
+ }
56
+ return value;
57
+ }
58
+
59
+ module.exports = { requireEnv, requirePositiveIntegerEnv };
@@ -1,81 +1,85 @@
1
1
  /**
2
- * Example: Using ServiceRegistryClient with event consumption
2
+ * event-consumer-example.js
3
3
  *
4
- * Demonstrates opt-in subscription to registry change events. The events are an
5
- * invalidation hint ("something changed"), not a read path: to read another
6
- * service's spec, use createRegistryReader from @onlineapps/service-common.
4
+ * What this example shows: a client that registers, beats, and opts in to the
5
+ * registry change events through subscribeToChanges(), with listeners for them.
7
6
  *
8
- * @see api/docs/biz/30-operations/registration-wire.md §5, §7
7
+ * The consumer runs in index-only mode: no storageConfig, because
8
+ * subscribeToChanges() (src/registryClient.js) builds it without a StorageConnector
9
+ * and the RegistryEventConsumer constructor refuses a complete storageConfig
10
+ * without one. Redis persists the local service index.
11
+ *
12
+ * Environment: RABBITMQ_URL, REDIS_URL, SERVICE_NAME, SERVICE_VERSION,
13
+ * SPECIFICATION_ENDPOINT, SERVICE_INSTANCE_ID, BIZ_HEARTBEAT_INTERVAL_MS,
14
+ * REGISTRY_DISCOVERY_TIMEOUT_MS.
15
+ *
16
+ * @see README.md § Configuration
17
+ * @see api/docs/biz/30-operations/registration-wire.md §7
9
18
  */
10
19
 
20
+ const { createClient } = require('redis');
11
21
  const { ServiceRegistryClient } = require('../src/index');
12
- const Redis = require('ioredis');
22
+ const { requireEnv, requirePositiveIntegerEnv } = require('./env');
13
23
 
14
24
  async function main() {
15
- // Optional Redis for index persistence
16
- const redis = new Redis({
17
- host: process.env.REDIS_HOST || 'localhost',
18
- port: process.env.REDIS_PORT || 6379
25
+ // node-redis, the client getService() reads the registry projection with.
26
+ const redis = createClient({
27
+ url: requireEnv('REDIS_URL', 'Redis URL, redis://:password@host:port'),
28
+ disableOfflineQueue: true
19
29
  });
30
+ await redis.connect();
20
31
 
21
- // Initialize client with storage configuration
22
- // The logger is a hard requirement of every constructor in this package
23
- // (README § Logger contract). An example script has no service logger, so it passes
24
- // `console` explicitly — it implements info/warn/error/debug, and choosing it here is
25
- // the caller's decision, not a fallback inside the library.
32
+ // The logger is required by every constructor in this package (README § Logger
33
+ // contract). An example script has no service logger, so it passes `console`
34
+ // explicitly — it implements info/warn/error/debug.
26
35
  const client = new ServiceRegistryClient({
27
36
  logger: console,
28
- amqpUrl: process.env.AMQP_URL || 'amqp://localhost',
29
- serviceName: 'example-service',
30
- version: '1.0.0',
31
- redis: redis,
32
- storageConfig: {
33
- endPoint: process.env.MINIO_ENDPOINT || 'localhost',
34
- port: parseInt(process.env.MINIO_PORT || 9000),
35
- useSSL: process.env.MINIO_USE_SSL === 'true',
36
- accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
37
- secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin'
38
- }
37
+ amqpUrl: requireEnv('RABBITMQ_URL', 'RabbitMQ URL, amqp://user:pass@host:port'),
38
+ serviceName: requireEnv('SERVICE_NAME', 'service logical name'),
39
+ version: requireEnv('SERVICE_VERSION', 'service version, SemVer'),
40
+ specificationEndpoint: requireEnv('SPECIFICATION_ENDPOINT', 'path the register and heartbeat messages carry, e.g. /api/v1/specification'),
41
+ instanceId: requireEnv('SERVICE_INSTANCE_ID', 'id of THIS running copy, one value per process'),
42
+ heartbeatInterval: requirePositiveIntegerEnv('BIZ_HEARTBEAT_INTERVAL_MS', 'milliseconds between heartbeats'),
43
+ discoveryTimeoutMs: requirePositiveIntegerEnv('REGISTRY_DISCOVERY_TIMEOUT_MS', 'deadline in ms of one getService() read'),
44
+ redis
39
45
  });
40
46
 
41
- // Initialize connection
42
47
  await client.init();
43
48
 
44
- // Register ourselves with the registry. The full spec (endpoints +
45
- // operations) is published as part of the register message — see
46
- // api/docs/biz/30-operations/registration-wire.md §2.
47
- await client.register({
48
- endpoints: [{ path: '/hello', method: 'GET' }],
49
+ // register() resolves with the registry's verdict (registration-wire.md §2.4, §8).
50
+ const verdict = await client.register({
51
+ endpoints: [],
49
52
  operations: {
50
53
  'say-hello': {
51
54
  description: 'Say hello',
52
- endpoint: '/hello',
53
- method: 'GET',
55
+ handler: 'handlers/hello#sayHello',
56
+ bundle_scope: 'platform',
54
57
  mutates: false,
55
58
  resource_type: null,
56
59
  minRole: 'VIEWER',
57
- input: {},
58
- output: {}
60
+ input: { type: 'object' },
61
+ output: { type: 'object' }
59
62
  }
60
63
  },
61
- metadata: {},
64
+ metadata: { version: client.version, description: 'Event consumer example' },
62
65
  health: '/health'
63
66
  });
67
+ if (verdict.success !== true) {
68
+ throw new Error(`[conn-orch-registry][example] Registration refused - ${verdict.errorCode}: ${verdict.message}`);
69
+ }
64
70
 
65
- // Start heartbeat
66
71
  client.startHeartbeat({
67
72
  maxFailures: 5,
68
73
  onFatal: (err) => {
69
74
  console.error(err.message);
70
- process.exit(1);
75
+ process.exit(1); // the restart policy boots it again and it re-registers
71
76
  }
72
77
  });
73
78
 
74
- // OPT-IN: Subscribe to registry changes
79
+ // OPT-IN: subscribe to registry changes.
75
80
  console.log('Subscribing to registry changes...');
76
81
  await client.subscribeToChanges();
77
82
 
78
- // Listen for events
79
83
  client.on('serviceUpdated', ({ service, fingerprint, version }) => {
80
84
  console.log(`Service updated: ${service} v${version} (${fingerprint})`);
81
85
  });
@@ -88,14 +92,22 @@ async function main() {
88
92
  console.log(`Received snapshot with ${count} services`);
89
93
  });
90
94
 
91
- // Graceful shutdown
95
+ client.on('error', (err) => {
96
+ console.error('[RegistryClient Error]', err);
97
+ });
98
+
92
99
  process.on('SIGINT', async () => {
93
100
  console.log('\nShutting down...');
101
+ // deregister() stops the heartbeat first; the registry deletes only the
102
+ // registration whose instance_id matches this copy.
103
+ await client.deregister();
94
104
  await client.close();
95
105
  await redis.quit();
96
106
  process.exit(0);
97
107
  });
98
108
  }
99
109
 
100
- // Run example
101
- main().catch(console.error);
110
+ main().catch((err) => {
111
+ console.error(err);
112
+ process.exit(1);
113
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-orch-registry",
3
- "version": "7.0.0",
3
+ "version": "9.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": [
@@ -38,15 +38,15 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@onlineapps/logger-contract": "2.0.0",
42
- "@onlineapps/mq-client-core": "5.0.0",
41
+ "@onlineapps/logger-contract": "2.1.0",
42
+ "@onlineapps/mq-client-core": "6.0.0",
43
43
  "uuid": "^9.0.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "amqplib": "0.10.9",
47
- "ioredis": "^5.8.0",
48
47
  "jest": "^29.6.1",
49
- "jsdoc-to-markdown": "^8.0.0"
48
+ "jsdoc-to-markdown": "^8.0.0",
49
+ "redis": "4.7.1"
50
50
  },
51
51
  "engines": {
52
52
  "node": ">=24.0.0 <25"
@@ -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).
@@ -114,12 +128,19 @@ class ServiceRegistryClient extends EventEmitter {
114
128
  * @param {number} opts.heartbeatInterval - Milliseconds between heartbeats. REQUIRED: the
115
129
  * cadence has ONE owner and it is not a library default (owner confirmation
116
130
  * docs/governance/confirmations/biz-health-freshness.md 001 point 3); ServiceWrapper
117
- * 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.
118
133
  * @param {string} [opts.apiQueue='api_services_queuer'] - Queue name for heartbeat and API traffic
119
134
  * @param {string} [opts.registryQueue='registry.register'] - Queue name for registry messages
120
135
  * @param {Object} [opts.validationProof=null] - Validation proof object {hash, data} (injected from outside)
121
136
  * @param {number} [opts.registrationTimeoutMs] - How long register() waits for the registry response
122
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).
123
144
  * @param {Object} opts.logger - Logger with info/warn/error/debug. Required and validated here:
124
145
  * registration, heartbeat and revalidation events are what this client exists to report, and
125
146
  * until 2026-09-05 they went to stdout through bare printing while this parameter was accepted
@@ -127,7 +148,7 @@ class ServiceRegistryClient extends EventEmitter {
127
148
  * docs/governance/confirmations/connector-logger-contract.md 001 + 002.
128
149
  */
129
150
  constructor({ amqpUrl, serviceName, version, specificationEndpoint,
130
- heartbeatInterval, instanceId, apiQueue = 'api_services_queuer', registryQueue = 'registry.register',
151
+ heartbeatInterval, discoveryTimeoutMs, instanceId, apiQueue = 'api_services_queuer', registryQueue = 'registry.register',
131
152
  redis = null, storageConfig = {}, validationProof = null, logger,
132
153
  registryKeyPrefix = DEFAULTS.registryKeyPrefix,
133
154
  registrationTimeoutMs = DEFAULTS.registrationTimeoutMs }) {
@@ -194,7 +215,8 @@ class ServiceRegistryClient extends EventEmitter {
194
215
  `[RegistryClient] ${serviceName}: Missing constructor option - heartbeatInterval is required. `
195
216
  + 'Expected: the cadence its one owner decided (docs/governance/confirmations/biz-health-freshness.md 001 '
196
217
  + 'point 3), passed as a positive number of milliseconds. '
197
- + '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).'
198
220
  );
199
221
  }
200
222
  if (typeof heartbeatInterval !== 'number' || !Number.isFinite(heartbeatInterval) || heartbeatInterval <= 0) {
@@ -203,8 +225,23 @@ class ServiceRegistryClient extends EventEmitter {
203
225
  + `got ${JSON.stringify(heartbeatInterval)}. Fix: pass the cadence as a number; a string is not parsed here.`
204
226
  );
205
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
+ }
206
242
  this.specificationEndpoint = specificationEndpoint;
207
243
  this.heartbeatInterval = heartbeatInterval;
244
+ this.discoveryTimeoutMs = discoveryTimeoutMs;
208
245
  this.apiQueue = apiQueue;
209
246
  this.registryQueue = registryQueue;
210
247
  this.queueManager = new QueueManager(amqpUrl, serviceName, this.logger);
@@ -816,10 +853,18 @@ class ServiceRegistryClient extends EventEmitter {
816
853
  * where it published it, and that the heartbeat timer is stopped. A publish that
817
854
  * fails REJECTS; reaching the return means the broker took the message.
818
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
+ *
819
862
  * @returns {Promise<{requestId: string, serviceName: string, version: string,
820
863
  * publishedTo: string, heartbeatStopped: boolean, publishedAt: string}>}
821
864
  */
822
865
  async deregister() {
866
+ this.stopHeartbeat();
867
+
823
868
  const msg = {
824
869
  id: uuidv4(),
825
870
  type: 'deregister',
@@ -846,9 +891,6 @@ class ServiceRegistryClient extends EventEmitter {
846
891
 
847
892
  this.emit('deregisterSent', msg);
848
893
 
849
- // Stop heartbeat if running
850
- this.stopHeartbeat();
851
-
852
894
  return {
853
895
  requestId: msg.id,
854
896
  serviceName: this.serviceName,
@@ -907,9 +949,10 @@ class ServiceRegistryClient extends EventEmitter {
907
949
  *
908
950
  * A heartbeat loop with no budget is worse than none: the beats stop reaching
909
951
  * the registry, the loop logs and reschedules forever, and the service keeps
910
- * running while every consumer of `registry:services` reads it as stale. Until
911
- * 2026-09-15 that budget lived in `@onlineapps/service-wrapper`, in a second
912
- * 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
913
956
  * (`.claude/rules/change-discipline.md` § One rail per concern).
914
957
  *
915
958
  * The cadence is NOT an argument here. It has one owner and it is the
@@ -1126,7 +1169,38 @@ class ServiceRegistryClient extends EventEmitter {
1126
1169
  }
1127
1170
 
1128
1171
  const key = `${this.registryKeyPrefix}services`;
1129
- 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
+ }
1130
1204
  if (!raw) {
1131
1205
  return null;
1132
1206
  }
@@ -1150,6 +1224,49 @@ class ServiceRegistryClient extends EventEmitter {
1150
1224
  };
1151
1225
  }
1152
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
+
1153
1270
  /**
1154
1271
  * Releases resources: stops heartbeat and closes connection.
1155
1272
  * @returns {Promise<void>}