@onlineapps/mq-client-core 4.1.0 → 4.2.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/CHANGELOG.md +143 -0
- package/README.md +127 -0
- package/package.json +1 -1
- package/src/config/queueConfig.js +74 -22
- package/src/config/recurrencesSchema.js +121 -0
- package/src/index.js +26 -0
- package/src/scheduler-request.js +481 -0
- package/src/transports/rabbitmqClient.js +66 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,149 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [4.2.0] — 2026-09-25
|
|
8
|
+
|
|
9
|
+
### Added — služba deklaruje své rekurence a mluví s plánovačem jednou kolejí (d.833, d.840)
|
|
10
|
+
|
|
11
|
+
`docs/architecture/scheduler.md` §7 dává klientské knihovně tvar jedné rekurence
|
|
12
|
+
a wrapperu úkol při každém bootu ten stav znovu ohlásit. Odkud služba rekurence bere,
|
|
13
|
+
je její věc — odpovídá jedním hookem (`scheduler: { recurrences: async () => [...] }`),
|
|
14
|
+
platformní konstantním seznamem, tenantové odvozené z vlastních dat (conf `scheduler` 011).
|
|
15
|
+
Knihovna nese tvar a cestu ke zprávě:
|
|
16
|
+
`scheduler.validateRecurrences(document, source)`,
|
|
17
|
+
`scheduler.buildRecurringRequest({ declaration, tenantId, workspaceId, requestedBy })`,
|
|
18
|
+
`scheduler.publishSchedulerRequest(mqClient, request)` a jméno fronty
|
|
19
|
+
`scheduler.requestQueue` (složené z deklarace v `queueConfig`, ne opsané).
|
|
20
|
+
|
|
21
|
+
Záznam rekurence má u knihovny dvě podoby, obě jsou jejím vstupem:
|
|
22
|
+
|
|
23
|
+
- **záznam deklarace** pro `validateRecurrences()` a `buildRecurringRequest({ declaration, … })`
|
|
24
|
+
ve snake_case, jak ho drží schéma `src/config/recurrencesSchema.js`:
|
|
25
|
+
`{ id, cookbook_ref, cron, timezone, input?, catch_up? }` (první čtyři povinné,
|
|
26
|
+
`additionalProperties: false`); tenancy v něm není — stavitel ji bere zvlášť jako
|
|
27
|
+
`tenantId` a `workspaceId`;
|
|
28
|
+
- **záznam portu** `createSchedulerPublisher({ mqClient, requestedBy }).registerRecurring(…)`
|
|
29
|
+
v camelCase: `{ id, cookbookRef, cron, timezone, tenantId, workspaceId, input?, catchUp? }`.
|
|
30
|
+
Port ho převede na záznam deklarace a tenancy předá staviteli.
|
|
31
|
+
|
|
32
|
+
`id` cestuje jako `idempotency_key`, takže re-registrace je upsert a každý boot smí
|
|
33
|
+
deklarovaný stav zopakovat. Dvě stejná `id` v jedné odpovědi jsou odmítnuta — tiše by se
|
|
34
|
+
přepisovala. `source` u `validateRecurrences` je povinný: každá hláška ho cituje, takže
|
|
35
|
+
výchozí hodnota by psala jméno jednoho volajícího k chybě druhého.
|
|
36
|
+
|
|
37
|
+
**Tenant a workspace dodává VOLAJÍCÍ u každé rekurence — nikdy prostředí, nikdy výchozí
|
|
38
|
+
hodnota.** Obojí je povinné a ≥ 1 (vlastník zamítl `0/0` jako platformní scope —
|
|
39
|
+
konfirmace `scheduler` 002); chybí-li, stavitel hází jménem klíče. Rekurence služba odvozuje
|
|
40
|
+
ze svých dat (conf `scheduler` 010) a hlásí je jedinou kolejí (011), takže tenancy nese
|
|
41
|
+
každý záznam: boot hook wrapperu (FÁZE 0.7b) ji u každého vráceného záznamu VYŽADUJE, protože
|
|
42
|
+
při bootu žádná obálka invokace není; port `ctx.scheduler` v handleru ji naopak doplní
|
|
43
|
+
z obálky invokace a od volajícího ji odmítne. `catch_up` je klíč záznamu portu a požadavku,
|
|
44
|
+
ne deklarace, kterou si služba drží ve svých datech (conf `scheduler` 012 a 018).
|
|
45
|
+
|
|
46
|
+
**Fixed — hlášky o chybějící tenancy posílaly čtenáře na špatné místo (d.864).** Stavitel
|
|
47
|
+
rekurence i události odmítal chybějící `tenantId` s Fix „pass the tenant the environment
|
|
48
|
+
declares for this service" a chybějící `workspaceId` s Fix „pass the workspace the cookbook
|
|
49
|
+
declares (defaults.workspace_id)". Ani jedno není zdroj, ze kterého hodnota na téhle cestě
|
|
50
|
+
jde: Fix teď říká, že se obě hodnoty podávají s každým požadavkem — rekurence je nese
|
|
51
|
+
v záznamu, který služba vrací ze svých dat, a `ctx.scheduler` je doplní z obálky invokace;
|
|
52
|
+
odjinud se nečtou. Chování se nemění, mění se jen to, kam hláška posílá. Totéž u odmítnutí
|
|
53
|
+
záznamu v `validateRecurrences()`: poznámka „tenant a workspace sem nepatří" citovala conf
|
|
54
|
+
`tier1-runner-workspace` 001, která mluví o jiném běhu; teď cituje conf `scheduler` 010 a 011
|
|
55
|
+
(d.864b).
|
|
56
|
+
|
|
57
|
+
**Prázdná odpověď je odpověď**, ne chyba: služba bez rozvrhů hook nemá, nebo vrátí prázdný
|
|
58
|
+
seznam, a wrapper svůj krok přeskočí. Odpověď, která NEOBSTOJÍ, hází — nezaregistrovaný
|
|
59
|
+
rozvrh je práce, která se nikdy nestane, a nic jiného na to neupozorní. Ze stejného důvodu
|
|
60
|
+
je odeslání **fail-fast**, na rozdíl od sousedních pomocníků monitoringu.
|
|
61
|
+
|
|
62
|
+
Zpráva se staví přesně podle kontraktu plánovače
|
|
63
|
+
(`api/infra/api_scheduler/contracts/scheduler.request.schema.json`, `additionalProperties:
|
|
64
|
+
false`) a sada to proti TOMU kontraktu ověřuje Ajvem, ne proti kopii schématu.
|
|
65
|
+
|
|
66
|
+
**d.840 — zbylé dva druhy a jedno odeslání.** Vedle `buildRecurringRequest()` přibyly
|
|
67
|
+
`scheduler.buildEventRequest({ event: { service, name }, idempotencyKey, tenantId,
|
|
68
|
+
workspaceId, requestedBy, input? })` a `scheduler.buildCancelRequest({ idempotencyKey,
|
|
69
|
+
requestedBy })`, a všechny tři druhy odesílá JEDNA funkce
|
|
70
|
+
`scheduler.publishSchedulerRequest(mqClient, request)` — druh je pole zprávy, ne druhá
|
|
71
|
+
funkce. (Nevydané `publishRecurringRequest` tím zaniká; čtenáře mimo balíček nemělo,
|
|
72
|
+
takže přechodový alias nevzniká — pravidlo 11.)
|
|
73
|
+
|
|
74
|
+
Událost NEJMENUJE recept: plánovač spustí každý recept, jehož `triggers` tu událost
|
|
75
|
+
deklarují (conf `scheduler` 004), takže producent, který by recept jmenoval, by mapoval
|
|
76
|
+
práci na něco, co nemusí vlastnit (conf `ingest-poll-trigger-rail` 001). `cancel` nenese
|
|
77
|
+
tenanta ani workspace — klíč jmenuje položku, která si kontext nese od registrace, a
|
|
78
|
+
zrušení neexistujícího klíče je výsledek (`cancel-nothing`), ne chyba.
|
|
79
|
+
|
|
80
|
+
**Klíč je vždy volajícího.** `idempotency_key` vyžaduje kořen kontraktu u všech druhů a
|
|
81
|
+
plánovač podle něj jednorázovou položku rozhodne JEDNOU (druhá událost s týmž klíčem =
|
|
82
|
+
`duplicate`), takže klíč vyrobený knihovnou by z každého opakování jednoho faktu udělal
|
|
83
|
+
druhý běh. Žádný stavitel ho proto nevymýšlí.
|
|
84
|
+
|
|
85
|
+
**Port `scheduler.createSchedulerPublisher({ mqClient, requestedBy })`** vrací tři slovesa
|
|
86
|
+
(`registerRecurring`, `publishEvent`, `cancel`) se jménem služby už rozhodnutým. Klient
|
|
87
|
+
i jméno fronty se ověřují PŘI KONSTRUKCI — služba, která ohlašuje fakty, má spadnout při
|
|
88
|
+
bootu, ne u prvního dokladu — a každé odmítnutí jmenuje slot a Fix, aby vada wiringu
|
|
89
|
+
neodpověděla místo operace. Tenancy zůstává na každém volání výslovná, protože port drží
|
|
90
|
+
dva různí volající: služba pod wrapperem (doplní ji z obálky běhu) a operátorský skript,
|
|
91
|
+
který wrapper vůbec nebootuje a pracuje řádek po řádku (měření BIZ-ingest 23. 9.).
|
|
92
|
+
|
|
93
|
+
Při té příležitosti se rozšířil tvar `id` v záznamu rekurence: schéma ho dosud omezovalo
|
|
94
|
+
na malá písmena a pomlčky, což je přísnější než konzument (jakýkoli řetězec 1–255 znaků) a
|
|
95
|
+
odmítalo by to konvenci prvního volajícího `<source_uuid>:<cookbook_ref>`. Nově se odmítá
|
|
96
|
+
jen bílé místo, které by z klíče udělalo větu.
|
|
97
|
+
### Changed — port `registerRecurring()` klíč, který požadavek nedeklaruje, odmítne (d.864b)
|
|
98
|
+
|
|
99
|
+
`createSchedulerPublisher(…).registerRecurring()` bral jen pojmenované klíče a cokoli
|
|
100
|
+
navíc tiše zahodil — takže překlep (`catch_up` místo `catchUp`) prošel a rozvrh se
|
|
101
|
+
zaregistroval bez toho, co volající chtěl. Port je teď stejně přísný jako záznam
|
|
102
|
+
deklarace, na který se převádí (`additionalProperties: false`): klíč mimo
|
|
103
|
+
`{ id, cookbookRef, cron, timezone, tenantId, workspaceId, input?, catchUp? }` je odmítnutí,
|
|
104
|
+
které ho jmenuje. Port je ve vydání nový, takže to žádného volajícího vydané verze nerozbije;
|
|
105
|
+
změřeno, že dnešní volající (boot hook a `ctx.scheduler` wrapperu, `declaredRecurrences`
|
|
106
|
+
ingestu) klíč navíc neposílají.
|
|
107
|
+
|
|
108
|
+
### Added — výměna `dlx` je v `queueConfig` deklarovaná i s typem a trvanlivostí (d.902)
|
|
109
|
+
|
|
110
|
+
`deadLetterExchange()` vracel jen jméno `dlx`; typ `direct` a `durable: true` žily jen
|
|
111
|
+
u zakladatele (`@onlineapps/conn-infra-mq` `QueueManager.setupServiceQueues()`), takže
|
|
112
|
+
CI nástroj INFRA `scripts/ci/declare-broker-exchanges.mjs` je nesl jako opsanou druhou
|
|
113
|
+
kopii (TODO INFRA 349). Nová sekce `deadLetter` deklaruje `dlx` stejným tvarem jako každá
|
|
114
|
+
jiná výměna (`type: 'exchange'`, `exchangeType: 'direct'`, `durable: true`), a protože
|
|
115
|
+
jméno na drátě prefix nemá, `SECTION_PREFIXES.deadLetter` je `''` — deklarovaný případ,
|
|
116
|
+
který `queueName()` čte výslovně (`queueName('deadLetter', 'dlx') === 'dlx'`); sekce bez
|
|
117
|
+
deklarovaného prefixu je dál odmítnuta toutéž hláškou. `deadLetterExchange()` jméno skládá
|
|
118
|
+
z téže položky. Seznam front (`infrastructurePrefixes()`, vstupy `initInfrastructureQueues`)
|
|
119
|
+
se nemění — změřeno před/po; počet deklarovaných položek 36 → 37, výměn 6 → 7, front 30.
|
|
120
|
+
|
|
121
|
+
### Fixed — handshake, který doběhne po `connectTimeout`, se zavře; nezůstane spojení bez vlastníka (INFRA 359, d.917)
|
|
122
|
+
|
|
123
|
+
`connect()` i `_reconnectWithBackoff()` (`src/transports/rabbitmqClient.js`) závodily
|
|
124
|
+
`amqp.connect()` s časovačem `connectTimeout`. Když vyhrál časovač, volající dostal chybu
|
|
125
|
+
timeoutu, ale handshake běžel dál a mohl doběhnout do úplného AMQP spojení, na které
|
|
126
|
+
žádné pole klienta neukazovalo — `disconnect()` ho tedy nezavřel, spojení viselo na
|
|
127
|
+
brokeru a proces neskončil (INFRA 359, změřeno mimo jest: mlčící TCP server a proxy
|
|
128
|
+
zdržující handshake o 11 s proti `connectTimeout` 10 000 ms). Obě místa teď závodí
|
|
129
|
+
jedním pomocníkem `_raceHandshake()`: opuštěný handshake se po doběhnutí zavře (varování
|
|
130
|
+
v logu), handshake, který po timeoutu selže, nic neotevřel. Kontrakt se nemění —
|
|
131
|
+
`connect()` odmítne timeoutem stejnou chybou jako dřív a včas doběhlé spojení zůstává
|
|
132
|
+
spojením klienta.
|
|
133
|
+
|
|
134
|
+
### Changed — TTL telemetrie 60 s → 300 s (conf telemetry-queue-ttl 001, INFRA 356)
|
|
135
|
+
|
|
136
|
+
`x-message-ttl` tří front `telemetry.logs.queue`, `telemetry.metrics.queue` a
|
|
137
|
+
`telemetry.traces.queue` (`src/config/queueConfig.js`, sekce `telemetry`) je 300000 místo
|
|
138
|
+
60000 — rozhodnutí vlastníka, varianta B (conf `telemetry-queue-ttl` 001). Proč: konzument
|
|
139
|
+
telemetrie stojí v okně deploye (stop před migracemi, `up -d` až po nich); při prvním
|
|
140
|
+
produkčním deployi `f03fad7c` bez něj byl 159 s a každá zpráva starší než 60 s expirovala
|
|
141
|
+
do `telemetry.dlq` (1414, všechny `x-death.reason = expired`), takže brána 9b dala NO-GO na
|
|
142
|
+
`dlqMaxMessages` 100 za ztrátu, kterou způsobil sám deploy. 300 s pokryje okno deploye.
|
|
143
|
+
Nemění se: `x-max-length` 100000 (paměťový strop), `telemetry.dlq` (`x-max-length` 50000,
|
|
144
|
+
bez TTL), dead-letter cesta ani práh brány 9b; fronty `delivery.*` a další s TTL 60 s
|
|
145
|
+
zůstávají. Provozní důsledek: fronta existující na brokeru s `x-message-ttl` 60000 dá při
|
|
146
|
+
bootu konzumenta na 4.2.0 406 `PRECONDITION_FAILED` (`initInfrastructureQueues` hází,
|
|
147
|
+
nikdy nemaže) — smazání a znovuvytvoření tří front v okně deploye je krok deploye INFRA
|
|
148
|
+
(conf `mq-consumer-contract` bod 4 a conf `telemetry-queue-ttl` 001 Conditions/limits).
|
|
149
|
+
|
|
7
150
|
## [4.1.0] — 2026-09-23
|
|
8
151
|
|
|
9
152
|
### Added — plánovač se dozví KONEC běhu z vlastní fronty (d.818)
|
package/README.md
CHANGED
|
@@ -619,8 +619,16 @@ queueConfig.isBusinessQueue('emailer.workflow'); // true
|
|
|
619
619
|
queueConfig.getInfrastructureQueueConfig('workflow.init'); // { durable, arguments }
|
|
620
620
|
queueConfig.getDeadLetterRoute('workflow.init'); // { exchange: '', routingKey: 'workflow.failed' }
|
|
621
621
|
queueConfig.isDeadLetterTarget('workflow.failed'); // true — the end of that chain
|
|
622
|
+
queueConfig.deadLetterExchange(); // 'dlx' — composed from queueConfig.deadLetter
|
|
622
623
|
```
|
|
623
624
|
|
|
625
|
+
- The platform's dead-letter exchange is declared like every other exchange here —
|
|
626
|
+
`queueConfig.deadLetter.dlx` is `{ type: 'exchange', exchangeType: 'direct', durable: true }`,
|
|
627
|
+
the shape its founder (`@onlineapps/conn-infra-mq` `setupServiceQueues()`) asserts. Its wire
|
|
628
|
+
name carries no prefix: the `deadLetter` section declares an empty one, so
|
|
629
|
+
`queueName('deadLetter', 'dlx')` is `'dlx'`, and a reader walking the `type: 'exchange'`
|
|
630
|
+
entries finds it with the rest.
|
|
631
|
+
|
|
624
632
|
- `workflow.failed` carries no `x-message-ttl`. It is read by an operator, not by a
|
|
625
633
|
service, and it has no dead-letter exchange, so a TTL there does not move a message
|
|
626
634
|
anywhere — it deletes it. See
|
|
@@ -720,6 +728,125 @@ trade a real effect for a record of it — a lost copy is reported through the l
|
|
|
720
728
|
answers `false`. The one exception is a missing logger, which is the transport half's rule:
|
|
721
729
|
without it the promise "every failure is reported" cannot hold.
|
|
722
730
|
|
|
731
|
+
**The two builders of this package fail differently, on purpose.** This one answers `false` and
|
|
732
|
+
reports; `scheduler.publishSchedulerRequest()` below throws. A lost monitoring copy is a missing
|
|
733
|
+
record of work that happened; a lost registration is work that will never happen, and nothing
|
|
734
|
+
rings for it.
|
|
735
|
+
|
|
736
|
+
### scheduler — what a service says to the platform's owner of time
|
|
737
|
+
|
|
738
|
+
A service that runs work on a schedule ANSWERS with the recurrences it owns, through one hook
|
|
739
|
+
the wrapper asks at boot, and the wrapper re-asserts each of them as a `scheduler.request` of
|
|
740
|
+
kind `recurring` ([scheduler.md §7](/docs/architecture/scheduler.md)). Re-registration is an
|
|
741
|
+
upsert — a recurring request's `idempotency_key` IS the schedule id — so a boot that re-asserts
|
|
742
|
+
the declared state costs nothing and drifts nowhere.
|
|
743
|
+
|
|
744
|
+
ONE RAIL FOR BOTH KINDS (confirmation `scheduler` 011): the hook answers a constant list for
|
|
745
|
+
platform recurrences and a list derived from the service's own data for tenant ones. This
|
|
746
|
+
package owns the SHAPE of a record and the message built from it — never where the record comes
|
|
747
|
+
from, which is the service's business.
|
|
748
|
+
|
|
749
|
+
```javascript
|
|
750
|
+
// In the service, the hook the wrapper asks at boot (FÁZE 0.7b). Every record carries its own
|
|
751
|
+
// tenancy, derived from the service's data — at boot there is no invocation envelope to take
|
|
752
|
+
// it from (confirmations `scheduler` 002, 010):
|
|
753
|
+
new ServiceWrapper({
|
|
754
|
+
scheduler: {
|
|
755
|
+
recurrences: async () => ([
|
|
756
|
+
{ id: '31bf8bac:servant-poll', cookbookRef: 'servant-poll', cron: '0 * * * *',
|
|
757
|
+
timezone: 'Europe/Prague', tenantId: 101, workspaceId: 1, input: { source_uuid: '31bf8bac' } }
|
|
758
|
+
])
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// What the wrapper does with the answer — the port this package builds:
|
|
763
|
+
const { scheduler } = require('@onlineapps/mq-client-core');
|
|
764
|
+
const port = scheduler.createSchedulerPublisher({ mqClient, requestedBy: serviceName });
|
|
765
|
+
for (const record of answered) await port.registerRecurring(record);
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
The port record is camelCase — `{ id, cookbookRef, cron, timezone, tenantId, workspaceId,
|
|
769
|
+
input?, catchUp? }` — and the port turns it into the declaration record below, which is what
|
|
770
|
+
`validateRecurrences()` and `buildRecurringRequest({ declaration, tenantId, workspaceId,
|
|
771
|
+
requestedBy })` take. One declaration record, and nothing else:
|
|
772
|
+
|
|
773
|
+
| Key | Required | Meaning |
|
|
774
|
+
|---|---|---|
|
|
775
|
+
| `id` | ✅ | The schedule's stable id; it travels as `idempotency_key`, so two records of one id are refused |
|
|
776
|
+
| `cookbook_ref` | ✅ | The cookbook to run. Its `triggers` must name this service and the kind `recurring`, or the scheduler refuses the request |
|
|
777
|
+
| `cron` | ✅ | Five fields, the same expression the scheduler's contract takes |
|
|
778
|
+
| `timezone` | ✅ | An explicit IANA zone; there is no implicit one |
|
|
779
|
+
| `input` | – | Handed to the cookbook unchanged — a scheduled run that writes says so in the record, while the same cookbook run by hand can be given a different input |
|
|
780
|
+
| `catch_up` | – | Absent means false, and the scheduler materialises that default |
|
|
781
|
+
|
|
782
|
+
- **Tenancy is the caller's, on every recurrence — never the environment, never a default.**
|
|
783
|
+
Both ids are mandatory and ≥ 1 (the owner rejected `0/0` as a platform scope, confirmation
|
|
784
|
+
`scheduler` 002), and a missing one is refused by name. The declaration record above does not
|
|
785
|
+
carry them; the builder takes them beside it, and the port record carries them per entry.
|
|
786
|
+
Who fills them depends on where the call comes from: the boot hook's records must carry
|
|
787
|
+
them (no envelope exists at boot), while `ctx.scheduler` inside a handler fills them from the
|
|
788
|
+
invocation envelope and refuses them from the caller.
|
|
789
|
+
- **An empty answer is an answer.** A service with no recurrences has no hook, or answers an
|
|
790
|
+
empty list, and the wrapper skips its step. An answer that does NOT hold throws — a
|
|
791
|
+
recurrence nobody registered is work that never happens, and nothing else rings for it.
|
|
792
|
+
- **`validateRecurrences(document, source)` names its source**, and the name is required: every
|
|
793
|
+
refusal quotes it, so a default here would put one caller's name on another caller's mistake.
|
|
794
|
+
- **Sending is fail-fast**, unlike the monitoring helpers beside it. A lost monitoring copy
|
|
795
|
+
is a missing record of work that happened; a lost registration is work that will not happen.
|
|
796
|
+
- `scheduler.requestQueue` is the queue name, composed from the declaration in `queueConfig`
|
|
797
|
+
— a caller never types `scheduler.request`.
|
|
798
|
+
|
|
799
|
+
Two more things a service says on that queue, and one send for all of them:
|
|
800
|
+
|
|
801
|
+
```javascript
|
|
802
|
+
// THAT something happened. It names no cookbook: the scheduler starts every recipe whose
|
|
803
|
+
// `triggers` declare this event, so a producer naming a recipe would map work to something it
|
|
804
|
+
// may not own.
|
|
805
|
+
await scheduler.publishSchedulerRequest(mqClient, scheduler.buildEventRequest({
|
|
806
|
+
event: { service: 'biz-ingest', name: 'job-persisted' },
|
|
807
|
+
idempotencyKey: `biz-ingest:job-persisted:${jobId}`, // the identity of the fact
|
|
808
|
+
tenantId, workspaceId, requestedBy: serviceName,
|
|
809
|
+
input: { source_uuid } // optional, handed to every cookbook it starts
|
|
810
|
+
}));
|
|
811
|
+
|
|
812
|
+
// An item back, by the key it was registered with.
|
|
813
|
+
await scheduler.publishSchedulerRequest(mqClient, scheduler.buildCancelRequest({
|
|
814
|
+
idempotencyKey: 'servant-poll',
|
|
815
|
+
requestedBy: serviceName
|
|
816
|
+
}));
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
- **The key is always the caller's.** `idempotency_key` is required for every kind, and the
|
|
820
|
+
scheduler decides a one-shot item ONCE by it — a second `event` carrying a known key is
|
|
821
|
+
answered `duplicate`. A key this package invented per call would turn every retry of one fact
|
|
822
|
+
into a second run, so no builder makes one up.
|
|
823
|
+
- **`cancel` carries no tenancy**, and needs none: the key names an item that already holds the
|
|
824
|
+
context it was registered with. Cancelling a key that names nothing is a result rather than an
|
|
825
|
+
error — the scheduler answers `cancel-nothing` — so the builder judges nothing about it.
|
|
826
|
+
- **One send, `publishSchedulerRequest()`**, for every kind: the kind is a field of the message,
|
|
827
|
+
never a second function. It is fail-fast like the registration above, and it refuses a message
|
|
828
|
+
none of these builders produced.
|
|
829
|
+
|
|
830
|
+
A service that speaks to the scheduler more than once holds a port instead of repeating its own
|
|
831
|
+
name and the queue:
|
|
832
|
+
|
|
833
|
+
```javascript
|
|
834
|
+
const port = scheduler.createSchedulerPublisher({ mqClient, requestedBy: serviceName });
|
|
835
|
+
|
|
836
|
+
await port.registerRecurring({ id, cookbookRef, cron, timezone, tenantId, workspaceId, input, catchUp });
|
|
837
|
+
await port.publishEvent({ name: 'job-persisted', idempotencyKey: jobUuid, tenantId, workspaceId, input });
|
|
838
|
+
await port.cancel({ id });
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
- **The client and the queue are decided at construction**, not at the first call: a service that
|
|
842
|
+
announces facts has to fall over at boot rather than at its first document. On a pin whose
|
|
843
|
+
`queueConfig` declares no `scheduler` section this module refuses when it is required, earlier
|
|
844
|
+
still. Every refusal names the slot it is about and how to fix it, so a defect of the wiring can
|
|
845
|
+
never be read as the operation's own error.
|
|
846
|
+
- **Tenancy stays explicit on every call**, because two kinds of caller hold this port: a service
|
|
847
|
+
under the wrapper, which fills it from the envelope of the run, and an operator script that does
|
|
848
|
+
not boot the wrapper at all and works row by row.
|
|
849
|
+
|
|
723
850
|
### redactUrl — a connection URL that is safe to log
|
|
724
851
|
|
|
725
852
|
`RABBITMQ_URL` carries the broker account, so anything that renders it verbatim
|
package/package.json
CHANGED
|
@@ -61,7 +61,12 @@ const SECTION_PREFIXES = Object.freeze({
|
|
|
61
61
|
telemetry: 'telemetry',
|
|
62
62
|
alerts: 'alert',
|
|
63
63
|
registry: 'registry',
|
|
64
|
-
scheduler: 'scheduler'
|
|
64
|
+
scheduler: 'scheduler',
|
|
65
|
+
// The one section whose names travel WITHOUT a prefix: `dlx` is the wire name every
|
|
66
|
+
// business queue dead-letters to (`x-dead-letter-exchange`), and renaming it would
|
|
67
|
+
// re-declare live topology. The empty prefix is a declared case, read explicitly by
|
|
68
|
+
// `queueName()`; a section absent from this map is still refused (d.902).
|
|
69
|
+
deadLetter: ''
|
|
65
70
|
});
|
|
66
71
|
|
|
67
72
|
/**
|
|
@@ -629,10 +634,17 @@ module.exports = {
|
|
|
629
634
|
/**
|
|
630
635
|
* monitoring.workflow - Unified workflow lifecycle monitoring
|
|
631
636
|
* Bound to monitoring.workflow.fanout exchange (fanout).
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
637
|
+
*
|
|
638
|
+
* WHO publishes onto that fanout: the Delivery Dispatcher after it processes a terminal
|
|
639
|
+
* workflow message, the in-process orchestrator during a run, and this client's own
|
|
640
|
+
* dead-letter hook. WHICH event_type each of them sends is not written here - the
|
|
641
|
+
* vocabulary has one owner and it grows, so a list copied into this comment would be a
|
|
642
|
+
* hand-written descriptive fact that ages in silence
|
|
643
|
+
* (`.claude/rules/doc-code-binding.md` §1).
|
|
644
|
+
*
|
|
645
|
+
* The message carries the cookbook and the context snapshot the trace store keeps.
|
|
646
|
+
*
|
|
647
|
+
* @see api/docs/biz/70-contracts/monitoring-events.md
|
|
636
648
|
*/
|
|
637
649
|
'workflow': {
|
|
638
650
|
durable: true,
|
|
@@ -646,15 +658,20 @@ module.exports = {
|
|
|
646
658
|
|
|
647
659
|
/**
|
|
648
660
|
* monitoring.services - Service lifecycle events monitoring
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
661
|
+
*
|
|
662
|
+
* WHO publishes here: the Registry, which owns the service lifecycle, and the
|
|
663
|
+
* InfrastructureHealthTracker with service status changes. WHICH event_type each of them
|
|
664
|
+
* sends is not written here — that vocabulary has one owner and it grows, and a list
|
|
665
|
+
* copied into this comment would be a hand-written descriptive fact that ages in silence
|
|
666
|
+
* (`.claude/rules/doc-code-binding.md` §1).
|
|
667
|
+
*
|
|
653
668
|
* NOTE: Heartbeats are NOT published here - they are too frequent. The cadence
|
|
654
669
|
* is not a number this file owns: see the `heartbeatInterval` constructor option
|
|
655
670
|
* of RegistryClient (@onlineapps/conn-orch-registry), which ServiceWrapper feeds
|
|
656
671
|
* from BIZ_HEARTBEAT_INTERVAL_MS.
|
|
657
672
|
* NOTE: Healthcheck messages go to infrastructure.health.events exchange
|
|
673
|
+
*
|
|
674
|
+
* @see api/docs/biz/70-contracts/monitoring-events.md
|
|
658
675
|
*/
|
|
659
676
|
'services': {
|
|
660
677
|
durable: true,
|
|
@@ -830,7 +847,7 @@ module.exports = {
|
|
|
830
847
|
'logs.queue': {
|
|
831
848
|
durable: true,
|
|
832
849
|
arguments: {
|
|
833
|
-
'x-message-ttl':
|
|
850
|
+
'x-message-ttl': 300000, // 5 min — okno deploye bez konzumenta; conf telemetry-queue-ttl 001
|
|
834
851
|
'x-max-length': 100000, // Max 100k messages in queue
|
|
835
852
|
'x-dead-letter-exchange': '', // Default exchange: routing key IS the queue name
|
|
836
853
|
'x-dead-letter-routing-key': 'telemetry.dlq'
|
|
@@ -841,7 +858,7 @@ module.exports = {
|
|
|
841
858
|
'metrics.queue': {
|
|
842
859
|
durable: true,
|
|
843
860
|
arguments: {
|
|
844
|
-
'x-message-ttl':
|
|
861
|
+
'x-message-ttl': 300000, // 5 min — okno deploye bez konzumenta; conf telemetry-queue-ttl 001
|
|
845
862
|
'x-max-length': 100000,
|
|
846
863
|
'x-dead-letter-exchange': '',
|
|
847
864
|
'x-dead-letter-routing-key': 'telemetry.dlq'
|
|
@@ -852,7 +869,7 @@ module.exports = {
|
|
|
852
869
|
'traces.queue': {
|
|
853
870
|
durable: true,
|
|
854
871
|
arguments: {
|
|
855
|
-
'x-message-ttl':
|
|
872
|
+
'x-message-ttl': 300000, // 5 min — okno deploye bez konzumenta; conf telemetry-queue-ttl 001
|
|
856
873
|
'x-max-length': 100000,
|
|
857
874
|
'x-dead-letter-exchange': '',
|
|
858
875
|
'x-dead-letter-routing-key': 'telemetry.dlq'
|
|
@@ -1075,12 +1092,16 @@ module.exports = {
|
|
|
1075
1092
|
*
|
|
1076
1093
|
* The third queue bound to `monitoring.workflow.fanout`, beside `monitoring.workflow`
|
|
1077
1094
|
* and `delivery.workflow.events`. The owner settled it in
|
|
1078
|
-
* `docs/governance/confirmations/scheduler.md` 007: the terminal event of a run
|
|
1079
|
-
*
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1095
|
+
* `docs/governance/confirmations/scheduler.md` 007: the terminal event of a run already
|
|
1096
|
+
* travels on that exchange, so the scheduler reads it there rather than asking the
|
|
1097
|
+
* observer over HTTP or reading a database that is not its own. No producer changes; the
|
|
1098
|
+
* binding is the scheduler's own act at boot (the pattern
|
|
1099
|
+
* `infra/api_delivery_endpoint/src/index.js:164` already uses), and this file only
|
|
1100
|
+
* DECLARES what that queue is.
|
|
1101
|
+
*
|
|
1102
|
+
* WHICH event_type values those are is NOT written here: the vocabulary has one owner and
|
|
1103
|
+
* it grows. A list copied into this comment would be a hand-written descriptive fact that
|
|
1104
|
+
* ages in silence (`.claude/rules/doc-code-binding.md` §1).
|
|
1084
1105
|
*
|
|
1085
1106
|
* THE TTL IS STATED, NOT INHERITED, and it is a property of that decision rather than a
|
|
1086
1107
|
* default: a fanout cannot filter, so this queue receives EVERY workflow event of the
|
|
@@ -1096,6 +1117,7 @@ module.exports = {
|
|
|
1096
1117
|
* `CONSUMER_DEAD_LETTER_ROUTE_MISSING`; `mq-consumer-contract` 002).
|
|
1097
1118
|
*
|
|
1098
1119
|
* @see api/docs/governance/confirmations/scheduler.md 007
|
|
1120
|
+
* @see api/docs/biz/70-contracts/monitoring-events.md
|
|
1099
1121
|
*/
|
|
1100
1122
|
'workflow.events': {
|
|
1101
1123
|
durable: true,
|
|
@@ -1123,6 +1145,31 @@ module.exports = {
|
|
|
1123
1145
|
}
|
|
1124
1146
|
},
|
|
1125
1147
|
|
|
1148
|
+
/**
|
|
1149
|
+
* The platform's dead-letter exchange, DECLARED — name, type and durability.
|
|
1150
|
+
*
|
|
1151
|
+
* Every business template dead-letters here (`x-dead-letter-exchange`), and
|
|
1152
|
+
* `@onlineapps/conn-infra-mq` `QueueManager.setupServiceQueues()` brings it into
|
|
1153
|
+
* being as a durable `direct` exchange before binding `{service}.dlq` to it. The
|
|
1154
|
+
* broker compares type and durability at EVERY `assertExchange`, so they belong to
|
|
1155
|
+
* the declaration, where a second reader — `api/scripts/ci/declare-broker-exchanges.mjs`,
|
|
1156
|
+
* which declares exchanges on a CI broker nobody's services booted against — finds
|
|
1157
|
+
* them through the same loop as every other exchange of this file, instead of keeping
|
|
1158
|
+
* a copy of the founder's line (TODO INFRA 349, d.902).
|
|
1159
|
+
*
|
|
1160
|
+
* Its wire name carries no prefix (`SECTION_PREFIXES.deadLetter` is `''`), and the key
|
|
1161
|
+
* is the one constant at the top of this file, so the name has one spelling.
|
|
1162
|
+
* An exchange holds nothing to dead-letter, so — like every other exchange here — it
|
|
1163
|
+
* carries no route.
|
|
1164
|
+
*/
|
|
1165
|
+
deadLetter: {
|
|
1166
|
+
[DEAD_LETTER_EXCHANGE]: {
|
|
1167
|
+
type: 'exchange',
|
|
1168
|
+
exchangeType: 'direct',
|
|
1169
|
+
durable: true
|
|
1170
|
+
}
|
|
1171
|
+
},
|
|
1172
|
+
|
|
1126
1173
|
/**
|
|
1127
1174
|
* The full wire name of a declared entry: the section's prefix + the entry's key.
|
|
1128
1175
|
*
|
|
@@ -1132,9 +1179,13 @@ module.exports = {
|
|
|
1132
1179
|
* a name this configuration does not declare would be asserted against the broker
|
|
1133
1180
|
* by whoever imported it, which is how a typo becomes a second queue.
|
|
1134
1181
|
*
|
|
1182
|
+
* A section whose declared prefix is `''` (`deadLetter`) composes the bare key: its
|
|
1183
|
+
* names travel without a prefix. That is a value of the map, not a default — a
|
|
1184
|
+
* section missing from it is refused below.
|
|
1185
|
+
*
|
|
1135
1186
|
* @param {string} section - Section holding the entry (e.g. 'deliveryEvents').
|
|
1136
1187
|
* @param {string} key - The entry's key within that section (e.g. 'health.events').
|
|
1137
|
-
* @returns {string} The full name (e.g. 'delivery.health.events').
|
|
1188
|
+
* @returns {string} The full name (e.g. 'delivery.health.events'; the bare key for 'deadLetter').
|
|
1138
1189
|
* @throws {ValidationError} If the section composes no wire name, or the entry is undeclared.
|
|
1139
1190
|
*/
|
|
1140
1191
|
queueName(section, key) {
|
|
@@ -1156,7 +1207,7 @@ module.exports = {
|
|
|
1156
1207
|
);
|
|
1157
1208
|
}
|
|
1158
1209
|
|
|
1159
|
-
return `${prefix}.${key}`;
|
|
1210
|
+
return prefix === '' ? key : `${prefix}.${key}`;
|
|
1160
1211
|
},
|
|
1161
1212
|
|
|
1162
1213
|
/**
|
|
@@ -1627,11 +1678,12 @@ module.exports = {
|
|
|
1627
1678
|
* Every business template routes here (`x-dead-letter-exchange`), and so does
|
|
1628
1679
|
* `getDeadLetterRoute()` for a business queue. `@onlineapps/conn-infra-mq` binds
|
|
1629
1680
|
* `<service>.dlq` to it and had to read the name back out of a queue's arguments
|
|
1630
|
-
* for want of an export.
|
|
1681
|
+
* for want of an export. Composed from the `deadLetter` entry, which also declares
|
|
1682
|
+
* the exchange's type and durability — one declaration, not a name beside it.
|
|
1631
1683
|
*
|
|
1632
1684
|
* @returns {string}
|
|
1633
1685
|
*/
|
|
1634
1686
|
deadLetterExchange() {
|
|
1635
|
-
return DEAD_LETTER_EXCHANGE;
|
|
1687
|
+
return this.queueName('deadLetter', DEAD_LETTER_EXCHANGE);
|
|
1636
1688
|
}
|
|
1637
1689
|
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* recurrencesSchema.js
|
|
5
|
+
*
|
|
6
|
+
* JSON Schema used by Ajv (in `src/scheduler-request.js`) to validate the recurrences a service
|
|
7
|
+
* declares about itself.
|
|
8
|
+
*
|
|
9
|
+
* WHY THE SHAPE LIVES HERE. `docs/architecture/scheduler.md` §7 has the wrapper register a
|
|
10
|
+
* service's recurrences at boot — "idempotent, so every boot re-asserts the declared state" —
|
|
11
|
+
* and the service answers with them through ONE hook, platform ones as a constant list and
|
|
12
|
+
* tenant ones derived from its own data (confirmation `scheduler` 011). The shape of a record
|
|
13
|
+
* is the library's; where the record comes from is not. One owner of the shape means every service's file is the
|
|
14
|
+
* same file, and the wrapper reading it never has to guess.
|
|
15
|
+
*
|
|
16
|
+
* WHAT THE FILE DOES NOT CARRY, and why that is a rule rather than an omission:
|
|
17
|
+
*
|
|
18
|
+
* - **`tenant_id` and `workspace_id`.** They travel beside the record, not in it: the
|
|
19
|
+
* builder takes them as `tenantId` and `workspaceId`, and the caller supplies them with
|
|
20
|
+
* every recurrence — the service knows them from its own data, and nothing reads them from
|
|
21
|
+
* the environment or defaults them.
|
|
22
|
+
* @see api/docs/governance/confirmations/scheduler.md 002, 010
|
|
23
|
+
* - **`requested_by`.** It is the service that declared the record; the caller passes the name
|
|
24
|
+
* it already knows, so a record cannot disagree with the service that answered it.
|
|
25
|
+
*
|
|
26
|
+
* `additionalProperties: false` at both levels, deliberately: the consumer's own contract
|
|
27
|
+
* (`api/infra/api_scheduler/contracts/scheduler.request.schema.json`) closes its keys the same
|
|
28
|
+
* way, so a key accepted here and refused there would turn a boot into a dead-lettered
|
|
29
|
+
* request nobody reads.
|
|
30
|
+
*
|
|
31
|
+
* @see ../scheduler-request.js — the shape check, the builders and the send
|
|
32
|
+
* @see api/docs/architecture/scheduler.md §4, §7
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Five whitespace-separated fields — the same expression the scheduler's contract takes
|
|
37
|
+
* (`recurrence.cron`, pattern `^\S+( \S+){4}$`). Whether those fields describe a reachable
|
|
38
|
+
* instant is the tick engine's answer, not this file's.
|
|
39
|
+
*/
|
|
40
|
+
const CRON_PATTERN = '^\\S+( \\S+){4}$';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The id of one schedule: it becomes `idempotency_key`, so it is a name, not a sentence.
|
|
44
|
+
*
|
|
45
|
+
* As WIDE as the consumer's own rule and no wider: the scheduler takes any string of 1–255
|
|
46
|
+
* characters for that key, so this file refuses only what would make the key a sentence —
|
|
47
|
+
* whitespace. The narrower spelling this schema first carried (lower-case letters and dashes)
|
|
48
|
+
* was the library being stricter than the contract for no reason, and it refused the very
|
|
49
|
+
* convention the first caller uses: `<source_uuid>:<cookbook_ref>` (BIZ-ingest, 2026-09-23).
|
|
50
|
+
*/
|
|
51
|
+
const ID_PATTERN = '^\\S+$';
|
|
52
|
+
|
|
53
|
+
const recurrencesSchema = {
|
|
54
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
55
|
+
$id: 'https://onlineapps.cz/schemas/mq-client-core/recurrences.v1.schema.json',
|
|
56
|
+
title: 'Service recurrence declaration',
|
|
57
|
+
description:
|
|
58
|
+
'What a service declares about its own recurring runs. The wrapper re-asserts every record '
|
|
59
|
+
+ 'at boot as a `scheduler.request` of kind `recurring`.',
|
|
60
|
+
type: 'object',
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
required: ['recurrences'],
|
|
63
|
+
properties: {
|
|
64
|
+
recurrences: {
|
|
65
|
+
description:
|
|
66
|
+
'Every recurrence this service owns. An empty list is a valid declaration of nothing, '
|
|
67
|
+
+ 'and a service with no recurrences may answer one.',
|
|
68
|
+
type: 'array',
|
|
69
|
+
items: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
required: ['id', 'cookbook_ref', 'cron', 'timezone'],
|
|
73
|
+
properties: {
|
|
74
|
+
id: {
|
|
75
|
+
description:
|
|
76
|
+
'Stable id of THIS schedule; it travels as `idempotency_key`, so re-registration '
|
|
77
|
+
+ 'upserts instead of creating a second schedule.',
|
|
78
|
+
type: 'string',
|
|
79
|
+
minLength: 1,
|
|
80
|
+
maxLength: 255,
|
|
81
|
+
pattern: ID_PATTERN
|
|
82
|
+
},
|
|
83
|
+
cookbook_ref: {
|
|
84
|
+
description:
|
|
85
|
+
'Id of the cookbook to run. Its `triggers` must name this service and the kind '
|
|
86
|
+
+ '`recurring`, or the scheduler refuses the request (confirmation scheduler 004).',
|
|
87
|
+
type: 'string',
|
|
88
|
+
minLength: 1,
|
|
89
|
+
maxLength: 255
|
|
90
|
+
},
|
|
91
|
+
cron: {
|
|
92
|
+
description: 'Cron expression, five fields.',
|
|
93
|
+
type: 'string',
|
|
94
|
+
pattern: CRON_PATTERN
|
|
95
|
+
},
|
|
96
|
+
timezone: {
|
|
97
|
+
description: 'IANA zone, explicit. No implicit zone exists (scheduler.md §4).',
|
|
98
|
+
type: 'string',
|
|
99
|
+
minLength: 1,
|
|
100
|
+
maxLength: 64
|
|
101
|
+
},
|
|
102
|
+
input: {
|
|
103
|
+
description:
|
|
104
|
+
'Input handed to the cookbook unchanged. Present only where the run needs one — '
|
|
105
|
+
+ 'a scheduled import that writes says so here, while the same cookbook run by hand '
|
|
106
|
+
+ 'can be given a different input.',
|
|
107
|
+
type: 'object'
|
|
108
|
+
},
|
|
109
|
+
catch_up: {
|
|
110
|
+
description:
|
|
111
|
+
'Whether a missed window runs once when the scheduler comes back. Absent means '
|
|
112
|
+
+ 'false, and the scheduler materialises that default — this file does not.',
|
|
113
|
+
type: 'boolean'
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
module.exports = { recurrencesSchema, CRON_PATTERN, ID_PATTERN };
|
package/src/index.js
CHANGED
|
@@ -40,6 +40,15 @@ const {
|
|
|
40
40
|
buildWorkflowInitiatedEvent,
|
|
41
41
|
publishWorkflowInitiated,
|
|
42
42
|
} = require('./monitoring-initiated');
|
|
43
|
+
const {
|
|
44
|
+
schedulerRequestQueue,
|
|
45
|
+
validateRecurrences,
|
|
46
|
+
buildRecurringRequest,
|
|
47
|
+
buildEventRequest,
|
|
48
|
+
buildCancelRequest,
|
|
49
|
+
publishSchedulerRequest,
|
|
50
|
+
createSchedulerPublisher,
|
|
51
|
+
} = require('./scheduler-request');
|
|
43
52
|
const queueConfig = require('./config/queueConfig');
|
|
44
53
|
const deliveryPolicy = require('./config/deliveryPolicy');
|
|
45
54
|
const { redactUrl, UNPARSEABLE_PLACEHOLDER } = require('@onlineapps/logger-contract');
|
|
@@ -94,6 +103,23 @@ module.exports.monitoring = {
|
|
|
94
103
|
buildWorkflowInitiatedEvent,
|
|
95
104
|
publishWorkflowInitiated,
|
|
96
105
|
};
|
|
106
|
+
/**
|
|
107
|
+
* The recurrences a service declares, and how one of them is registered (d.833).
|
|
108
|
+
*
|
|
109
|
+
* `docs/architecture/scheduler.md` §7 gives this library the shape of one declared recurrence
|
|
110
|
+
* and the wrapper the job of re-asserting what the service answers at every boot (one hook,
|
|
111
|
+
* confirmation `scheduler` 011). The queue name travels with them, so no caller composes
|
|
112
|
+
* `scheduler.request` itself.
|
|
113
|
+
*/
|
|
114
|
+
module.exports.scheduler = {
|
|
115
|
+
requestQueue: schedulerRequestQueue,
|
|
116
|
+
validateRecurrences,
|
|
117
|
+
buildRecurringRequest,
|
|
118
|
+
buildEventRequest,
|
|
119
|
+
buildCancelRequest,
|
|
120
|
+
publishSchedulerRequest,
|
|
121
|
+
createSchedulerPublisher,
|
|
122
|
+
};
|
|
97
123
|
/** The only public way to reach the central queue classification; `src/config/queueConfig` is an internal path, not a contract. */
|
|
98
124
|
module.exports.queueConfig = queueConfig;
|
|
99
125
|
/**
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The recurrences a service declares, and the request that registers ONE of them.
|
|
5
|
+
*
|
|
6
|
+
* `docs/architecture/scheduler.md` §7: a service declares the recurrences it owns and the
|
|
7
|
+
* wrapper re-asserts them at every boot as `scheduler.request` messages of kind `recurring`.
|
|
8
|
+
* The service answers with them through ONE hook (`scheduler: { recurrences: async () => [...] }`,
|
|
9
|
+
* d.834) - platform ones as a constant list, tenant ones derived from its own data - and this
|
|
10
|
+
* library fixes the SHAPE of a record (`validateRecurrences()`), never where it comes from
|
|
11
|
+
* (confirmation `scheduler` 011). The scheduler treats a
|
|
12
|
+
* repeated registration as an upsert — `idempotency_key` for a recurring request IS the
|
|
13
|
+
* schedule id — so re-asserting the declared state costs nothing and drifts nowhere
|
|
14
|
+
* (`api/infra/api_scheduler/src/consumer/decideOnRequest.js`, `contracts/scheduler.request.schema.json`).
|
|
15
|
+
*
|
|
16
|
+
* ONE RAIL FOR EVERY THING A SERVICE SAYS TO THE SCHEDULER. The queue takes one message with
|
|
17
|
+
* four branches, and three of them are a service's to send: `recurring` (a schedule, built from
|
|
18
|
+
* a record the service declared), `event` (a fact that happened, naming no
|
|
19
|
+
* cookbook - the scheduler starts every recipe whose `triggers` declare it, confirmation
|
|
20
|
+
* `scheduler` 004) and `cancel` (an item by its key). Each has a pure builder anybody can ask
|
|
21
|
+
* without a broker, and ONE `publishSchedulerRequest()` sends them all: the kind is a field of
|
|
22
|
+
* the message, never a second function - the split `./monitoring-initiated.js` uses for
|
|
23
|
+
* `workflow_initiated`, with one send instead of three.
|
|
24
|
+
*
|
|
25
|
+
* THE KEY IS ALWAYS THE CALLER'S. `idempotency_key` sits in the contract's root `required`, and
|
|
26
|
+
* the consumer decides a one-shot item ONCE by it (a second `event` of a known key answers
|
|
27
|
+
* `duplicate`). A key invented here would make every retry of one fact a second run, so no
|
|
28
|
+
* builder makes one up - it is the identity of the thing, and only the caller knows it.
|
|
29
|
+
*
|
|
30
|
+
* WHERE IT DIFFERS FROM THAT SIBLING, and why: sending is **fail-fast**. A lost monitoring
|
|
31
|
+
* copy is a missing record of work that already happened, so that rail returns `false` and
|
|
32
|
+
* reports. A lost registration is work that will never happen, and nothing rings when a run
|
|
33
|
+
* does not start — so every refusal here is thrown and the boot stops on it
|
|
34
|
+
* (`architecture-principles.md` §4).
|
|
35
|
+
*
|
|
36
|
+
* WHAT IT NEVER INVENTS: `tenant_id` and `workspace_id`. Both are mandatory and ≥ 1, and the
|
|
37
|
+
* caller passes them with every request — nothing here reads them from the environment or
|
|
38
|
+
* supplies a default. For a recurrence the service knows them from its own data, so the records
|
|
39
|
+
* its boot hook returns carry them; inside a handler `ctx.scheduler` takes them from the
|
|
40
|
+
* invocation envelope.
|
|
41
|
+
*
|
|
42
|
+
* @see api/docs/governance/confirmations/scheduler.md 002
|
|
43
|
+
* @see api/docs/governance/confirmations/scheduler.md 010
|
|
44
|
+
* @see api/docs/governance/confirmations/scheduler.md 011
|
|
45
|
+
*
|
|
46
|
+
* @see ./config/recurrencesSchema.js — the shape of one declared recurrence
|
|
47
|
+
* @see ./monitoring-initiated.js — the same split for `workflow_initiated`
|
|
48
|
+
* @see api/docs/architecture/scheduler.md §4, §7
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const Ajv = require('ajv');
|
|
52
|
+
|
|
53
|
+
const queueConfig = require('./config/queueConfig');
|
|
54
|
+
const { recurrencesSchema } = require('./config/recurrencesSchema');
|
|
55
|
+
|
|
56
|
+
/** The queue the scheduler consumes, composed from the declaration rather than typed. */
|
|
57
|
+
const schedulerRequestQueue = queueConfig.queueName('scheduler', 'request');
|
|
58
|
+
|
|
59
|
+
const ajv = new Ajv({ allErrors: true });
|
|
60
|
+
const validateDeclaration = ajv.compile(recurrencesSchema);
|
|
61
|
+
|
|
62
|
+
/** Ajv's findings as one sentence a human can act on. */
|
|
63
|
+
const describeErrors = (errors) => (errors || [])
|
|
64
|
+
.map((error) => {
|
|
65
|
+
const where = error.instancePath === '' ? 'the document' : error.instancePath;
|
|
66
|
+
const what = error.params && error.params.additionalProperty
|
|
67
|
+
? `${error.message} ("${error.params.additionalProperty}")`
|
|
68
|
+
: error.message;
|
|
69
|
+
return `${where} ${what}`;
|
|
70
|
+
})
|
|
71
|
+
.join('; ');
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check a parsed declaration document and hand back its records.
|
|
75
|
+
*
|
|
76
|
+
* @param {Object} document - `{ recurrences: [...] }`, as the service declared it.
|
|
77
|
+
* @param {string} source - Where it came from, named in every sentence this function throws.
|
|
78
|
+
* Required: a default here would put one caller's name on another caller's mistake.
|
|
79
|
+
* @returns {Array<Object>} The declared recurrences, in the order they were given.
|
|
80
|
+
* @throws {Error} When the document does not hold, naming every rule it broke.
|
|
81
|
+
*/
|
|
82
|
+
function validateRecurrences(document, source) {
|
|
83
|
+
if (typeof source !== 'string' || source.trim() === '') {
|
|
84
|
+
throw new Error(
|
|
85
|
+
'[mq-client-core/scheduler-request] Missing source - Expected: a name for what is being '
|
|
86
|
+
+ 'checked, because every refusal below quotes it and a wrong name sends the reader to the '
|
|
87
|
+
+ 'wrong place. Fix: pass what the caller calls this document (e.g. "the declaration").'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`[mq-client-core/scheduler-request] ${source} is not a declaration - Expected: an object with a "recurrences" list, `
|
|
94
|
+
+ `got ${Array.isArray(document) ? 'an array' : typeof document}. `
|
|
95
|
+
+ 'Fix: write { "recurrences": [ … ] }, or remove the file if the service declares none.'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!validateDeclaration(document)) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`[mq-client-core/scheduler-request] ${source} does not hold: ${describeErrors(validateDeclaration.errors)} - `
|
|
102
|
+
+ 'Expected: every record carries id, cookbook_ref, cron (five fields) and an explicit '
|
|
103
|
+
+ 'IANA timezone, and nothing this shape does not declare. '
|
|
104
|
+
+ 'Fix: correct the record the message names; the shape is src/config/recurrencesSchema.js. '
|
|
105
|
+
+ 'Note: tenant and workspace do NOT belong here - the caller passes them '
|
|
106
|
+
+ '(confirmations scheduler 010, 011; the tenancy rule itself is scheduler 002).'
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
for (const recurrence of document.recurrences) {
|
|
112
|
+
if (seen.has(recurrence.id)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`[mq-client-core/scheduler-request] ${source} declares the id "${recurrence.id}" twice - Expected: one record per `
|
|
115
|
+
+ 'id, because the id travels as the scheduler\'s idempotency_key and a second record '
|
|
116
|
+
+ 'would silently overwrite the first at every boot. '
|
|
117
|
+
+ 'Fix: give each schedule its own id, or remove the duplicate.'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
seen.add(recurrence.id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return document.recurrences;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A whole number a tenant or workspace id may be: ≥ 1, never 0 (confirmation scheduler 002). */
|
|
127
|
+
const isContextId = (value) => Number.isInteger(value) && value >= 1;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Turn ONE declared recurrence into the message the scheduler takes.
|
|
131
|
+
*
|
|
132
|
+
* @param {Object} args
|
|
133
|
+
* @param {Object} args.declaration - One record the service declared.
|
|
134
|
+
* @param {number} args.tenantId - The tenant the runs execute under; supplied by the caller with
|
|
135
|
+
* this recurrence (the service's own data), never a default.
|
|
136
|
+
* @param {number} args.workspaceId - The workspace the runs execute under; supplied the same way.
|
|
137
|
+
* @param {string} args.requestedBy - The service registering this schedule.
|
|
138
|
+
* @returns {Object} The `scheduler.request` message, exactly the keys the contract declares.
|
|
139
|
+
* @throws {Error} When anything is missing, zero, or not a whole number.
|
|
140
|
+
*/
|
|
141
|
+
function buildRecurringRequest({ declaration, tenantId, workspaceId, requestedBy } = {}) {
|
|
142
|
+
const [recurrence] = validateRecurrences({ recurrences: [declaration] }, 'the declaration');
|
|
143
|
+
|
|
144
|
+
requireTenancy(tenantId, workspaceId);
|
|
145
|
+
const by = requireRequestedBy(requestedBy);
|
|
146
|
+
|
|
147
|
+
const request = {
|
|
148
|
+
kind: 'recurring',
|
|
149
|
+
idempotency_key: recurrence.id,
|
|
150
|
+
cookbook_ref: recurrence.cookbook_ref,
|
|
151
|
+
recurrence: { cron: recurrence.cron, timezone: recurrence.timezone },
|
|
152
|
+
tenant_id: tenantId,
|
|
153
|
+
workspace_id: workspaceId,
|
|
154
|
+
requested_by: by
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Written only when the declaration states them: an absent `input` is no input, and an absent
|
|
158
|
+
// `catch_up` is the scheduler's own default (§5) - a value materialised here would be a second
|
|
159
|
+
// owner of it.
|
|
160
|
+
if (Object.prototype.hasOwnProperty.call(recurrence, 'input')) request.input = recurrence.input;
|
|
161
|
+
if (Object.prototype.hasOwnProperty.call(recurrence, 'catch_up')) request.catch_up = recurrence.catch_up;
|
|
162
|
+
|
|
163
|
+
return request;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The platform spelling of a service or an event name, as the consumer's contract defines it. */
|
|
167
|
+
const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
168
|
+
|
|
169
|
+
/** The key that identifies the item, whatever kind it is - required by the contract's root. */
|
|
170
|
+
function requireIdempotencyKey(idempotencyKey) {
|
|
171
|
+
if (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '') {
|
|
172
|
+
throw new Error(
|
|
173
|
+
'[mq-client-core/scheduler-request] Missing idempotency_key - Expected: the stable identity '
|
|
174
|
+
+ 'of this item, chosen by the caller. The scheduler decides a one-shot item ONCE by this '
|
|
175
|
+
+ 'key, so a key invented here would turn every retry of one fact into a second run. '
|
|
176
|
+
+ 'Fix: pass the key that names the thing (for an event, what it happened to).'
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return idempotencyKey.trim();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The service registering, cancelling or reporting - checked by the scheduler against services.json. */
|
|
184
|
+
function requireRequestedBy(requestedBy) {
|
|
185
|
+
if (typeof requestedBy !== 'string' || requestedBy.trim() === '') {
|
|
186
|
+
throw new Error(
|
|
187
|
+
'[mq-client-core/scheduler-request] Missing requested_by - Expected: the platform name of the '
|
|
188
|
+
+ 'service sending this request; the scheduler checks it against api/config/services.json '
|
|
189
|
+
+ "and against the cookbook's own trigger declaration. "
|
|
190
|
+
+ 'Fix: pass the service name the wrapper already holds.'
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return requestedBy.trim();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Both context ids, under the one rule confirmation `scheduler` 002 fixed. */
|
|
198
|
+
function requireTenancy(tenantId, workspaceId) {
|
|
199
|
+
if (!isContextId(tenantId)) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`[mq-client-core/scheduler-request] Invalid tenant_id ${JSON.stringify(tenantId)} - Expected: `
|
|
202
|
+
+ 'a whole number of at least 1. The owner rejected a reserved value: 0 is not a platform '
|
|
203
|
+
+ 'scope, and an absent tenant is an error rather than a default (confirmation scheduler 002). '
|
|
204
|
+
+ 'Fix: pass tenantId with every request - a recurrence carries it in the record the service '
|
|
205
|
+
+ 'returns from its own data, and ctx.scheduler fills it from the invocation envelope; it is '
|
|
206
|
+
+ 'never read from anywhere else.'
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!isContextId(workspaceId)) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`[mq-client-core/scheduler-request] Invalid workspace_id ${JSON.stringify(workspaceId)} - `
|
|
213
|
+
+ 'Expected: a whole number of at least 1, under the same rule as the tenant (confirmation '
|
|
214
|
+
+ 'scheduler 002). Fix: pass workspaceId with every request - a recurrence carries it in the '
|
|
215
|
+
+ 'record the service returns from its own data, and ctx.scheduler fills it from the invocation '
|
|
216
|
+
+ 'envelope; it is never read from anywhere else.'
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Turn a fact into the message that reports it.
|
|
223
|
+
*
|
|
224
|
+
* It names NO cookbook, and that is the point: the scheduler starts every recipe whose
|
|
225
|
+
* `triggers` declare this event (confirmation `scheduler` 004), so a producer naming a recipe
|
|
226
|
+
* would be mapping work to something it may not own (`ingest-poll-trigger-rail` 001: which
|
|
227
|
+
* cookbook runs is a platform decision, not the producer's).
|
|
228
|
+
*
|
|
229
|
+
* @param {Object} args
|
|
230
|
+
* @param {{service: string, name: string}} args.event - Who it happened to, and what happened.
|
|
231
|
+
* @param {number} args.tenantId
|
|
232
|
+
* @param {number} args.workspaceId
|
|
233
|
+
* @param {string} args.requestedBy
|
|
234
|
+
* @param {string} args.idempotencyKey - The identity of this fact; see the module note.
|
|
235
|
+
* @param {Object} [args.input] - Handed to every cookbook the event starts, unchanged.
|
|
236
|
+
* @returns {Object} The `scheduler.request` message of kind `event`.
|
|
237
|
+
* @throws {Error} When anything is missing or not the shape the contract takes.
|
|
238
|
+
*/
|
|
239
|
+
function buildEventRequest({ event, tenantId, workspaceId, requestedBy, idempotencyKey, input } = {}) {
|
|
240
|
+
const key = requireIdempotencyKey(idempotencyKey);
|
|
241
|
+
const by = requireRequestedBy(requestedBy);
|
|
242
|
+
requireTenancy(tenantId, workspaceId);
|
|
243
|
+
|
|
244
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`[mq-client-core/scheduler-request] Missing event - Expected: { service, name } - who it `
|
|
247
|
+
+ 'happened to and what happened, both of them. '
|
|
248
|
+
+ 'Fix: pass the fact this service publishes, never a cookbook name.'
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const [field, value] of [['service', event.service], ['name', event.name]]) {
|
|
253
|
+
if (typeof value !== 'string' || !NAME_PATTERN.test(value)) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`[mq-client-core/scheduler-request] Invalid event.${field} ${JSON.stringify(value)} - `
|
|
256
|
+
+ 'Expected: the platform spelling, lower case letters, digits and dashes '
|
|
257
|
+
+ '(the pattern the scheduler\'s own contract declares). '
|
|
258
|
+
+ `Fix: pass the ${field} as api/config/services.json spells it.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const request = {
|
|
264
|
+
kind: 'event',
|
|
265
|
+
idempotency_key: key,
|
|
266
|
+
event: { service: event.service, name: event.name },
|
|
267
|
+
tenant_id: tenantId,
|
|
268
|
+
workspace_id: workspaceId,
|
|
269
|
+
requested_by: by
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
if (input !== undefined) request.input = input;
|
|
273
|
+
|
|
274
|
+
return request;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Take an item back by its key.
|
|
279
|
+
*
|
|
280
|
+
* Idempotent by contract: a key that names nothing is answered `cancel-nothing` by the
|
|
281
|
+
* scheduler, not refused - so this builder judges nothing about the key, it only requires one.
|
|
282
|
+
* The item carries the context it was registered with, which is why no tenancy travels here.
|
|
283
|
+
*
|
|
284
|
+
* @param {Object} args
|
|
285
|
+
* @param {string} args.idempotencyKey - The key of the schedule or pending item to remove.
|
|
286
|
+
* @param {string} args.requestedBy - Only the service that registered an item may cancel it.
|
|
287
|
+
* @returns {Object} The `scheduler.request` message of kind `cancel`.
|
|
288
|
+
* @throws {Error} When the key or the requester is missing.
|
|
289
|
+
*/
|
|
290
|
+
function buildCancelRequest({ idempotencyKey, requestedBy } = {}) {
|
|
291
|
+
return {
|
|
292
|
+
kind: 'cancel',
|
|
293
|
+
idempotency_key: requireIdempotencyKey(idempotencyKey),
|
|
294
|
+
requested_by: requireRequestedBy(requestedBy)
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Every key the port's `registerRecurring()` takes — anything else is refused, never dropped. */
|
|
299
|
+
const RECURRING_PORT_KEYS = Object.freeze([
|
|
300
|
+
'id', 'cookbookRef', 'cron', 'timezone', 'tenantId', 'workspaceId', 'input', 'catchUp'
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
/** The kinds this library builds, and the only ones it will send. */
|
|
304
|
+
const SENDABLE_KINDS = Object.freeze(['recurring', 'event', 'cancel']);
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Send one request of any kind this library builds. Throws on every refusal — see the module
|
|
308
|
+
* note on fail-fast.
|
|
309
|
+
*
|
|
310
|
+
* @param {Object} mqClient - A connected `BaseClient`.
|
|
311
|
+
* @param {Object} request - What one of the builders above produced.
|
|
312
|
+
* @returns {Promise<void>}
|
|
313
|
+
* @throws {Error} When the client is not connected, the request is not one of ours, or the
|
|
314
|
+
* publish fails.
|
|
315
|
+
*/
|
|
316
|
+
async function publishSchedulerRequest(mqClient, request) {
|
|
317
|
+
if (!mqClient || typeof mqClient.publish !== 'function' || typeof mqClient.isConnected !== 'function') {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`[mq-client-core/scheduler-request] Missing client - Expected: a connected @onlineapps/mq-client-core BaseClient, so `
|
|
320
|
+
+ `the registration reaches ${schedulerRequestQueue}. `
|
|
321
|
+
+ 'Fix: pass the client the composition root connected.'
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!mqClient.isConnected()) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`[mq-client-core/scheduler-request] The client is not connected - Expected: a registration reaches `
|
|
328
|
+
+ `${schedulerRequestQueue} before the service starts taking work; a schedule nobody `
|
|
329
|
+
+ 'registered is a run that never happens. Fix: connect the client first.'
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!request || !SENDABLE_KINDS.includes(request.kind) || typeof request.idempotency_key !== 'string') {
|
|
334
|
+
throw new Error(
|
|
335
|
+
'[mq-client-core/scheduler-request] Not a request this library built - Expected: one of '
|
|
336
|
+
+ `${SENDABLE_KINDS.join(', ')}, got kind ${JSON.stringify(request && request.kind)}. `
|
|
337
|
+
+ 'Fix: build the message with buildRecurringRequest(), buildEventRequest() or '
|
|
338
|
+
+ 'buildCancelRequest() before sending it.'
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
await mqClient.publish(schedulerRequestQueue, request);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`[mq-client-core/scheduler-request] Could not send "${request.idempotency_key}" (${request.kind}) `
|
|
347
|
+
+ `on ${schedulerRequestQueue}: ${error.message} - Expected: the request to reach the `
|
|
348
|
+
+ 'scheduler; a request nobody received is work that will not happen. '
|
|
349
|
+
+ 'Fix: act on the reason above - the boot stops here rather than running a service whose '
|
|
350
|
+
+ 'schedules were silently not registered.',
|
|
351
|
+
{ cause: error }
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* A port one service holds: the three things it says, with WHO is speaking already decided.
|
|
358
|
+
*
|
|
359
|
+
* TWO CALLERS, TWO DOORS. The wrapper builds `ctx.scheduler` over this factory at boot and fills
|
|
360
|
+
* the tenancy of each call from the envelope of the run. The operator CLIs of biz-ingest
|
|
361
|
+
* (`scripts/run-{poll,fetch-source-item,retry-pending-items}.js`) do NOT boot the wrapper - a
|
|
362
|
+
* bootstrap there would start consumers, the registry and a heartbeat - and today they are the
|
|
363
|
+
* only way a document of the pilot comes into being, so they call this factory directly with the
|
|
364
|
+
* service identity and the tenancy of the row they are working on. That is why tenancy is
|
|
365
|
+
* explicit on every call rather than held by the port: one port, two kinds of caller, and no
|
|
366
|
+
* hidden context that the CLI would have to fake (BIZ-ingest measurement, 2026-09-23).
|
|
367
|
+
*
|
|
368
|
+
* EAGER ON PURPOSE. The client and the name of the queue are decided HERE, when the port is
|
|
369
|
+
* made, so a service that announces facts falls over at boot rather than at its first document.
|
|
370
|
+
* (On a pin whose `queueConfig` declares no `scheduler` section this module already refuses at
|
|
371
|
+
* require time, which is earlier still.) And because a defect of the wiring must never answer in
|
|
372
|
+
* place of the operation, every refusal names the slot it is about and how to fix it.
|
|
373
|
+
*
|
|
374
|
+
* @param {Object} args
|
|
375
|
+
* @param {Object} args.mqClient - A `BaseClient` (connected by the time a verb is called).
|
|
376
|
+
* @param {string} args.requestedBy - The service this port speaks for; it becomes `requested_by`
|
|
377
|
+
* on every request and `event.service` on every fact.
|
|
378
|
+
* @returns {{registerRecurring: Function, publishEvent: Function, cancel: Function}}
|
|
379
|
+
* @throws {Error} When a slot is missing or unusable - at construction, never later.
|
|
380
|
+
*/
|
|
381
|
+
function createSchedulerPublisher({ mqClient, requestedBy } = {}) {
|
|
382
|
+
if (!mqClient || typeof mqClient.publish !== 'function' || typeof mqClient.isConnected !== 'function') {
|
|
383
|
+
throw new Error(
|
|
384
|
+
'[mq-client-core/scheduler-request] Cannot build the scheduler port: slot "mqClient" is '
|
|
385
|
+
+ 'missing or cannot publish - Expected: the @onlineapps/mq-client-core BaseClient this '
|
|
386
|
+
+ `service connects, so a request reaches ${schedulerRequestQueue}. This is decided now, at `
|
|
387
|
+
+ 'construction, because a service that announces facts must fail at boot rather than at '
|
|
388
|
+
+ 'its first document. Fix: pass the client the composition root built.'
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const by = (() => {
|
|
393
|
+
try {
|
|
394
|
+
return requireRequestedBy(requestedBy);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
'[mq-client-core/scheduler-request] Cannot build the scheduler port: slot "requestedBy" is '
|
|
398
|
+
+ `missing - ${error.message} `
|
|
399
|
+
+ 'Fix: pass the platform name of the service this port speaks for (config.serviceName).'
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
})();
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
/**
|
|
406
|
+
* Re-assert one recurrence. The id is the schedule's identity, so a repeat upserts.
|
|
407
|
+
*
|
|
408
|
+
* @param {Object} args - `{ id, cookbookRef, cron, timezone, tenantId, workspaceId, input?, catchUp? }`
|
|
409
|
+
* @returns {Promise<void>}
|
|
410
|
+
*/
|
|
411
|
+
async registerRecurring(record = {}) {
|
|
412
|
+
// As strict as the declaration it becomes (`additionalProperties: false` in
|
|
413
|
+
// `./config/recurrencesSchema.js`): a key nobody reads is a typo or a second definition of
|
|
414
|
+
// something, and dropping it in silence hides both (`architecture-principles.md` §3).
|
|
415
|
+
for (const key of Object.keys(record)) {
|
|
416
|
+
if (!RECURRING_PORT_KEYS.includes(key)) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
`[mq-client-core/scheduler-request] registerRecurring got a key the request does not declare ("${key}") - `
|
|
419
|
+
+ 'Expected: exactly { id, cookbookRef, cron, timezone, tenantId, workspaceId, input?, catchUp? }. '
|
|
420
|
+
+ 'Fix: remove the key from the record the service returns / the ctx.scheduler call.'
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const { id, cookbookRef, cron, timezone, tenantId, workspaceId, input, catchUp } = record;
|
|
426
|
+
const declaration = { id, cookbook_ref: cookbookRef, cron, timezone };
|
|
427
|
+
if (input !== undefined) declaration.input = input;
|
|
428
|
+
if (catchUp !== undefined) declaration.catch_up = catchUp;
|
|
429
|
+
|
|
430
|
+
await publishSchedulerRequest(mqClient, buildRecurringRequest({
|
|
431
|
+
declaration,
|
|
432
|
+
tenantId,
|
|
433
|
+
workspaceId,
|
|
434
|
+
requestedBy: by
|
|
435
|
+
}));
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Announce one fact. The service is this port's; the key is the caller's (the identity of the
|
|
440
|
+
* thing that happened - a job's uuid, not a clock reading), because the scheduler decides a
|
|
441
|
+
* one-shot item once by it.
|
|
442
|
+
*
|
|
443
|
+
* @param {Object} args - `{ name, idempotencyKey, tenantId, workspaceId, input? }`
|
|
444
|
+
* @returns {Promise<void>}
|
|
445
|
+
*/
|
|
446
|
+
async publishEvent({ name, idempotencyKey, tenantId, workspaceId, input } = {}) {
|
|
447
|
+
await publishSchedulerRequest(mqClient, buildEventRequest({
|
|
448
|
+
event: { service: by, name },
|
|
449
|
+
idempotencyKey,
|
|
450
|
+
tenantId,
|
|
451
|
+
workspaceId,
|
|
452
|
+
requestedBy: by,
|
|
453
|
+
input
|
|
454
|
+
}));
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Take back an item this service registered, by its id.
|
|
459
|
+
*
|
|
460
|
+
* @param {Object} args - `{ id }`
|
|
461
|
+
* @returns {Promise<void>}
|
|
462
|
+
*/
|
|
463
|
+
async cancel({ id } = {}) {
|
|
464
|
+
await publishSchedulerRequest(mqClient, buildCancelRequest({
|
|
465
|
+
idempotencyKey: id,
|
|
466
|
+
requestedBy: by
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
module.exports = {
|
|
473
|
+
schedulerRequestQueue,
|
|
474
|
+
SENDABLE_KINDS,
|
|
475
|
+
validateRecurrences,
|
|
476
|
+
buildRecurringRequest,
|
|
477
|
+
buildEventRequest,
|
|
478
|
+
buildCancelRequest,
|
|
479
|
+
publishSchedulerRequest,
|
|
480
|
+
createSchedulerPublisher
|
|
481
|
+
};
|
|
@@ -1416,6 +1416,70 @@ class RabbitMQClient extends EventEmitter {
|
|
|
1416
1416
|
}
|
|
1417
1417
|
}
|
|
1418
1418
|
|
|
1419
|
+
/**
|
|
1420
|
+
* Race an `amqp.connect()` handshake against its `connectTimeout`, and own the
|
|
1421
|
+
* handshake the timeout abandons.
|
|
1422
|
+
*
|
|
1423
|
+
* When the timeout wins, the race rejects but the handshake does not stop: it
|
|
1424
|
+
* can still complete into a full AMQP connection that no field of this client
|
|
1425
|
+
* points at, so `disconnect()` never closes it and it keeps the process alive
|
|
1426
|
+
* (INFRA 359, measured with a proxy delaying the handshake past the timeout).
|
|
1427
|
+
* The rejection the caller receives is unchanged; what changes is that the
|
|
1428
|
+
* abandoned handshake is closed the moment it completes.
|
|
1429
|
+
*
|
|
1430
|
+
* @private
|
|
1431
|
+
* @param {Promise<Object>} connectPromise - the handshake from `amqp.connect()`
|
|
1432
|
+
* @param {Promise<never>} timeoutPromise - rejects with the timeout error
|
|
1433
|
+
* @param {string} phase - 'connect' or 'reconnect', named in the log lines
|
|
1434
|
+
* @returns {Promise<Object>} the connection, when the handshake won the race
|
|
1435
|
+
*/
|
|
1436
|
+
async _raceHandshake(connectPromise, timeoutPromise, phase) {
|
|
1437
|
+
let abandoned = false;
|
|
1438
|
+
const timeoutLeg = timeoutPromise.catch((err) => {
|
|
1439
|
+
abandoned = true;
|
|
1440
|
+
throw err;
|
|
1441
|
+
});
|
|
1442
|
+
try {
|
|
1443
|
+
return await Promise.race([connectPromise, timeoutLeg]);
|
|
1444
|
+
} catch (err) {
|
|
1445
|
+
if (abandoned) {
|
|
1446
|
+
this._closeAbandonedHandshake(connectPromise, phase);
|
|
1447
|
+
}
|
|
1448
|
+
throw err;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Close the connection an abandoned handshake produces, whenever it completes.
|
|
1454
|
+
* A handshake that fails after its timeout opened nothing; its error adds
|
|
1455
|
+
* nothing to the timeout the caller already received.
|
|
1456
|
+
* @private
|
|
1457
|
+
* @param {Promise<Object>} connectPromise - the handshake the timeout abandoned
|
|
1458
|
+
* @param {string} phase - 'connect' or 'reconnect'
|
|
1459
|
+
*/
|
|
1460
|
+
_closeAbandonedHandshake(connectPromise, phase) {
|
|
1461
|
+
connectPromise.then(
|
|
1462
|
+
async (late) => {
|
|
1463
|
+
this._logger.warn(
|
|
1464
|
+
`[RabbitMQClient] Handshake completed after the ${phase} timeout (${this._connectTimeout} ms) - `
|
|
1465
|
+
+ 'closing that connection, nothing in this client owns it'
|
|
1466
|
+
);
|
|
1467
|
+
try {
|
|
1468
|
+
await late.close();
|
|
1469
|
+
} catch (closeErr) {
|
|
1470
|
+
this._logger.warn(
|
|
1471
|
+
`[RabbitMQClient] Error closing the connection whose handshake outlived the ${phase} timeout: ${closeErr.message}`
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
},
|
|
1475
|
+
(lateErr) => {
|
|
1476
|
+
this._logger.debug(
|
|
1477
|
+
`[RabbitMQClient] Handshake abandoned by the ${phase} timeout failed afterwards: ${lateErr.message}`
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1419
1483
|
/**
|
|
1420
1484
|
* Clear all active timers
|
|
1421
1485
|
* @private
|
|
@@ -1459,7 +1523,7 @@ class RabbitMQClient extends EventEmitter {
|
|
|
1459
1523
|
}
|
|
1460
1524
|
});
|
|
1461
1525
|
this._logger.debug('[RabbitMQClient] Starting connection race...');
|
|
1462
|
-
this._connection = await
|
|
1526
|
+
this._connection = await this._raceHandshake(connectPromise, timeoutPromise, 'connect');
|
|
1463
1527
|
if (connectTimeoutTimer) {
|
|
1464
1528
|
this._clearTimeout(connectTimeoutTimer);
|
|
1465
1529
|
connectTimeoutTimer = null;
|
|
@@ -3941,7 +4005,7 @@ class RabbitMQClient extends EventEmitter {
|
|
|
3941
4005
|
)), this._connectTimeout);
|
|
3942
4006
|
});
|
|
3943
4007
|
|
|
3944
|
-
const revived = await
|
|
4008
|
+
const revived = await this._raceHandshake(connectPromise, timeoutPromise, 'reconnect');
|
|
3945
4009
|
|
|
3946
4010
|
// The caller may have disposed of this client while that handshake was
|
|
3947
4011
|
// in flight. The socket is up, and it belongs to nobody: keeping it
|