@onlineapps/conn-orch-registry 7.0.0 → 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.
- package/API.md +25 -4
- package/CHANGELOG.md +83 -0
- package/README.md +21 -6
- package/examples/basicUsage.js +58 -64
- package/examples/env.js +59 -0
- package/examples/event-consumer-example.js +56 -44
- package/package.json +4 -4
- package/src/registryClient.js +127 -10
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.<void></code>
|
|
406
406
|
* [.getService(serviceName)](#module_@onlineapps/conn-orch-registry/src/registryClient..ServiceRegistryClient+getService) ⇒ <code>Promise.<(ServiceSummary\|null)></code>
|
|
407
407
|
* [.close()](#module_@onlineapps/conn-orch-registry/src/registryClient..ServiceRegistryClient+close) ⇒ <code>Promise.<void></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
|
|
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>"'api_services_queuer'"</code> | Queue name for heartbeat and API traffic |
|
|
443
444
|
| [opts.registryQueue] | <code>string</code> | <code>"'registry.register'"</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.
|
|
522
|
-
|
|
523
|
-
loop
|
|
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,89 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [8.0.0] — 2026-10-03
|
|
8
|
+
|
|
9
|
+
pin: `@onlineapps/mq-client-core` 5.0.0 → 5.1.0
|
|
10
|
+
|
|
11
|
+
### Changed — BREAKING: `discoveryTimeoutMs` je povinný, `getService()` má termín a typuje selhání (d.1093)
|
|
12
|
+
|
|
13
|
+
- Nový povinný parametr konstruktoru `discoveryTimeoutMs` (kladné číslo ms, bez výchozí hodnoty;
|
|
14
|
+
chybějící i neplatná hodnota se odmítne při konstrukci). Hodnota patří platformnímu env, čte ji
|
|
15
|
+
`@onlineapps/service-wrapper`.
|
|
16
|
+
- `getService()` dřív při ležícím nebo půlotevřeném Redisu čekal bez termínu (node-redis drží příkaz
|
|
17
|
+
v offline frontě nebo čeká na odpověď, d.1088, d.1090) a krok workflow visel, dokud broker po
|
|
18
|
+
`consumer_timeout` nezavřel kanál a zpráva neexpirovala do DLQ. Nově:
|
|
19
|
+
- dotaz, který neskončí do `discoveryTimeoutMs` → `Error` s `code: 'REGISTRY_DISCOVERY_TIMEOUT'`,
|
|
20
|
+
`type: 'TRANSIENT'`, `serviceName`, bez `cause` (nic pod ním neselhalo);
|
|
21
|
+
- odmítnutí, když klient hlásí `isReady === false` (offline s `disableOfflineQueue`, socket zavřený
|
|
22
|
+
pod příkazem) → `code: 'REGISTRY_DISCOVERY_UNAVAILABLE'`, `type: 'TRANSIENT'`, původní chyba v `cause`;
|
|
23
|
+
- jiné odmítnutí (chybová odpověď připojeného Redisu) projde beze změny.
|
|
24
|
+
Typuje se tady, kde je selhání známé (vzor mq-client-core d.683); `@onlineapps/cookbook-router`
|
|
25
|
+
převezme `type` z `cause`, klasifikátor L1 Redis nezná.
|
|
26
|
+
- **Proč termín závodem a ne `AbortSignal`:** node-redis 4.7.1 (`@redis/client` 1.6.1) nabízí
|
|
27
|
+
`commandOptions({ signal })`, ale abort příkazu, který už odešel na server, odebere jeho uzel z fronty
|
|
28
|
+
podruhé (posluchač abortu zůstane po odeslání připojený), délka fronty klesne pod nulu a další
|
|
29
|
+
`flushAll` — chyba socketu při `disableOfflineQueue`, nebo `disconnect()` — hodí
|
|
30
|
+
`TypeError: Cannot read properties of undefined (reading 'reject')`; z handleru chyby socketu je to
|
|
31
|
+
nezachycená výjimka, která ukončí proces (změřeno d.1093 proti falešnému RESP serveru). Závod příkaz
|
|
32
|
+
nechá ve frontě: pozdní odpověď se spáruje správně a jeho pozdní odmítnutí se zachytí a zaloguje
|
|
33
|
+
(`debug`), nikdy nezůstane neošetřené.
|
|
34
|
+
- devDependency `redis` 4.7.1 (přesně) pro integrační sadu `tests/integration/discoveryDeadline.integration.test.js`
|
|
35
|
+
(živý node-redis proti falešnému RESP serveru; dev Redis se nedotýká).
|
|
36
|
+
|
|
37
|
+
### Fixed — `deregister()` zastaví heartbeat PŘED publikací, ne až po ní (d.1083)
|
|
38
|
+
|
|
39
|
+
Smyčka se dřív zastavovala až po úspěšné publikaci zprávy `deregister`; když publikace selhala,
|
|
40
|
+
volání odmítlo, ale smyčka tepala dál za kopii, která se odhlašuje. `stopHeartbeat()` je teď první
|
|
41
|
+
krok `deregister()`, ať publikace dopadne jakkoli. Úspěšná deregistrace se chová stejně jako dřív
|
|
42
|
+
(zpráva odejde, `heartbeatStopped: true`). Sada `tests/unit/registryClient.deregisterStopsHeartbeat.test.js`.
|
|
43
|
+
`@onlineapps/service-wrapper` zastavuje smyčku v `shutdown()` sám před `unregister()` (d.1076), pro něj
|
|
44
|
+
je změna neutrální.
|
|
45
|
+
|
|
46
|
+
### Documentation — docblocky `startHeartbeat` a konstruktoru (`heartbeatInterval`) říkají pravdu (d.1083)
|
|
47
|
+
|
|
48
|
+
`startHeartbeat`: rozpočet selhání žije jen tady, wrapper smyčku zapíná a vlastní nemá (odkaz na
|
|
49
|
+
service-wrapper d.1076 a jeho CHANGELOG místo ručně psaného data). `opts.heartbeatInterval`: wrapper
|
|
50
|
+
kadenci čte z env `BIZ_HEARTBEAT_INTERVAL_MS`; klíč `wrapper.registry.heartbeatInterval` je zrušený
|
|
51
|
+
a wrapper ho odmítá. `API.md` přegenerován (`npm run docs`). Tutéž pravdu teď říká i `Fix:` hlášky
|
|
52
|
+
konstruktoru při chybějícím `heartbeatInterval` (dřív radila právě zrušený klíč; test
|
|
53
|
+
`registryClient.registryAnswer.test.js` tvrdí celou `Fix:` větu), `README.md` (vlastník kadence)
|
|
54
|
+
a hlavička sady `registryClient.registryAnswer.test.js`.
|
|
55
|
+
|
|
56
|
+
### Documentation — `examples/` srovnané na kontrakt 8.0.0 (d.1187)
|
|
57
|
+
|
|
58
|
+
Oba příklady stavěly klienta, kterého konstruktor odmítne: chyběl `instanceId` (d.817) a
|
|
59
|
+
`discoveryTimeoutMs` (d.1093), `event-consumer-example.js` navíc `specificationEndpoint` a
|
|
60
|
+
`heartbeatInterval` (d.278c). Registrovaly operace ve tvaru v2 (`endpoint`, `method`, neprázdné
|
|
61
|
+
`endpoints`), který registr odmítá (`schema-v3.md` § Keys the v3 schema rejects). Nově:
|
|
62
|
+
- každá povinná volba je zapsaná výslovně; nastavení se čte z env podle jména (klíče platformy:
|
|
63
|
+
`RABBITMQ_URL`, `REDIS_URL`, `SERVICE_INSTANCE_ID`, `BIZ_HEARTBEAT_INTERVAL_MS`,
|
|
64
|
+
`REGISTRY_DISCOVERY_TIMEOUT_MS`, …) přes `examples/env.js`, který chybějící, prázdný nebo
|
|
65
|
+
nečíselný klíč odmítne s `Fix:`; žádný `||` fallback (dřív `REDIS_HOST || 'localhost'`,
|
|
66
|
+
`MINIO_ACCESS_KEY || 'minioadmin'` aj.);
|
|
67
|
+
- operace ve tvaru v3 (`handler`, `bundle_scope`, `mutates`, `resource_type`, `minRole`, `input`,
|
|
68
|
+
`output`), `endpoints: []`, `metadata: { version, description }`;
|
|
69
|
+
- `register()` se větví podle verdiktu registru (`success`, `errorCode`), ne podle událostí
|
|
70
|
+
`registerConfirmed`/`registerRejected`, které klient nevysílá; ukončení volá `deregister()` před
|
|
71
|
+
`close()`;
|
|
72
|
+
- `event-consumer-example.js` běží v režimu jen-index bez `storageConfig`: `subscribeToChanges()`
|
|
73
|
+
staví konzumenta bez `StorageConnector` a úplný `storageConfig` by konstruktor
|
|
74
|
+
`RegistryEventConsumer` odmítl; Redis je node-redis (`createClient`), tentýž klient, kterým
|
|
75
|
+
`getService()` čte projekci.
|
|
76
|
+
- devDependency `ioredis` odebrána: jejím jediným čtenářem byl `event-consumer-example.js`.
|
|
77
|
+
- `README.md` § Quick Start čte `instanceId` přes `requireEnv` jako příklady, ne jako holé
|
|
78
|
+
`process.env.SERVICE_INSTANCE_ID` (d.1187b); `amqpUrl` přes `requireEnv('RABBITMQ_URL', …)` místo
|
|
79
|
+
holého `process.env.AMQP_URL`, `heartbeatInterval` a `discoveryTimeoutMs` z klíčů platformy
|
|
80
|
+
`BIZ_HEARTBEAT_INTERVAL_MS` a `REGISTRY_DISCOVERY_TIMEOUT_MS` místo čísel napsaných v kódu;
|
|
81
|
+
§ Configuration už netvrdí, že se konfigurace dá předat proměnnými prostředí (klient žádnou
|
|
82
|
+
nečte) (d.1187c).
|
|
83
|
+
|
|
84
|
+
Nová sada `tests/unit/examples.contract.test.js`: každý příklad projde `node --check`, nenese `||`
|
|
85
|
+
ani `??`, nedeklaruje klíč operace v2 a konstruktoru předá všechny povinné volby jménem, žádnou jako
|
|
86
|
+
holé `process.env`. Seznam povinných voleb sada sama ověří proti konstruktoru (bez kterékoli z nich
|
|
87
|
+
konstrukce selže a hláška ji jmenuje), takže nová povinná volba sadu shodí dřív, než ji příklad tiše
|
|
88
|
+
vynechá. Příklady sada nespouští (připojují se k RabbitMQ a Redisu).
|
|
89
|
+
|
|
7
90
|
## [7.0.0] — 2026-09-27
|
|
8
91
|
|
|
9
92
|
pin: `@onlineapps/mq-client-core` 4.2.0 → 5.0.0
|
package/README.md
CHANGED
|
@@ -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:
|
|
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:
|
|
66
|
-
instanceId:
|
|
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
|
|
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 — `
|
|
123
|
-
`
|
|
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
|
package/examples/basicUsage.js
CHANGED
|
@@ -1,58 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* basicUsage.js
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1.
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
8
|
-
* 4.
|
|
9
|
-
* 5.
|
|
10
|
-
* 6.
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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('
|
|
36
|
-
serviceName: requireEnv('SERVICE_NAME', '
|
|
37
|
-
version: requireEnv('SERVICE_VERSION', '
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
86
|
+
console.error('Failed to run RegistryClient', err);
|
|
92
87
|
process.exit(1);
|
|
93
88
|
}
|
|
94
89
|
})();
|
|
95
90
|
|
|
96
91
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
115
|
+
description: 'Retrieve an invoice by id',
|
|
116
|
+
handler: 'handlers/invoices#getInvoice',
|
|
117
|
+
bundle_scope: 'workspace',
|
|
124
118
|
mutates: false,
|
|
125
|
-
resource_type:
|
|
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
|
}
|
package/examples/env.js
ADDED
|
@@ -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
|
-
*
|
|
2
|
+
* event-consumer-example.js
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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
|
|
22
|
+
const { requireEnv, requirePositiveIntegerEnv } = require('./env');
|
|
13
23
|
|
|
14
24
|
async function main() {
|
|
15
|
-
//
|
|
16
|
-
const redis =
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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:
|
|
29
|
-
serviceName: '
|
|
30
|
-
version: '
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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": "
|
|
3
|
+
"version": "8.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": [
|
|
@@ -39,14 +39,14 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@onlineapps/logger-contract": "2.0.0",
|
|
42
|
-
"@onlineapps/mq-client-core": "5.
|
|
42
|
+
"@onlineapps/mq-client-core": "5.1.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"
|
package/src/registryClient.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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.
|
|
911
|
-
*
|
|
912
|
-
* loop
|
|
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
|
-
|
|
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>}
|