@onlineapps/cookbook-router 3.0.1 → 5.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 +297 -0
- package/CHANGELOG.md +360 -0
- package/README.md +53 -12
- package/package.json +13 -5
- package/src/index.js +4 -3
- package/src/options.js +79 -0
- package/src/queueManager.js +16 -13
- package/src/router.js +50 -12
- package/src/serviceDiscovery.js +78 -31
package/API.md
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
## Modules
|
|
2
|
+
|
|
3
|
+
<dl>
|
|
4
|
+
<dt><a href="#@onlineapps/cookbook-router
|
|
5
|
+
|
|
6
|
+
Message routing for cookbook workflows_ given a service name and a workflow
|
|
7
|
+
message, publish it to that services workflow queue.
|
|
8
|
+
|
|
9
|
+
The package does NOT execute cookbooks, drive flow control, retry, complete
|
|
10
|
+
or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
|
|
11
|
+
`api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
|
|
12
|
+
is also the only caller_ its constructor takes `createRouter` (through
|
|
13
|
+
`cookbook.createRouter`), and `processWorkflowMessage` and the method that
|
|
14
|
+
hands the next steps message to the router call `routeToService` on the
|
|
15
|
+
result. The second routing rail, the retry rail and the
|
|
16
|
+
queue/registry administration this package used to carry had zero callers
|
|
17
|
+
outside it and were removed on 2026-09-02.module_">@onlineapps/cookbook-router
|
|
18
|
+
|
|
19
|
+
Message routing for cookbook workflows: given a service name and a workflow
|
|
20
|
+
message, publish it to that services workflow queue.
|
|
21
|
+
|
|
22
|
+
The package does NOT execute cookbooks, drive flow control, retry, complete
|
|
23
|
+
or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
|
|
24
|
+
`api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
|
|
25
|
+
is also the only caller: its constructor takes `createRouter` (through
|
|
26
|
+
`cookbook.createRouter`), and `processWorkflowMessage` and the method that
|
|
27
|
+
hands the next steps message to the router call `routeToService` on the
|
|
28
|
+
result. The second routing rail, the retry rail and the
|
|
29
|
+
queue/registry administration this package used to carry had zero callers
|
|
30
|
+
outside it and were removed on 2026-09-02.</a></dt>
|
|
31
|
+
<dd></dd>
|
|
32
|
+
</dl>
|
|
33
|
+
|
|
34
|
+
## Classes
|
|
35
|
+
|
|
36
|
+
<dl>
|
|
37
|
+
<dt><a href="#QueueManager">QueueManager</a></dt>
|
|
38
|
+
<dd><p>QueueManager - Queue operations and management</p>
|
|
39
|
+
<p>A QUEUE and a MESSAGE are two different things, and neither one's defaults
|
|
40
|
+
belong to the other. Until d.396d this class held ONE object for both —
|
|
41
|
+
<code>defaultOptions: { durable: true, persistent: true }</code> — and spread it into the
|
|
42
|
+
publish AND into the queue declaration, so each call site was handed a key it
|
|
43
|
+
has no use for. Measured in both directions on the unit tier:</p>
|
|
44
|
+
<pre><code>assertQueue('split.queue', { durable: true, persistent: true })
|
|
45
|
+
publish('control.publish', …, { durable: true, persistent: true })
|
|
46
|
+
</code></pre>
|
|
47
|
+
<p><code>persistent</code> is a message property (amqplib <code>Options.Publish</code>) and means
|
|
48
|
+
nothing to a queue; <code>durable</code> is a queue property and means nothing to a
|
|
49
|
+
message. Nothing objected, because <code>@onlineapps/mq-client-core</code> read the two
|
|
50
|
+
keys it knew and dropped the rest in silence — and since d.396c it no longer
|
|
51
|
+
does: <code>assertQueue()</code> refuses an option it does not read, by name. What was an
|
|
52
|
+
invisible confusion becomes a throw the moment the pin moves, and the cure is
|
|
53
|
+
not to catch it but to stop sending a message property to a queue.</p>
|
|
54
|
+
<p>So there are two defaults with two owners, <code>queueOptions</code> and <code>publishOptions</code>,
|
|
55
|
+
and each call site merges only its own.</p>
|
|
56
|
+
</dd>
|
|
57
|
+
<dt><a href="#CookbookRouter">CookbookRouter</a></dt>
|
|
58
|
+
<dd><p>CookbookRouter - routes a workflow message to a service queue.</p>
|
|
59
|
+
<p>ONE responsibility, ONE method: <code>routeToService</code>. Execution, flow control,
|
|
60
|
+
retry, DLQ and completion belong to <code>WorkflowOrchestrator</code>
|
|
61
|
+
(confirmation <code>api/docs/governance/confirmations/cookbook-execution-owner.md</code>
|
|
62
|
+
001), which is also this class's only caller: <code>processWorkflowMessage()</code> and
|
|
63
|
+
the method that hands the next step's message to the router.</p>
|
|
64
|
+
</dd>
|
|
65
|
+
<dt><a href="#ServiceDiscovery">ServiceDiscovery</a></dt>
|
|
66
|
+
<dd><p>ServiceDiscovery - Service discovery and health checking</p>
|
|
67
|
+
</dd>
|
|
68
|
+
</dl>
|
|
69
|
+
|
|
70
|
+
## Constants
|
|
71
|
+
|
|
72
|
+
<dl>
|
|
73
|
+
<dt><a href="#DEFAULT_QUEUE_OPTIONS">DEFAULT_QUEUE_OPTIONS</a></dt>
|
|
74
|
+
<dd><p>What describes the QUEUE. Handed to <code>mqClient.assertQueue()</code>, whose declared
|
|
75
|
+
option set is <code>durable</code>, <code>arguments</code>, <code>exclusive</code>, <code>autoDelete</code>.</p>
|
|
76
|
+
</dd>
|
|
77
|
+
<dt><a href="#DEFAULT_PUBLISH_OPTIONS">DEFAULT_PUBLISH_OPTIONS</a></dt>
|
|
78
|
+
<dd><p>What describes the MESSAGE. Handed to <code>mqClient.publish()</code>.</p>
|
|
79
|
+
</dd>
|
|
80
|
+
</dl>
|
|
81
|
+
|
|
82
|
+
## Functions
|
|
83
|
+
|
|
84
|
+
<dl>
|
|
85
|
+
<dt><a href="#describeValue">describeValue(value)</a> ⇒ <code>string</code></dt>
|
|
86
|
+
<dd><p>Renders a refused value for an error message without ever printing it as a
|
|
87
|
+
bare word: <code>"300000"</code> and <code>300000</code> look identical otherwise, and the whole
|
|
88
|
+
point of the refusal is that they are not the same thing.</p>
|
|
89
|
+
</dd>
|
|
90
|
+
<dt><a href="#readCacheTTL">readCacheTTL(context, value)</a> ⇒ <code>number</code></dt>
|
|
91
|
+
<dd><p>Reads the discovery cache lifetime: a required, non-negative integer of
|
|
92
|
+
milliseconds, where <code>0</code> means "no cache — every lookup reaches the registry".</p>
|
|
93
|
+
<p><code>options.cacheTTL || 300000</code> is what this replaces, and <code>0</code> was the value it
|
|
94
|
+
destroyed: a caller switching the cache off was given five minutes of cached
|
|
95
|
+
<code>status</code> instead, with nothing said. A default that inverts the one override
|
|
96
|
+
it is asked for is not a default (<code>architecture-principles.md</code> §3, §8), and
|
|
97
|
+
the absent case is not a value at all — it is a missing decision, so it
|
|
98
|
+
throws (§4).</p>
|
|
99
|
+
</dd>
|
|
100
|
+
</dl>
|
|
101
|
+
|
|
102
|
+
<a name="@onlineapps/cookbook-router
|
|
103
|
+
|
|
104
|
+
Message routing for cookbook workflows_ given a service name and a workflow
|
|
105
|
+
message, publish it to that services workflow queue.
|
|
106
|
+
|
|
107
|
+
The package does NOT execute cookbooks, drive flow control, retry, complete
|
|
108
|
+
or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
|
|
109
|
+
`api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
|
|
110
|
+
is also the only caller_ its constructor takes `createRouter` (through
|
|
111
|
+
`cookbook.createRouter`), and `processWorkflowMessage` and the method that
|
|
112
|
+
hands the next steps message to the router call `routeToService` on the
|
|
113
|
+
result. The second routing rail, the retry rail and the
|
|
114
|
+
queue/registry administration this package used to carry had zero callers
|
|
115
|
+
outside it and were removed on 2026-09-02.module_"></a>
|
|
116
|
+
|
|
117
|
+
## @onlineapps/cookbook-router
|
|
118
|
+
|
|
119
|
+
Message routing for cookbook workflows: given a service name and a workflow
|
|
120
|
+
message, publish it to that services workflow queue.
|
|
121
|
+
|
|
122
|
+
The package does NOT execute cookbooks, drive flow control, retry, complete
|
|
123
|
+
or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
|
|
124
|
+
`api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
|
|
125
|
+
is also the only caller: its constructor takes `createRouter` (through
|
|
126
|
+
`cookbook.createRouter`), and `processWorkflowMessage` and the method that
|
|
127
|
+
hands the next steps message to the router call `routeToService` on the
|
|
128
|
+
result. The second routing rail, the retry rail and the
|
|
129
|
+
queue/registry administration this package used to carry had zero callers
|
|
130
|
+
outside it and were removed on 2026-09-02.
|
|
131
|
+
**See**: /api/shared/cookbook/cookbook-router/README.md
|
|
132
|
+
<a name="QueueManager"></a>
|
|
133
|
+
|
|
134
|
+
## QueueManager
|
|
135
|
+
QueueManager - Queue operations and management
|
|
136
|
+
|
|
137
|
+
A QUEUE and a MESSAGE are two different things, and neither one's defaults
|
|
138
|
+
belong to the other. Until d.396d this class held ONE object for both —
|
|
139
|
+
`defaultOptions: { durable: true, persistent: true }` — and spread it into the
|
|
140
|
+
publish AND into the queue declaration, so each call site was handed a key it
|
|
141
|
+
has no use for. Measured in both directions on the unit tier:
|
|
142
|
+
|
|
143
|
+
assertQueue('split.queue', { durable: true, persistent: true })
|
|
144
|
+
publish('control.publish', …, { durable: true, persistent: true })
|
|
145
|
+
|
|
146
|
+
`persistent` is a message property (amqplib `Options.Publish`) and means
|
|
147
|
+
nothing to a queue; `durable` is a queue property and means nothing to a
|
|
148
|
+
message. Nothing objected, because `@onlineapps/mq-client-core` read the two
|
|
149
|
+
keys it knew and dropped the rest in silence — and since d.396c it no longer
|
|
150
|
+
does: `assertQueue()` refuses an option it does not read, by name. What was an
|
|
151
|
+
invisible confusion becomes a throw the moment the pin moves, and the cure is
|
|
152
|
+
not to catch it but to stop sending a message property to a queue.
|
|
153
|
+
|
|
154
|
+
So there are two defaults with two owners, `queueOptions` and `publishOptions`,
|
|
155
|
+
and each call site merges only its own.
|
|
156
|
+
|
|
157
|
+
**Kind**: global class
|
|
158
|
+
|
|
159
|
+
* [QueueManager](#QueueManager)
|
|
160
|
+
* [.publish(queueName, message, options)](#QueueManager+publish) ⇒ <code>Promise.<boolean></code>
|
|
161
|
+
* [.ensureQueue(queueName, options)](#QueueManager+ensureQueue) ⇒ <code>Promise.<boolean></code>
|
|
162
|
+
|
|
163
|
+
<a name="QueueManager+publish"></a>
|
|
164
|
+
|
|
165
|
+
### queueManager.publish(queueName, message, options) ⇒ <code>Promise.<boolean></code>
|
|
166
|
+
Publish message to queue
|
|
167
|
+
|
|
168
|
+
**Kind**: instance method of [<code>QueueManager</code>](#QueueManager)
|
|
169
|
+
|
|
170
|
+
| Param | Type | Description |
|
|
171
|
+
| --- | --- | --- |
|
|
172
|
+
| queueName | <code>string</code> | Target queue name |
|
|
173
|
+
| message | <code>Object</code> | Message to publish |
|
|
174
|
+
| options | <code>Object</code> | Publishing options (amqplib `Options.Publish`) |
|
|
175
|
+
|
|
176
|
+
<a name="QueueManager+ensureQueue"></a>
|
|
177
|
+
|
|
178
|
+
### queueManager.ensureQueue(queueName, options) ⇒ <code>Promise.<boolean></code>
|
|
179
|
+
Ensure queue exists
|
|
180
|
+
|
|
181
|
+
**Kind**: instance method of [<code>QueueManager</code>](#QueueManager)
|
|
182
|
+
|
|
183
|
+
| Param | Type | Description |
|
|
184
|
+
| --- | --- | --- |
|
|
185
|
+
| queueName | <code>string</code> | Queue name |
|
|
186
|
+
| options | <code>Object</code> | Queue options, as `mqClient.assertQueue()` declares them (`durable`, `arguments`, `exclusive`, `autoDelete`). A limit is one entry of `arguments`, in the broker's own vocabulary — `x-max-length`, `x-message-ttl`. This class translates no friendlier spelling into one: `maxLength` is a key the client does not read, and since d.396c it says so by name instead of dropping it. |
|
|
187
|
+
|
|
188
|
+
<a name="CookbookRouter"></a>
|
|
189
|
+
|
|
190
|
+
## CookbookRouter
|
|
191
|
+
CookbookRouter - routes a workflow message to a service queue.
|
|
192
|
+
|
|
193
|
+
ONE responsibility, ONE method: `routeToService`. Execution, flow control,
|
|
194
|
+
retry, DLQ and completion belong to `WorkflowOrchestrator`
|
|
195
|
+
(confirmation `api/docs/governance/confirmations/cookbook-execution-owner.md`
|
|
196
|
+
001), which is also this class's only caller: `processWorkflowMessage()` and
|
|
197
|
+
the method that hands the next step's message to the router.
|
|
198
|
+
|
|
199
|
+
**Kind**: global class
|
|
200
|
+
**See**: /api/shared/cookbook/cookbook-router/README.md
|
|
201
|
+
<a name="CookbookRouter+routeToService"></a>
|
|
202
|
+
|
|
203
|
+
### cookbookRouter.routeToService(serviceName, message, [publishOptions]) ⇒ <code>Promise.<void></code>
|
|
204
|
+
Route message directly to a specific service
|
|
205
|
+
|
|
206
|
+
**Kind**: instance method of [<code>CookbookRouter</code>](#CookbookRouter)
|
|
207
|
+
**Throws**:
|
|
208
|
+
|
|
209
|
+
- <code>Error</code> when `publishOptions` is given and is not a plain object —
|
|
210
|
+
before the registry is asked and before anything is published
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
| Param | Type | Default | Description |
|
|
214
|
+
| --- | --- | --- | --- |
|
|
215
|
+
| serviceName | <code>string</code> | | Target service name |
|
|
216
|
+
| message | <code>Object</code> | | Workflow message to send |
|
|
217
|
+
| [publishOptions] | <code>Object</code> | <code>{}</code> | Publish options for this one message, handed unchanged as the third argument of `QueueManager.publish()`, which merges them over its message defaults and passes them to the MQ client (e.g. `{ bufferOnFailure: false }`). The router neither reads nor completes them: a key the MQ client does not know is the client's to ignore. Must be a plain object when given. |
|
|
218
|
+
|
|
219
|
+
<a name="ServiceDiscovery"></a>
|
|
220
|
+
|
|
221
|
+
## ServiceDiscovery
|
|
222
|
+
ServiceDiscovery - Service discovery and health checking
|
|
223
|
+
|
|
224
|
+
**Kind**: global class
|
|
225
|
+
<a name="ServiceDiscovery+isServiceAvailable"></a>
|
|
226
|
+
|
|
227
|
+
### serviceDiscovery.isServiceAvailable(serviceName) ⇒ <code>Promise.<boolean></code>
|
|
228
|
+
Check if a service is available.
|
|
229
|
+
|
|
230
|
+
`false` is the registry's answer: the service is there and not active, or the
|
|
231
|
+
projection does not hold it (`getService()` resolved to `null`). A lookup the
|
|
232
|
+
registry could not answer is not that answer, so it is thrown with its cause
|
|
233
|
+
rather than read as "not available" (d.1086; architecture-principles.md §3–§5).
|
|
234
|
+
A failed lookup is never cached.
|
|
235
|
+
|
|
236
|
+
**Kind**: instance method of [<code>ServiceDiscovery</code>](#ServiceDiscovery)
|
|
237
|
+
**Throws**:
|
|
238
|
+
|
|
239
|
+
- <code>Error</code> `code: 'SERVICE_DISCOVERY_FAILED'`, `serviceName`, and the
|
|
240
|
+
registry's rejection as `cause`, when `registryClient.getService()` rejects;
|
|
241
|
+
`type` copied from the cause when the cause declares one (d.1093).
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
| Param | Type | Description |
|
|
245
|
+
| --- | --- | --- |
|
|
246
|
+
| serviceName | <code>string</code> | Service name |
|
|
247
|
+
|
|
248
|
+
<a name="DEFAULT_QUEUE_OPTIONS"></a>
|
|
249
|
+
|
|
250
|
+
## DEFAULT\_QUEUE\_OPTIONS
|
|
251
|
+
What describes the QUEUE. Handed to `mqClient.assertQueue()`, whose declared
|
|
252
|
+
option set is `durable`, `arguments`, `exclusive`, `autoDelete`.
|
|
253
|
+
|
|
254
|
+
**Kind**: global constant
|
|
255
|
+
<a name="DEFAULT_PUBLISH_OPTIONS"></a>
|
|
256
|
+
|
|
257
|
+
## DEFAULT\_PUBLISH\_OPTIONS
|
|
258
|
+
What describes the MESSAGE. Handed to `mqClient.publish()`.
|
|
259
|
+
|
|
260
|
+
**Kind**: global constant
|
|
261
|
+
<a name="describeValue"></a>
|
|
262
|
+
|
|
263
|
+
## describeValue(value) ⇒ <code>string</code>
|
|
264
|
+
Renders a refused value for an error message without ever printing it as a
|
|
265
|
+
bare word: `"300000"` and `300000` look identical otherwise, and the whole
|
|
266
|
+
point of the refusal is that they are not the same thing.
|
|
267
|
+
|
|
268
|
+
**Kind**: global function
|
|
269
|
+
|
|
270
|
+
| Param | Type |
|
|
271
|
+
| --- | --- |
|
|
272
|
+
| value | <code>\*</code> |
|
|
273
|
+
|
|
274
|
+
<a name="readCacheTTL"></a>
|
|
275
|
+
|
|
276
|
+
## readCacheTTL(context, value) ⇒ <code>number</code>
|
|
277
|
+
Reads the discovery cache lifetime: a required, non-negative integer of
|
|
278
|
+
milliseconds, where `0` means "no cache — every lookup reaches the registry".
|
|
279
|
+
|
|
280
|
+
`options.cacheTTL || 300000` is what this replaces, and `0` was the value it
|
|
281
|
+
destroyed: a caller switching the cache off was given five minutes of cached
|
|
282
|
+
`status` instead, with nothing said. A default that inverts the one override
|
|
283
|
+
it is asked for is not a default (`architecture-principles.md` §3, §8), and
|
|
284
|
+
the absent case is not a value at all — it is a missing decision, so it
|
|
285
|
+
throws (§4).
|
|
286
|
+
|
|
287
|
+
**Kind**: global function
|
|
288
|
+
**Throws**:
|
|
289
|
+
|
|
290
|
+
- <code>Error</code> when the value is absent, or is not an integer >= 0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
| Param | Type | Description |
|
|
294
|
+
| --- | --- | --- |
|
|
295
|
+
| context | <code>string</code> | Name of the caller for the message |
|
|
296
|
+
| value | <code>\*</code> | The value the caller passed as `options.cacheTTL` |
|
|
297
|
+
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# Changelog — @onlineapps/cookbook-router
|
|
2
|
+
|
|
3
|
+
All notable changes to this package. Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [5.0.0] — 2026-10-03
|
|
8
|
+
|
|
9
|
+
pin: `@onlineapps/conn-orch-registry` 7.0.0 → 8.0.0
|
|
10
|
+
|
|
11
|
+
### Changed — BREAKING: selhání registru už není „Service not available" (d.1086)
|
|
12
|
+
|
|
13
|
+
Pro volajícího `isServiceAvailable()` a `routeToService()` se mění kontrakt: při odmítnutí
|
|
14
|
+
`registryClient.getService()` dřív dostal `false` (a `routeToService` chybu
|
|
15
|
+
`Service not available`), teď dostane odmítnutí s `code: 'SERVICE_DISCOVERY_FAILED'`.
|
|
16
|
+
|
|
17
|
+
- `ServiceDiscovery.isServiceAvailable()` spolkla odmítnutí `registryClient.getService()`:
|
|
18
|
+
zalogovala ho a vrátila `false`, takže `routeToService` hlásil
|
|
19
|
+
`[CookbookRouter] Service not available: <služba>` a příčina (nedostupný Redis, poškozený
|
|
20
|
+
záznam projekce, chyba zapojení) do neúspěšného běhu nedošla (architecture-principles §3–§5).
|
|
21
|
+
Nově se odmítnutí dál loguje a **vyhodí** jako `Error` s `code: 'SERVICE_DISCOVERY_FAILED'`,
|
|
22
|
+
`serviceName` a původní chybou v `cause`; `routeToService` ji nechá projít. Neúspěšný dotaz
|
|
23
|
+
se necachuje.
|
|
24
|
+
- `false` zůstává jen odpovědí registru: služba není `active`, nebo ji projekce nedrží
|
|
25
|
+
(`getService()` → `null`). Pro neznámou službu metoda dřív vracela `null` místo slíbeného
|
|
26
|
+
`boolean`; nyní `false`.
|
|
27
|
+
- Klasifikace pro retry se nemění: `SERVICE_DISCOVERY_FAILED` klasifikátor
|
|
28
|
+
(`error-handler-core`) nezná → `UNKNOWN`, stejně jako dřívější „Service not available".
|
|
29
|
+
|
|
30
|
+
### Changed — jedna cesta chyby discovery, mrtvá větev `ECONNREFUSED` pryč (d.1092)
|
|
31
|
+
|
|
32
|
+
- `ServiceDiscovery.isServiceAvailable()` logovala odmítnutí s `code === 'ECONNREFUSED'`
|
|
33
|
+
jinou řádkou (`Registry connection failed`). Větev zbyla z HTTP discovery; dotaz dnes čte
|
|
34
|
+
Redis projekci (conf `biz-discovery-redis` 001, ADR 0005) a jeho klient takový `code`
|
|
35
|
+
nedává, takže nebyla dosažitelná. Každé odmítnutí se teď loguje jednou řádkou
|
|
36
|
+
`Service discovery failed for <služba>:` a hází stejně jako dřív
|
|
37
|
+
(`SERVICE_DISCOVERY_FAILED`, `cause` = původní chyba).
|
|
38
|
+
|
|
39
|
+
### Added — `SERVICE_DISCOVERY_FAILED` nese `type` příčiny (d.1093)
|
|
40
|
+
|
|
41
|
+
- Odmítnutí z `ServiceDiscovery.isServiceAvailable()` převezme `type` z `cause`, pokud ho
|
|
42
|
+
příčina má; jinak `type` nemá. Verdikt vzniká tam, kde vzniká chyba: `@onlineapps/conn-orch-registry`
|
|
43
|
+
`getService()` otypuje dotaz, na který projekce registru neodpověděla (termín / Redis
|
|
44
|
+
nedostupný), jako `TRANSIENT`, a klasifikátor L1 (`error-handler-core`) čte `type`, aniž by
|
|
45
|
+
znal Redis — krok tak jde do retry místo `non_retryable_unknown`. Příčina bez `type` se
|
|
46
|
+
klasifikuje jako dřív (`UNKNOWN`).
|
|
47
|
+
|
|
48
|
+
### Added — integrační tier: discovery nad skutečným registry klientem a Redisem (d.1097)
|
|
49
|
+
|
|
50
|
+
- Nový tier `npm run test:integration` (`jest.integration.config.js`, `tests/integration/`);
|
|
51
|
+
`npm test` = unit && integration. `globalSetup` ověří živý Redis (`REDIS_URL`, jediný klíč,
|
|
52
|
+
hodnota se nikdy nevypisuje) a bez něj běh odmítne s opravou; `jest.config.js` tier
|
|
53
|
+
vylučuje, takže do unit tieru api nevstoupí.
|
|
54
|
+
- `ServiceDiscovery` se skládá s **nainstalovaným** `@onlineapps/conn-orch-registry` a `redis`
|
|
55
|
+
(devDependencies, přesné piny): služba v projekci `registry:services` jako `active` → `true`,
|
|
56
|
+
jiný stav nebo chybějící záznam → `false` (s kontrolou souseda v témže hashi), nečitelný
|
|
57
|
+
záznam, odpojený Redis, zavřený socket a chybová odpověď → `SERVICE_DISCOVERY_FAILED`
|
|
58
|
+
s původní chybou v `cause`. Živý Redis v databázi tieru, jen pole vlastního běhu.
|
|
59
|
+
- Pojmenovaná mez: nainstalovaný registry klient chybu netypuje, proto sada tvrdí
|
|
60
|
+
`type === undefined`; strážní test verze zčervená při posunu pinu, kdy se tvrzení mění na
|
|
61
|
+
`TRANSIENT` a přibude scénář polootevřeného Redisu (conf `biz-discovery-redis` 003).
|
|
62
|
+
|
|
63
|
+
### Fixed — `QueueManager.publish()` už publish neopakuje (d.1121)
|
|
64
|
+
|
|
65
|
+
- Při odmítnutí, jehož text obsahoval `Connection lost`, volal `publish()` klienta podruhé.
|
|
66
|
+
Retry a buffer publishe vlastní `@onlineapps/mq-client-core` uvnitř každého `publish()`
|
|
67
|
+
(`docs/architecture/mq-publish-reliability.md` § The one rule for services); vyhozená
|
|
68
|
+
chyba znamená „nepotvrzeno" — zpráva je v bufferu klienta, nebo odmítnuta natrvalo — a druhé
|
|
69
|
+
volání by odeslalo druhou kopii. Rozhodovalo se navíc podle textu hlášky, ne podle typu.
|
|
70
|
+
- Nově jedno volání na zprávu; odmítnutí se zaloguje a propadne volajícímu beze změny (týž
|
|
71
|
+
objekt). Měřeno nad vydaným `mq-client-core` 5.0.0 (skutečné `BaseClient`, `RabbitMQClient`,
|
|
72
|
+
`PublishLayer`, dvojník jen `amqplib.connect`): ztracené spojení bez obnovy, odmítnutá obnova
|
|
73
|
+
i kanál mrtvý pod potvrzením hodí `PublishError`/`ConnectionError`, žádná s `Connection lost`
|
|
74
|
+
v textu — větev na této verzi nebyla dosažitelná.
|
|
75
|
+
|
|
76
|
+
### Added — `routeToService` nese volby publishe (d.1124)
|
|
77
|
+
|
|
78
|
+
- `routeToService(serviceName, message, publishOptions = {})`: třetí argument se předá
|
|
79
|
+
beze změny jako třetí argument `QueueManager.publish()`, který ho sloučí přes výchozí volby
|
|
80
|
+
zprávy (`{ persistent: true }`) a pošle MQ klientovi. Router volby nečte ani nedoplňuje —
|
|
81
|
+
klíč, který klient nezná, je klientův k ignorování. Do d.1124 router třetí argument zahodil,
|
|
82
|
+
takže `{ bufferOnFailure: false }` orchestrátoru k MQ klientovi nedošel a klient zprávu
|
|
83
|
+
dalšího kroku po vyčerpaném publishi uložil do bufferu a po reconnectu odeslal podruhé
|
|
84
|
+
(změřeno d.1119).
|
|
85
|
+
- Volby, jsou-li dány, musí být plain object; jinak `routeToService` hodí
|
|
86
|
+
`[CookbookRouter] routeToService - publishOptions must be an object - …` dřív, než se zeptá
|
|
87
|
+
registru, a nic nepublikuje. Bez třetího argumentu se chování nemění (`publish()` dostane `{}`).
|
|
88
|
+
- `API.md` nově nese `CookbookRouter` a jeho `routeToService`: docblock třídy stál nad
|
|
89
|
+
`require('./serviceDiscovery')`, takže ho jsdoc2md vykreslil jako druhý záznam
|
|
90
|
+
„ServiceDiscovery" (konstanta) a třídu s metodou vynechal. Docblock je teď nad
|
|
91
|
+
`class CookbookRouter`; citace volajících jsou jmény metod `WorkflowOrchestrator`, ne
|
|
92
|
+
ručně psanými čísly řádků.
|
|
93
|
+
|
|
94
|
+
### Tests — integrační helper staví registry klienta s `discoveryTimeoutMs` (d.1200)
|
|
95
|
+
|
|
96
|
+
- Test, ne chování: `registryClientOver()` (`tests/integration/helpers.js`) stavěl klienta bez
|
|
97
|
+
`discoveryTimeoutMs`, který je od kontraktu registry 8.0.0 povinný (d.1093); se zdrojem
|
|
98
|
+
registry z `release/w4` proto padalo 11 z 12 testů integračního tieru hláškou
|
|
99
|
+
`Missing constructor option - discoveryTimeoutMs is required`. Helper teď předává hodnotu
|
|
100
|
+
z deklarace `REGISTRY_DISCOVERY_TIMEOUT_MS` v `config/shared-env.json` — tutéž, kterou wrapper
|
|
101
|
+
čte a klientovi předává — nikdy literál; chybějící nebo nečíselná deklarace skončí
|
|
102
|
+
pojmenovanou chybou. Tvrzení beze změny; `src/**` se nemění.
|
|
103
|
+
|
|
104
|
+
### Changed — hláška `SERVICE_DISCOVERY_FAILED` bez dvojité tečky (d.1202)
|
|
105
|
+
|
|
106
|
+
- `ServiceDiscovery.isServiceAvailable()` uzavíral citovanou příčinu vždy tečkou. Odmítnutí
|
|
107
|
+
registry klienta 8.0.0 jsou celé věty `[Context] Problem - Expected/Fix`, které tečkou už
|
|
108
|
+
končí, takže hláška četla `…retried as TRANSIENT.. Expected: …` (architecture-principles §5).
|
|
109
|
+
Příčina zakončená `.`, `!` nebo `?` se teď cituje beze změny; příčině bez koncové interpunkce
|
|
110
|
+
se tečka doplní jako dřív. Kód, `serviceName`, `cause` a `type` beze změny.
|
|
111
|
+
|
|
112
|
+
### Tests — aserce objevování na kontrakt registry 8.0.0 (K13; d.1201)
|
|
113
|
+
|
|
114
|
+
- Test, ne chování: `tests/integration/serviceDiscovery.integration.test.js` popisoval registry
|
|
115
|
+
klienta 7.0.0, který selhání dotazu netypuje (dva případy „NAMED LIMIT: untyped"). Registry
|
|
116
|
+
8.0.0 při `isReady === false` (offline s `disableOfflineQueue`, socket zavřený pod příkazem)
|
|
117
|
+
odmítne vlastní chybou `REGISTRY_DISCOVERY_UNAVAILABLE`, `type: 'TRANSIENT'`, s chybou
|
|
118
|
+
node-redis v její `cause` (d.1093). Oba případy teď tvrdí `SERVICE_DISCOVERY_FAILED` s
|
|
119
|
+
`type: 'TRANSIENT'`, v `cause` chybu registru (`code`, `type`) a pod ní původní třídu
|
|
120
|
+
node-redis (`ClientOfflineError`, `SocketClosedUnexpectedlyError`); offline případ dál tvrdí
|
|
121
|
+
celou hlášku. Strážní `INSTALLED_REGISTRY_VERSION` je `'8.0.0'`; devDependency
|
|
122
|
+
`@onlineapps/conn-orch-registry` přechází na 8.0.0 v témže commitu (K13). Chybová
|
|
123
|
+
odpověď připojeného Redisu a poškozený záznam projekce zůstávají netypované. `src/**` se nemění.
|
|
124
|
+
|
|
125
|
+
## [4.0.0] — 2026-09-27
|
|
126
|
+
|
|
127
|
+
### Changed — BREAKING: `logger` and `cacheTTL` are required, and `0` is a value (d.615)
|
|
128
|
+
|
|
129
|
+
Three constructors read their options with `||`:
|
|
130
|
+
|
|
131
|
+
logger: options.logger || console (router.js, serviceDiscovery.js, queueManager.js)
|
|
132
|
+
cacheTTL: options.cacheTTL || 300000 (serviceDiscovery.js)
|
|
133
|
+
|
|
134
|
+
Both are the shape `.claude/rules/architecture-principles.md` §3 bans by name,
|
|
135
|
+
and the second did measurable harm beyond the principle. `cacheTTL: 0` means
|
|
136
|
+
"ask the registry every time"; `||` is false for `0`, so that request became
|
|
137
|
+
five minutes of cached `status` instead — the exact opposite of what the caller
|
|
138
|
+
asked for, decided in silence. A caller who had switched the cache off was
|
|
139
|
+
served a service's liveness from memory for 300 seconds and had no way to see
|
|
140
|
+
it. `logger || console` is the quieter half: a component nobody handed a logger
|
|
141
|
+
wrote to a stream no collector reads, on a platform where every neighbouring
|
|
142
|
+
package refuses the same omission by name.
|
|
143
|
+
|
|
144
|
+
So both keys are now **required** and checked in the constructor (§4):
|
|
145
|
+
|
|
146
|
+
- `logger` must be an object with `info`, `warn`, `error` and `debug` as
|
|
147
|
+
functions. Absent → `[<class>] logger is required - Expected: a logger with
|
|
148
|
+
info/warn/error/debug, so <reason>. Fix: pass options.logger …`; incomplete →
|
|
149
|
+
the same message naming the missing methods. `console` satisfies the shape and
|
|
150
|
+
is still accepted — when a caller passes it deliberately.
|
|
151
|
+
- `cacheTTL` must be an integer `>= 0`, in milliseconds. Absent (or `null`, the
|
|
152
|
+
way `WorkflowOrchestrator.readNumericOption` reads an omitted number) →
|
|
153
|
+
`cacheTTL is required`; `NaN`, negative, fractional, `Infinity`, a numeric
|
|
154
|
+
string or a boolean → `cacheTTL is invalid - … got <value>`, with the value
|
|
155
|
+
rendered so `"300000"` and `300000` cannot be confused.
|
|
156
|
+
- `cacheTTL: 0` is accepted and means **no cache**: `getCached` now compares
|
|
157
|
+
`age >= cacheTTL` rather than `age > cacheTTL`, so an entry whose age has
|
|
158
|
+
reached its lifetime is spent and every lookup reaches the registry. With `>`
|
|
159
|
+
two lookups inside one millisecond would still have hit the cache, and "no
|
|
160
|
+
cache" would have held only most of the time.
|
|
161
|
+
|
|
162
|
+
The checks live in one new module, `src/options.js`, because all three
|
|
163
|
+
constructors read `logger` with the same meaning and a second copy inside one
|
|
164
|
+
package is two rails for one concern (`change-discipline.md` § One rail per
|
|
165
|
+
concern). The logger contract itself comes from `@onlineapps/logger-contract`,
|
|
166
|
+
which owns it platform-wide: `src/options.js` re-exports its `assertLogger`
|
|
167
|
+
rather than restating it, now that the publication wave has pinned the package
|
|
168
|
+
(`f60f24c3`, `@onlineapps/logger-contract 2.0.0`). Until that pin existed the
|
|
169
|
+
check was a hand-written copy here, word for word identical — so the swap cost
|
|
170
|
+
one import and no re-test: every message assertion in the suite passed
|
|
171
|
+
unchanged. `readCacheTTL` stays this package's own, because `cacheTTL` is its
|
|
172
|
+
own option and no library owns it.
|
|
173
|
+
|
|
174
|
+
**For the caller:** `@onlineapps/conn-orch-orchestrator` built the router with
|
|
175
|
+
`createRouter(this.mqClient, this.registryClient, { logger: this.logger })` in
|
|
176
|
+
`WorkflowOrchestrator` — a logger but no `cacheTTL`. It passes one since d.615b
|
|
177
|
+
(`config.serviceDiscoveryCacheTTL`, default 300000, `0` honoured), so this major
|
|
178
|
+
has its caller ready before it is published. Nothing else in the workspace
|
|
179
|
+
constructs these classes: `conn-orch-cookbook` only destructures and re-exports
|
|
180
|
+
them.
|
|
181
|
+
|
|
182
|
+
Tests: `tests/unit/required-options.test.js` (26), covering each refusal by its
|
|
183
|
+
message, `cacheTTL: 0` sending three lookups to the registry, the control that a
|
|
184
|
+
real TTL still serves the second from cache, the control that the whole chain
|
|
185
|
+
still routes end to end with the real collaborators, and — since the pin — that
|
|
186
|
+
`options.assertLogger` IS the library's function (identity, not an equal copy)
|
|
187
|
+
and that the module exports no second `LOGGER_METHODS`.
|
|
188
|
+
|
|
189
|
+
### Removed — BREAKING: `cacheEnabled`, druhá kolej pro „necachuj" (d.615b)
|
|
190
|
+
|
|
191
|
+
`ServiceDiscovery` odpovídal na otázku „mám si pamatovat odpověď registru?"
|
|
192
|
+
dvěma klíči: `cacheEnabled` (`options.cacheEnabled !== false`) a — od d.615 —
|
|
193
|
+
`cacheTTL`, kde `0` znamená bez cache. Obě odpovědi šly napsat proti sobě:
|
|
194
|
+
`{ cacheEnabled: true, cacheTTL: 0 }` i `{ cacheEnabled: false, cacheTTL: 300000 }`
|
|
195
|
+
byly platné a v každé z nich jeden z těch dvou klíčů lhal. Jedna starost, dvě
|
|
196
|
+
koleje (`change-discipline.md` § One rail per concern) — a `cacheEnabled` navíc
|
|
197
|
+
implicitní default rozhodovaný z `!== false` (§8).
|
|
198
|
+
|
|
199
|
+
Zůstává `cacheTTL`: jedna hodnota říká obojí — jestli cachovat a jak dlouho.
|
|
200
|
+
Kdo nechce cachovat, napíše `0`; od téhle dávky se při něm do `Map` ani nic
|
|
201
|
+
nezapisuje. Klíč `cacheEnabled` se nečte vůbec (nepřekládá se, nevaruje,
|
|
202
|
+
neodmítá) — volající, který ho ještě pošle, dostane to, co říká životnost.
|
|
203
|
+
|
|
204
|
+
Čtyři otázky `change-discipline.md` § Removing something removes its declaration:
|
|
205
|
+
|
|
206
|
+
1. **Proč vznikl.** Přišel s balíkem do `shared/` (`d05bb7be`, restrukturalizace)
|
|
207
|
+
jako přepínač „tenhle volající nechce discovery-cache". `git log -S cacheEnabled
|
|
208
|
+
-- shared/cookbook/cookbook-router` zná od té doby tři commity (`d05bb7be`,
|
|
209
|
+
`144db203`, `7fa728e8`) a ani jeden z nich ten klíč nikde nenastavuje.
|
|
210
|
+
2. **Která část koncepce ho nesla.** Žádná: nemá ADR, kontrakt ani model, jen
|
|
211
|
+
řádek v README § Options. Koncept „necachuj" vlastní od d.615 `cacheTTL`
|
|
212
|
+
(`0` = bez cache, `getCached` porovnává `age >= cacheTTL`).
|
|
213
|
+
3. **Proč ho dnes nikdo nečte.** Mimo balík ho nikdy nikdo nenastavil. Jediný
|
|
214
|
+
stavitel routeru je `WorkflowOrchestrator` a ten posílá `{ logger, cacheTTL }`
|
|
215
|
+
(d.615b). Změřeno napříč workspace (`api`, `api_biz/*`, `fe_adminui`, mimo
|
|
216
|
+
`node_modules`): jediné další výskyty `cacheEnabled` patří `conn-base-storage`
|
|
217
|
+
a `conn-orch-registry` — vlastní, nesouvisející volby jejich vlastních tříd.
|
|
218
|
+
4. **Je náhrada koncepčnější.** Ano. Jedna hodnota nemůže sama se sebou být
|
|
219
|
+
v rozporu, zatímco dvě klidně ano; a `0` je hodnota, kterou volající vysloví,
|
|
220
|
+
místo default odvozeného z `!== false`.
|
|
221
|
+
|
|
222
|
+
Testy: `tests/unit/serviceDiscovery.test.js` — `cacheTTL: 0` je jediná cesta
|
|
223
|
+
k „necachuj" (dva lookupy = dvě volání registru, `cache.size === 0`),
|
|
224
|
+
`cacheEnabled: false` s reálnou životností cache NEvypne (druhý lookup jde z ní),
|
|
225
|
+
a konstruktor po sobě nenechá `options.cacheEnabled`.
|
|
226
|
+
|
|
227
|
+
### Changed — tarball nese CHANGELOG, README a API.md (d.1021)
|
|
228
|
+
|
|
229
|
+
`files` v `package.json` jmenoval jen `src`, takže vydaný balíček nenesl `CHANGELOG.md`
|
|
230
|
+
ani generované `API.md` — kdo měl router v `node_modules`, neviděl, co se mezi verzemi
|
|
231
|
+
změnilo, ani referenci exportů. `files` je teď `src`, `CHANGELOG.md`, `README.md`,
|
|
232
|
+
`API.md`. `README.md` npm přibaluje vždy; v seznamu stojí proto, aby obsah tarballu
|
|
233
|
+
říkal jeden seznam, ne seznam plus pravidlo npm. Ze `src` nic neubylo
|
|
234
|
+
(`npm pack --dry-run`: přibyly `API.md` a `CHANGELOG.md`).
|
|
235
|
+
|
|
236
|
+
## [3.0.1] — 2026-09-16
|
|
237
|
+
|
|
238
|
+
### Fixed — the unit tier claimed a queue declaration the client refuses (d.520)
|
|
239
|
+
|
|
240
|
+
`should apply custom queue options` asserted that `ensureQueue('custom.queue',
|
|
241
|
+
{ maxLength: 1000, messageTtl: 60000 })` reaches the declaration with those two
|
|
242
|
+
keys. Neither half held: this package composes no queue argument — it merges its
|
|
243
|
+
defaults with the caller's options and hands the result to
|
|
244
|
+
`mqClient.assertQueue()` — and since d.396c of `@onlineapps/mq-client-core` that
|
|
245
|
+
client refuses, by name, a key `assertQueue()` does not read:
|
|
246
|
+
|
|
247
|
+
[RabbitMQClient] Queue option not declared by assertQueue(): "maxLength",
|
|
248
|
+
"messageTtl" - Expected: only durable, arguments, exclusive, autoDelete.
|
|
249
|
+
|
|
250
|
+
The test passed only because its client stub accepted anything. The stub now
|
|
251
|
+
refuses exactly what the real client refuses, which makes it the guard of the
|
|
252
|
+
file: every declaration any test here produces runs through it, so a key the
|
|
253
|
+
client does not read cannot pass unnoticed. The claim itself is corrected to the
|
|
254
|
+
broker vocabulary — `arguments: { 'x-max-length': 1000, 'x-message-ttl': 60000 }`
|
|
255
|
+
— with the value asserted, not its presence.
|
|
256
|
+
|
|
257
|
+
Added with it: the failure path (a friendlier spelling is refused and the refusal
|
|
258
|
+
propagates — the package neither translates nor catches, and the queue is not
|
|
259
|
+
remembered as ensured), the control case (a queue asked for without limits
|
|
260
|
+
carries no `arguments` key at all), and a test that every declaration this
|
|
261
|
+
package composes on its own — constructor defaults, the `publish()` path, a
|
|
262
|
+
per-call merge — carries only keys the client reads.
|
|
263
|
+
|
|
264
|
+
No behaviour change: `src/queueManager.js` already forwarded the declared option
|
|
265
|
+
set, and its `ensureQueue()` doc now says where a limit belongs. `README.md`
|
|
266
|
+
§ Options documents the shape.
|
|
267
|
+
|
|
268
|
+
## [3.0.0] — 2026-09-14
|
|
269
|
+
|
|
270
|
+
### BREAKING — `defaultOptions` is two keys, `queueOptions` and `publishOptions` (d.396d)
|
|
271
|
+
|
|
272
|
+
A queue declaration and a message are two different things, and neither one's
|
|
273
|
+
defaults belong to the other. One object fed both — `{ durable: true,
|
|
274
|
+
persistent: true }`, spread into the publish AND into the queue declaration — so
|
|
275
|
+
each call site was handed a key it has no use for. Measured in both directions
|
|
276
|
+
on the unit tier:
|
|
277
|
+
|
|
278
|
+
assertQueue('split.queue', { durable: true, persistent: true })
|
|
279
|
+
publish('control.publish', …, { durable: true, persistent: true })
|
|
280
|
+
|
|
281
|
+
`persistent` is a message property (amqplib `Options.Publish`) and means nothing
|
|
282
|
+
to a queue; `durable` is a queue property and means nothing to a message.
|
|
283
|
+
Nothing objected, because `@onlineapps/mq-client-core` read the two keys it knew
|
|
284
|
+
and dropped the rest in silence — and since its d.396c it no longer does:
|
|
285
|
+
`assertQueue()` refuses an option it does not read, by name. What was an
|
|
286
|
+
invisible confusion becomes a throw the moment the pin moves, and the cure is
|
|
287
|
+
not to catch it but to stop sending a message property to a queue.
|
|
288
|
+
|
|
289
|
+
- `queueOptions` (default `{ durable: true }`) is merged into every queue
|
|
290
|
+
declaration and reaches `mqClient.assertQueue()`, whose declared option set is
|
|
291
|
+
`durable`, `arguments`, `exclusive`, `autoDelete`.
|
|
292
|
+
- `publishOptions` (default `{ persistent: true }`) is merged into every publish
|
|
293
|
+
and reaches `mqClient.publish()` as amqplib message properties.
|
|
294
|
+
- Per-call options are unchanged: `ensureQueue(queue, options)` and
|
|
295
|
+
`publish(queue, message, options)` still override their own defaults.
|
|
296
|
+
|
|
297
|
+
**Fixed in the same expression:** the constructor spread `...options` AFTER the
|
|
298
|
+
computed block, which undid the merge it was written to extend — a caller
|
|
299
|
+
passing `{ durable: false }` replaced the whole defaults object instead of
|
|
300
|
+
overriding one key of it, and lost the other default with it. `...options` now
|
|
301
|
+
comes first, so every key this class does not compute is still forwarded
|
|
302
|
+
untouched (`router.js` hands its whole options object to both collaborators) and
|
|
303
|
+
the computed keys survive.
|
|
304
|
+
|
|
305
|
+
**Callers:** none. `defaultOptions` was measured across `api`, `api_biz` and
|
|
306
|
+
`fe_adminui` (no `node_modules`) on 2026-09-14 — nothing outside this package
|
|
307
|
+
constructs `QueueManager` or writes the key; `config/libraries.json` pins the
|
|
308
|
+
package, and `api/jest.config.unit.js` lists it, neither of which passes options.
|
|
309
|
+
|
|
310
|
+
RED (unit): 8 of 24, with the leak named in both directions —
|
|
311
|
+
`+ "persistent": true` inside the `assertQueue` call and `+ "durable": true`
|
|
312
|
+
inside the publish options. GREEN: 24/24 in the file, 63/63 for the package.
|
|
313
|
+
|
|
314
|
+
### BREAKING — the package routes, and does nothing else (DÁVKA 83)
|
|
315
|
+
|
|
316
|
+
Execution, flow control, retry, completion and dead-lettering have exactly one
|
|
317
|
+
owner, `WorkflowOrchestrator` — confirmation
|
|
318
|
+
[`cookbook-execution-owner.md`](/api/docs/governance/confirmations/cookbook-execution-owner.md)
|
|
319
|
+
001. This package carried a second copy of most of it. Measured on 2026-09-02
|
|
320
|
+
across `api/shared`, `api/infra`, `api_biz` and `fe_adminui` (no
|
|
321
|
+
`node_modules`), every removed member had **zero callers outside this package**;
|
|
322
|
+
the only live chain is `createRouter` → `routeToService` →
|
|
323
|
+
`isServiceAvailable` → `publish` → `ensureQueue`, which
|
|
324
|
+
`WorkflowOrchestrator.js:71,78,233,241,1223,1230` uses and nothing else does.
|
|
325
|
+
|
|
326
|
+
Removed:
|
|
327
|
+
|
|
328
|
+
- `CookbookRouter`: `routeWorkflow`, `routeToNextService`, `routeToCompleted`,
|
|
329
|
+
`routeToDLQ`, `determineTargetService`, `buildWorkflowMessage`, `handleRetry`.
|
|
330
|
+
`routeToService` and the constructor stay.
|
|
331
|
+
- `RetryHandler` — the whole class and its module, reachable only from
|
|
332
|
+
`handleRetry`. It is no longer exported here, nor re-exported by
|
|
333
|
+
`@onlineapps/conn-orch-cookbook`. Retry with backoff lives in
|
|
334
|
+
`WorkflowOrchestrator.js:495`, and the exhausted-retry publish to
|
|
335
|
+
`workflow.failed` at `:574`.
|
|
336
|
+
- `QueueManager`: `consume`, `getQueueInfo`, `purgeQueue`, `deleteQueue`,
|
|
337
|
+
`resetConnection`. Consumption belongs to `@onlineapps/mq-client-core` through
|
|
338
|
+
`ServiceWrapper`. Removing `consume` also closes the open ack/nack question
|
|
339
|
+
recorded against `queueManager.js` in `api/shared/TODO.md`: the code it asked
|
|
340
|
+
about is gone.
|
|
341
|
+
- `ServiceDiscovery`: `getServiceInfo`, `listAvailableServices`,
|
|
342
|
+
`getServiceQueue`, `invalidateCache`. `isServiceAvailable` and its TTL cache
|
|
343
|
+
stay.
|
|
344
|
+
- `CookbookRouter` option defaults `defaultQueue`, `completedQueue`, `dlqSuffix`,
|
|
345
|
+
`maxRetries`, `retryDelay`. The first three fed the removed routing rail; the
|
|
346
|
+
last two had no reader even before it — `RetryHandler` keyed on `maxAttempts`
|
|
347
|
+
and `baseDelay`, never on these. `logger` is the only key the class reads, and
|
|
348
|
+
the caller's `options` still reach `ServiceDiscovery` and `QueueManager`
|
|
349
|
+
whole. A declaration nothing reads is dead
|
|
350
|
+
(`.claude/rules/change-discipline.md` § Removing).
|
|
351
|
+
|
|
352
|
+
Also in this change: the package gained a `jest.config.js` and a line in
|
|
353
|
+
`api/jest.config.unit.js`, so its suites run in the api unit tier. They ran
|
|
354
|
+
under no root npm script before — a whole sada outside the regression loop
|
|
355
|
+
(`.claude/rules/service-refactoring.md` § forbidden action #4).
|
|
356
|
+
|
|
357
|
+
The DÁVKA 78 fix that made `routeToNextService` and `handleRetry` find steps by
|
|
358
|
+
`step_id` instead of `id` is superseded: both methods are gone, and so is the
|
|
359
|
+
`id`-vs-`step_id` question in this package. `routeToService` takes a service
|
|
360
|
+
name and an opaque message and reads no step identifier at all.
|
package/README.md
CHANGED
|
@@ -36,8 +36,8 @@ const { createRouter, CookbookRouter, ServiceDiscovery, QueueManager } =
|
|
|
36
36
|
|
|
37
37
|
| Export | What it is |
|
|
38
38
|
|---|---|
|
|
39
|
-
| `createRouter(mqClient, registryClient, options)` | Factory returning a `CookbookRouter`. This is what `WorkflowOrchestrator
|
|
40
|
-
| `CookbookRouter` | One method: `routeToService(serviceName, message)`. |
|
|
39
|
+
| `createRouter(mqClient, registryClient, options)` | Factory returning a `CookbookRouter`. This is what the `WorkflowOrchestrator` constructor takes (through `cookbook.createRouter`). |
|
|
40
|
+
| `CookbookRouter` | One method: `routeToService(serviceName, message, publishOptions)`. |
|
|
41
41
|
| `ServiceDiscovery` | One method: `isServiceAvailable(serviceName)`, with a TTL cache. |
|
|
42
42
|
| `QueueManager` | Two methods: `publish(queue, message, options)` and `ensureQueue(queue, options)`. |
|
|
43
43
|
| `VERSION` | String constant. |
|
|
@@ -47,35 +47,71 @@ const { createRouter, CookbookRouter, ServiceDiscovery, QueueManager } =
|
|
|
47
47
|
```javascript
|
|
48
48
|
const { createRouter } = require('@onlineapps/cookbook-router');
|
|
49
49
|
|
|
50
|
-
const router = createRouter(mqClient, registryClient, { logger });
|
|
50
|
+
const router = createRouter(mqClient, registryClient, { logger, cacheTTL: 300000 });
|
|
51
51
|
|
|
52
52
|
// Publishes to `biz-invoicing.workflow`.
|
|
53
53
|
await router.routeToService('biz-invoicing', workflowMessage);
|
|
54
|
+
|
|
55
|
+
// Publish options for this one message travel to the MQ client unchanged.
|
|
56
|
+
await router.routeToService('biz-invoicing', workflowMessage, { bufferOnFailure: false });
|
|
54
57
|
```
|
|
55
58
|
|
|
59
|
+
The optional third argument, `publishOptions`, is handed as-is to
|
|
60
|
+
`QueueManager.publish()`, which merges it over the message defaults and passes it
|
|
61
|
+
to the MQ client. The router neither reads nor completes it; without it the
|
|
62
|
+
client gets the message defaults alone.
|
|
63
|
+
|
|
56
64
|
`routeToService` fails fast and never publishes a message it could not place:
|
|
57
65
|
|
|
58
66
|
| Condition | Result |
|
|
59
67
|
|---|---|
|
|
60
68
|
| `serviceName` missing or not a string | throws `[CookbookRouter] routeToService - serviceName is required and must be a string` |
|
|
61
69
|
| `message` missing or not an object | throws `[CookbookRouter] routeToService - message is required and must be an object` |
|
|
70
|
+
| `publishOptions` given and not a plain object | throws `[CookbookRouter] routeToService - publishOptions must be an object - …` |
|
|
62
71
|
| registry says the service is not `active` | throws `[CookbookRouter] Service not available: <name>` |
|
|
63
72
|
| the publish itself fails | the underlying error propagates unchanged |
|
|
64
73
|
|
|
65
74
|
## Options
|
|
66
75
|
|
|
67
|
-
The
|
|
68
|
-
verbatim to both collaborators, so their keys travel
|
|
76
|
+
The caller passes `{ logger, cacheTTL }`; everything else has a default.
|
|
77
|
+
`options` is forwarded verbatim to both collaborators, so their keys travel
|
|
78
|
+
through it:
|
|
69
79
|
|
|
70
80
|
| Key | Read by | Default |
|
|
71
81
|
|---|---|---|
|
|
72
|
-
| `logger` | `CookbookRouter`, `ServiceDiscovery`, `QueueManager` |
|
|
73
|
-
| `
|
|
74
|
-
| `cacheTTL` | `ServiceDiscovery` (ms) | `300000` |
|
|
82
|
+
| `logger` | `CookbookRouter`, `ServiceDiscovery`, `QueueManager` | **required** |
|
|
83
|
+
| `cacheTTL` | `ServiceDiscovery` (ms) | **required** |
|
|
75
84
|
| `ensureQueues` | `QueueManager` — assert the queue before the first publish | `true` |
|
|
76
85
|
| `queueOptions` | `QueueManager` — merged into every queue declaration | `{ durable: true }` |
|
|
77
86
|
| `publishOptions` | `QueueManager` — merged into every publish | `{ persistent: true }` |
|
|
78
87
|
|
|
88
|
+
What a `logger` IS comes from `@onlineapps/logger-contract` (pinned in
|
|
89
|
+
`f60f24c3`): `src/options.js` re-exports its `assertLogger`, so the four methods
|
|
90
|
+
and the wording below are the platform's, not this package's.
|
|
91
|
+
|
|
92
|
+
The two required keys are checked in the constructor, and each refusal names
|
|
93
|
+
the key:
|
|
94
|
+
|
|
95
|
+
| Condition | Result |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `logger` absent | throws `[<class>] logger is required - Expected: a logger with info/warn/error/debug, …` |
|
|
98
|
+
| `logger` missing a method | throws `[<class>] logger is incomplete - … missing: debug. Fix: pass a logger implementing all four.` |
|
|
99
|
+
| `cacheTTL` absent or `null` | throws `[ServiceDiscovery] cacheTTL is required - Expected: an integer >= 0, milliseconds …` |
|
|
100
|
+
| `cacheTTL` not an integer `>= 0` | throws `[ServiceDiscovery] cacheTTL is invalid - … got <value>.` |
|
|
101
|
+
|
|
102
|
+
`cacheTTL: 0` is a value, not an absence: it means **no cache** — every
|
|
103
|
+
`isServiceAvailable` lookup reaches the registry, and nothing is written to the
|
|
104
|
+
Map on the way. Until d.615 it was read as `options.cacheTTL || 300000`, so that
|
|
105
|
+
request silently became five minutes of cached `status`, and `logger` was read as
|
|
106
|
+
`options.logger || console`, so a caller who passed none had its output written
|
|
107
|
+
where nothing collects it.
|
|
108
|
+
|
|
109
|
+
It is also the **only** way to say it. `cacheEnabled` was a second key for the
|
|
110
|
+
same question until d.615b: with both, `{ cacheEnabled: true, cacheTTL: 0 }` and
|
|
111
|
+
`{ cacheEnabled: false, cacheTTL: 300000 }` were sayable, and in each of them one
|
|
112
|
+
of the two keys was a lie. The key is no longer read at all — a caller who still
|
|
113
|
+
passes it gets what the lifetime says.
|
|
114
|
+
|
|
79
115
|
A queue declaration and a message are two different things, so their defaults
|
|
80
116
|
are two objects with two owners. `queueOptions` reaches
|
|
81
117
|
`mqClient.assertQueue()`, whose declared option set is `durable`, `arguments`,
|
|
@@ -112,11 +148,16 @@ orchestrator's, not this package's.
|
|
|
112
148
|
## Collaborator contracts
|
|
113
149
|
|
|
114
150
|
- `registryClient.getService(serviceName)` resolves to an object whose `status`
|
|
115
|
-
is `'active'` when the service is up
|
|
116
|
-
|
|
151
|
+
is `'active'` when the service is up, or to `null` when the registry does not
|
|
152
|
+
hold the service — both are answers, and `isServiceAvailable` returns `false`
|
|
153
|
+
for anything but `'active'`. A rejection is not an answer: it is logged and
|
|
154
|
+
thrown as `code: 'SERVICE_DISCOVERY_FAILED'` with `serviceName` and the
|
|
155
|
+
rejection as `cause`, and `routeToService` lets it through (d.1086) — never
|
|
156
|
+
"Service not available". A failed lookup is not cached.
|
|
117
157
|
- `mqClient.assertQueue(queue, options)` and `mqClient.publish(queue, message,
|
|
118
|
-
options)`. `publish`
|
|
119
|
-
|
|
158
|
+
options)`. `publish` is called once per message; a rejection propagates
|
|
159
|
+
unchanged. Retry and buffering are the client's
|
|
160
|
+
(`docs/architecture/mq-publish-reliability.md` § The one rule for services).
|
|
120
161
|
|
|
121
162
|
## Related packages
|
|
122
163
|
|
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/cookbook-router",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "Message routing for cookbook workflows - handles service discovery and queue routing",
|
|
5
5
|
"oa": {
|
|
6
6
|
"category": "orchestration"
|
|
7
7
|
},
|
|
8
8
|
"main": "src/index.js",
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "npm run test:unit",
|
|
10
|
+
"test": "npm run test:unit && npm run test:integration",
|
|
11
11
|
"test:unit": "jest tests/unit",
|
|
12
|
+
"test:integration": "jest --config=jest.integration.config.js",
|
|
12
13
|
"test:watch": "jest --watch",
|
|
13
14
|
"test:coverage": "jest --coverage",
|
|
14
15
|
"docs": "jsdoc2md --files 'src/**/*.js' > API.md.tmp && mv API.md.tmp API.md || (rm -f API.md.tmp; exit 1)"
|
|
@@ -22,16 +23,23 @@
|
|
|
22
23
|
],
|
|
23
24
|
"author": "OnlineApps",
|
|
24
25
|
"license": "PROPRIETARY",
|
|
25
|
-
"dependencies": {
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@onlineapps/logger-contract": "2.0.0"
|
|
28
|
+
},
|
|
26
29
|
"devDependencies": {
|
|
30
|
+
"@onlineapps/conn-orch-registry": "8.0.0",
|
|
27
31
|
"jest": "^29.7.0",
|
|
28
|
-
"jsdoc-to-markdown": "^8.0.0"
|
|
32
|
+
"jsdoc-to-markdown": "^8.0.0",
|
|
33
|
+
"redis": "4.7.1"
|
|
29
34
|
},
|
|
30
35
|
"engines": {
|
|
31
36
|
"node": ">=24.0.0 <25"
|
|
32
37
|
},
|
|
33
38
|
"files": [
|
|
34
|
-
"src"
|
|
39
|
+
"src",
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
"README.md",
|
|
42
|
+
"API.md"
|
|
35
43
|
],
|
|
36
44
|
"publishConfig": {
|
|
37
45
|
"access": "public"
|
package/src/index.js
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* The package does NOT execute cookbooks, drive flow control, retry, complete
|
|
10
10
|
* or dead-letter them. Those belong to `WorkflowOrchestrator` — confirmation
|
|
11
11
|
* `api/docs/governance/confirmations/cookbook-execution-owner.md` 001 — which
|
|
12
|
-
* is also the only caller:
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* is also the only caller: its constructor takes `createRouter` (through
|
|
13
|
+
* `cookbook.createRouter`), and `processWorkflowMessage` and the method that
|
|
14
|
+
* hands the next step's message to the router call `routeToService` on the
|
|
15
|
+
* result. The second routing rail, the retry rail and the
|
|
15
16
|
* queue/registry administration this package used to carry had zero callers
|
|
16
17
|
* outside it and were removed on 2026-09-02.
|
|
17
18
|
*
|
package/src/options.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The option checks this package's three constructors share.
|
|
5
|
+
*
|
|
6
|
+
* ONE place, because all three read the same key with the same meaning: a
|
|
7
|
+
* second copy of "what a logger is" inside one package would be two rails for
|
|
8
|
+
* one concern (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
9
|
+
*
|
|
10
|
+
* For `logger` that one place is not here at all: the platform owns the concern
|
|
11
|
+
* in `@onlineapps/logger-contract`, and this module re-exports `assertLogger`
|
|
12
|
+
* from it. d.615 carried a hand-written copy, word for word identical, only
|
|
13
|
+
* because the package declared no dependencies; the publication wave pinned
|
|
14
|
+
* `@onlineapps/logger-contract 2.0.0` (`f60f24c3`), and a declared dependency
|
|
15
|
+
* nothing requires is what `change-discipline.md` § Removing something removes
|
|
16
|
+
* its declaration calls a defect. So the copy is gone — including its own
|
|
17
|
+
* `LOGGER_METHODS`, which this module no longer exports: whoever needs the list
|
|
18
|
+
* reads it from the package that defines it.
|
|
19
|
+
*
|
|
20
|
+
* `readCacheTTL` stays here. `cacheTTL` is this package's own option, read by
|
|
21
|
+
* `ServiceDiscovery` alone; no library owns it.
|
|
22
|
+
*
|
|
23
|
+
* @see /api/shared/logger-contract/src/index.js
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Renders a refused value for an error message without ever printing it as a
|
|
30
|
+
* bare word: `"300000"` and `300000` look identical otherwise, and the whole
|
|
31
|
+
* point of the refusal is that they are not the same thing.
|
|
32
|
+
*
|
|
33
|
+
* @param {*} value
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function describeValue(value) {
|
|
37
|
+
return typeof value === 'string' ? JSON.stringify(value) : String(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reads the discovery cache lifetime: a required, non-negative integer of
|
|
42
|
+
* milliseconds, where `0` means "no cache — every lookup reaches the registry".
|
|
43
|
+
*
|
|
44
|
+
* `options.cacheTTL || 300000` is what this replaces, and `0` was the value it
|
|
45
|
+
* destroyed: a caller switching the cache off was given five minutes of cached
|
|
46
|
+
* `status` instead, with nothing said. A default that inverts the one override
|
|
47
|
+
* it is asked for is not a default (`architecture-principles.md` §3, §8), and
|
|
48
|
+
* the absent case is not a value at all — it is a missing decision, so it
|
|
49
|
+
* throws (§4).
|
|
50
|
+
*
|
|
51
|
+
* @param {string} context - Name of the caller for the message
|
|
52
|
+
* @param {*} value - The value the caller passed as `options.cacheTTL`
|
|
53
|
+
* @returns {number}
|
|
54
|
+
* @throws {Error} when the value is absent, or is not an integer >= 0
|
|
55
|
+
*/
|
|
56
|
+
function readCacheTTL(context, value) {
|
|
57
|
+
if (value === undefined || value === null) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`[${context}] cacheTTL is required - Expected: an integer >= 0, milliseconds `
|
|
60
|
+
+ '(0 = no cache, every lookup reaches the registry). '
|
|
61
|
+
+ 'Fix: pass options.cacheTTL, e.g. 300000 for five minutes.'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`[${context}] cacheTTL is invalid - Expected: an integer >= 0, milliseconds `
|
|
68
|
+
+ `(0 = no cache); got ${describeValue(value)}. `
|
|
69
|
+
+ 'Fix: pass options.cacheTTL as a non-negative integer, or 0 to switch the cache off.'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
assertLogger,
|
|
78
|
+
readCacheTTL
|
|
79
|
+
};
|
package/src/queueManager.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { assertLogger } = require('./options');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* What describes the QUEUE. Handed to `mqClient.assertQueue()`, whose declared
|
|
5
7
|
* option set is `durable`, `arguments`, `exclusive`, `autoDelete`.
|
|
@@ -52,7 +54,11 @@ class QueueManager {
|
|
|
52
54
|
...DEFAULT_PUBLISH_OPTIONS,
|
|
53
55
|
...options.publishOptions
|
|
54
56
|
},
|
|
55
|
-
logger:
|
|
57
|
+
logger: assertLogger(
|
|
58
|
+
'QueueManager',
|
|
59
|
+
options.logger,
|
|
60
|
+
'a publish and its failure are reported somewhere that collects it'
|
|
61
|
+
)
|
|
56
62
|
};
|
|
57
63
|
|
|
58
64
|
this.ensuredQueues = new Set();
|
|
@@ -88,18 +94,15 @@ class QueueManager {
|
|
|
88
94
|
|
|
89
95
|
logger.debug(`Publishing to ${queueName}`);
|
|
90
96
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
throw error;
|
|
102
|
-
}
|
|
97
|
+
// ONE call. Retry, buffering and reconnect coordination live inside the
|
|
98
|
+
// client's `publish()` (`@onlineapps/mq-client-core`), so a rejection is its
|
|
99
|
+
// answer — the message was buffered for replay or refused for good — and a
|
|
100
|
+
// second call from here would be a second copy of it. Until d.1121 this
|
|
101
|
+
// published again whenever the rejection's text contained `Connection lost`:
|
|
102
|
+
// a service-side retry, decided on a sentence no client release produces.
|
|
103
|
+
// @see ../../../../docs/architecture/mq-publish-reliability.md § The one rule for services
|
|
104
|
+
await this.mqClient.publish(queueName, messageWithTimestamp, publishOptions);
|
|
105
|
+
return true;
|
|
103
106
|
|
|
104
107
|
} catch (error) {
|
|
105
108
|
logger.error(`Failed to publish to ${queueName}:`, error);
|
package/src/router.js
CHANGED
|
@@ -1,20 +1,34 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const ServiceDiscovery = require('./serviceDiscovery');
|
|
4
|
+
const QueueManager = require('./queueManager');
|
|
5
|
+
const { assertLogger } = require('./options');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A plain object: `{}` or `Object.create(null)` — not `null`, an array, or an
|
|
9
|
+
* instance of another class (a `Map` of options would reach the MQ client as an
|
|
10
|
+
* object with no own keys).
|
|
11
|
+
* @private
|
|
12
|
+
* @param {*} value
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
function isPlainObject(value) {
|
|
16
|
+
if (value === null || typeof value !== 'object') return false;
|
|
17
|
+
const proto = Object.getPrototypeOf(value);
|
|
18
|
+
return proto === Object.prototype || proto === null;
|
|
19
|
+
}
|
|
20
|
+
|
|
3
21
|
/**
|
|
4
22
|
* CookbookRouter - routes a workflow message to a service queue.
|
|
5
23
|
*
|
|
6
24
|
* ONE responsibility, ONE method: `routeToService`. Execution, flow control,
|
|
7
25
|
* retry, DLQ and completion belong to `WorkflowOrchestrator`
|
|
8
26
|
* (confirmation `api/docs/governance/confirmations/cookbook-execution-owner.md`
|
|
9
|
-
* 001), which is also this class's only caller
|
|
10
|
-
*
|
|
27
|
+
* 001), which is also this class's only caller: `processWorkflowMessage()` and
|
|
28
|
+
* the method that hands the next step's message to the router.
|
|
11
29
|
*
|
|
12
30
|
* @see /api/shared/cookbook/cookbook-router/README.md
|
|
13
31
|
*/
|
|
14
|
-
|
|
15
|
-
const ServiceDiscovery = require('./serviceDiscovery');
|
|
16
|
-
const QueueManager = require('./queueManager');
|
|
17
|
-
|
|
18
32
|
class CookbookRouter {
|
|
19
33
|
constructor(mqClient, registryClient, options = {}) {
|
|
20
34
|
this.mqClient = mqClient;
|
|
@@ -26,11 +40,20 @@ class CookbookRouter {
|
|
|
26
40
|
// keyed on `maxAttempts`/`baseDelay`, never on these. A declaration nothing
|
|
27
41
|
// reads is dead (`.claude/rules/change-discipline.md` § Removing).
|
|
28
42
|
// `options` is still forwarded whole to the two collaborators below, so
|
|
29
|
-
// their own keys (`
|
|
30
|
-
// `
|
|
43
|
+
// their own keys (`cacheTTL`, `ensureQueues`, `queueOptions`,
|
|
44
|
+
// `publishOptions`) reach them unchanged.
|
|
45
|
+
// `logger` used to default to `console` here. It no longer defaults at all:
|
|
46
|
+
// a router that logs where nothing collects is a router whose routing
|
|
47
|
+
// decisions are unobservable, and `||`/an implicit default is exactly what
|
|
48
|
+
// §3 bans. The check runs BEFORE the collaborators are built so the caller
|
|
49
|
+
// is told which object refused, in this class's own name.
|
|
31
50
|
this.options = {
|
|
32
|
-
|
|
33
|
-
|
|
51
|
+
...options,
|
|
52
|
+
logger: assertLogger(
|
|
53
|
+
'CookbookRouter',
|
|
54
|
+
options.logger,
|
|
55
|
+
'every routing decision is reported somewhere that collects it'
|
|
56
|
+
)
|
|
34
57
|
};
|
|
35
58
|
|
|
36
59
|
this.serviceDiscovery = new ServiceDiscovery(registryClient, options);
|
|
@@ -41,9 +64,17 @@ class CookbookRouter {
|
|
|
41
64
|
* Route message directly to a specific service
|
|
42
65
|
* @param {string} serviceName - Target service name
|
|
43
66
|
* @param {Object} message - Workflow message to send
|
|
67
|
+
* @param {Object} [publishOptions={}] - Publish options for this one message,
|
|
68
|
+
* handed unchanged as the third argument of `QueueManager.publish()`, which
|
|
69
|
+
* merges them over its message defaults and passes them to the MQ client
|
|
70
|
+
* (e.g. `{ bufferOnFailure: false }`). The router neither reads nor completes
|
|
71
|
+
* them: a key the MQ client does not know is the client's to ignore. Must be
|
|
72
|
+
* a plain object when given.
|
|
44
73
|
* @returns {Promise<void>}
|
|
74
|
+
* @throws {Error} when `publishOptions` is given and is not a plain object —
|
|
75
|
+
* before the registry is asked and before anything is published
|
|
45
76
|
*/
|
|
46
|
-
async routeToService(serviceName, message) {
|
|
77
|
+
async routeToService(serviceName, message, publishOptions = {}) {
|
|
47
78
|
const { logger } = this.options;
|
|
48
79
|
|
|
49
80
|
if (!serviceName || typeof serviceName !== 'string') {
|
|
@@ -52,6 +83,13 @@ class CookbookRouter {
|
|
|
52
83
|
if (!message || typeof message !== 'object') {
|
|
53
84
|
throw new Error('[CookbookRouter] routeToService - message is required and must be an object');
|
|
54
85
|
}
|
|
86
|
+
if (!isPlainObject(publishOptions)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
'[CookbookRouter] routeToService - publishOptions must be an object - '
|
|
89
|
+
+ 'Expected: a plain object of publish options (e.g. { bufferOnFailure: false }) or no third argument. '
|
|
90
|
+
+ 'Fix: pass the options QueueManager.publish() hands to the MQ client, or omit the argument'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
55
93
|
|
|
56
94
|
// Verify service is available
|
|
57
95
|
const isAvailable = await this.serviceDiscovery.isServiceAvailable(serviceName);
|
|
@@ -63,7 +101,7 @@ class CookbookRouter {
|
|
|
63
101
|
const queueName = `${serviceName}.workflow`;
|
|
64
102
|
logger.info(`[CookbookRouter] Routing to service: ${queueName}`);
|
|
65
103
|
|
|
66
|
-
await this.queueManager.publish(queueName, message);
|
|
104
|
+
await this.queueManager.publish(queueName, message, publishOptions);
|
|
67
105
|
}
|
|
68
106
|
}
|
|
69
107
|
|
package/src/serviceDiscovery.js
CHANGED
|
@@ -1,53 +1,91 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { assertLogger, readCacheTTL } = require('./options');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* ServiceDiscovery - Service discovery and health checking
|
|
5
7
|
*/
|
|
6
|
-
|
|
7
8
|
class ServiceDiscovery {
|
|
8
9
|
constructor(registryClient, options = {}) {
|
|
9
10
|
this.registryClient = registryClient;
|
|
11
|
+
// `...options` comes FIRST so the two checked keys below survive it — the
|
|
12
|
+
// same order `QueueManager` uses, and for the same reason: a spread placed
|
|
13
|
+
// last undoes the block it was written to extend.
|
|
10
14
|
this.options = {
|
|
11
|
-
|
|
12
|
-
cacheTTL: options.cacheTTL
|
|
13
|
-
logger:
|
|
14
|
-
|
|
15
|
+
...options,
|
|
16
|
+
cacheTTL: readCacheTTL('ServiceDiscovery', options.cacheTTL),
|
|
17
|
+
logger: assertLogger(
|
|
18
|
+
'ServiceDiscovery',
|
|
19
|
+
options.logger,
|
|
20
|
+
'a failed registry lookup is reported somewhere that collects it'
|
|
21
|
+
)
|
|
15
22
|
};
|
|
16
23
|
|
|
17
24
|
this.cache = new Map();
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
/**
|
|
21
|
-
* Check if a service is available
|
|
28
|
+
* Check if a service is available.
|
|
29
|
+
*
|
|
30
|
+
* `false` is the registry's answer: the service is there and not active, or the
|
|
31
|
+
* projection does not hold it (`getService()` resolved to `null`). A lookup the
|
|
32
|
+
* registry could not answer is not that answer, so it is thrown with its cause
|
|
33
|
+
* rather than read as "not available" (d.1086; architecture-principles.md §3–§5).
|
|
34
|
+
* A failed lookup is never cached.
|
|
35
|
+
*
|
|
22
36
|
* @param {string} serviceName - Service name
|
|
23
37
|
* @returns {Promise<boolean>}
|
|
38
|
+
* @throws {Error} `code: 'SERVICE_DISCOVERY_FAILED'`, `serviceName`, and the
|
|
39
|
+
* registry's rejection as `cause`, when `registryClient.getService()` rejects;
|
|
40
|
+
* `type` copied from the cause when the cause declares one (d.1093).
|
|
24
41
|
*/
|
|
25
42
|
async isServiceAvailable(serviceName) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const service = await this.registryClient.getService(serviceName);
|
|
36
|
-
|
|
37
|
-
if (service && this.options.cacheEnabled) {
|
|
38
|
-
this.setCached(serviceName, service);
|
|
39
|
-
}
|
|
43
|
+
// Whether there is anything to serve is `getCached`'s answer, and it reads
|
|
44
|
+
// the ONE value that decides it — the lifetime. Until d.615b a second key,
|
|
45
|
+
// `cacheEnabled`, guarded these two branches as well, so "do not cache"
|
|
46
|
+
// had two spellings that could contradict each other
|
|
47
|
+
// (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
48
|
+
const cached = this.getCached(serviceName);
|
|
49
|
+
if (cached !== null) {
|
|
50
|
+
return cached.status === 'active';
|
|
51
|
+
}
|
|
40
52
|
|
|
41
|
-
|
|
53
|
+
let service;
|
|
54
|
+
try {
|
|
55
|
+
service = await this.registryClient.getService(serviceName);
|
|
42
56
|
} catch (error) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
this.options.logger.error(`Service discovery failed for ${serviceName}:`, error);
|
|
58
|
+
// The cause is quoted as a sentence. The registry's own refusals are whole
|
|
59
|
+
// `[Context] Problem - Expected/Fix` sentences that already end with a
|
|
60
|
+
// full stop (conn-orch-registry 8.0.0), so the stop is added only to a
|
|
61
|
+
// cause that does not close its own sentence (d.1202).
|
|
62
|
+
const cause = /[.!?]$/.test(error.message) ? error.message : `${error.message}.`;
|
|
63
|
+
const failure = new Error(
|
|
64
|
+
`[ServiceDiscovery] Registry lookup for service '${serviceName}' failed - ${cause} `
|
|
65
|
+
+ 'Expected: the registry answers with the service summary or null. '
|
|
66
|
+
+ 'Fix: make the registry projection reachable to this service (the cause names what failed); '
|
|
67
|
+
+ 'an unanswered lookup is never read as an unavailable service.',
|
|
68
|
+
{ cause: error }
|
|
69
|
+
);
|
|
70
|
+
failure.code = 'SERVICE_DISCOVERY_FAILED';
|
|
71
|
+
failure.serviceName = serviceName;
|
|
72
|
+
// The verdict is made where the failure is born, never here (d.1093): the
|
|
73
|
+
// registry client types a lookup the projection could not answer, and the
|
|
74
|
+
// L1 classifier reads `type` without knowing what a registry or Redis is.
|
|
75
|
+
// Carried only when the cause has one — no type is invented for a cause
|
|
76
|
+
// that declared none.
|
|
77
|
+
if (error.type !== undefined) {
|
|
78
|
+
failure.type = error.type;
|
|
48
79
|
}
|
|
80
|
+
throw failure;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (service === null) {
|
|
49
84
|
return false;
|
|
50
85
|
}
|
|
86
|
+
|
|
87
|
+
this.setCached(serviceName, service);
|
|
88
|
+
return service.status === 'active';
|
|
51
89
|
}
|
|
52
90
|
|
|
53
91
|
/**
|
|
@@ -55,18 +93,19 @@ class ServiceDiscovery {
|
|
|
55
93
|
* @private
|
|
56
94
|
*/
|
|
57
95
|
getCached(serviceName) {
|
|
58
|
-
if (!this.options.cacheEnabled) {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
96
|
const cached = this.cache.get(serviceName);
|
|
63
97
|
|
|
64
98
|
if (!cached) {
|
|
65
99
|
return null;
|
|
66
100
|
}
|
|
67
101
|
|
|
102
|
+
// `>=`, not `>`: an entry whose age has REACHED the lifetime is spent, and
|
|
103
|
+
// that is what makes `cacheTTL: 0` mean what it says — every age is `>= 0`,
|
|
104
|
+
// so no entry is ever served and each lookup reaches the registry. With
|
|
105
|
+
// `>` the two lookups inside one millisecond would have hit the cache, and
|
|
106
|
+
// "no cache" would have been true only most of the time.
|
|
68
107
|
const age = Date.now() - cached.timestamp;
|
|
69
|
-
if (age
|
|
108
|
+
if (age >= this.options.cacheTTL) {
|
|
70
109
|
this.cache.delete(serviceName);
|
|
71
110
|
return null;
|
|
72
111
|
}
|
|
@@ -79,6 +118,14 @@ class ServiceDiscovery {
|
|
|
79
118
|
* @private
|
|
80
119
|
*/
|
|
81
120
|
setCached(serviceName, data) {
|
|
121
|
+
// `cacheTTL: 0` means no cache, so there is nothing to remember: `getCached`
|
|
122
|
+
// compares `age >= cacheTTL`, so an entry written with a zero lifetime is
|
|
123
|
+
// spent the moment it exists and the Map would grow with entries no lookup
|
|
124
|
+
// can ever be served from. Same single value decides here as there.
|
|
125
|
+
if (this.options.cacheTTL === 0) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
82
129
|
this.cache.set(serviceName, {
|
|
83
130
|
data,
|
|
84
131
|
timestamp: Date.now()
|