@entifix/amqp 0.9.0 → 0.10.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/dist/adapters/amqp-event-bus.d.ts.map +1 -1
- package/dist/adapters/amqp-event-bus.js +74 -27
- package/dist/adapters/amqp-event-bus.js.map +1 -1
- package/dist/amqp-connection/amqp-connection.d.ts +2 -2
- package/dist/amqp-connection/amqp-connection.d.ts.map +1 -1
- package/dist/amqp-connection/amqp-connection.js +4 -2
- package/dist/amqp-connection/amqp-connection.js.map +1 -1
- package/package.json +8 -8
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"amqp-event-bus.d.ts","sourceRoot":"","sources":["../../src/adapters/amqp-event-bus.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAO3E,OAAO,EACL,KAAK,QAAQ,EACb,WAAW,EACX,KAAK,YAAY,EAElB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAY,MAAM,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAEjD,OAAO,EACL,cAAc,EACd,KAAK,aAAa,EAGnB,MAAM,uCAAuC,CAAC;
|
|
1
|
+
{"version":3,"file":"amqp-event-bus.d.ts","sourceRoot":"","sources":["../../src/adapters/amqp-event-bus.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAO3E,OAAO,EACL,KAAK,QAAQ,EACb,WAAW,EACX,KAAK,YAAY,EAElB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAY,MAAM,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAEjD,OAAO,EACL,cAAc,EACd,KAAK,aAAa,EAGnB,MAAM,uCAAuC,CAAC;AAsE/C;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,YAAY,GAAI,cAAc,YAAY,KAAG,MAG1B,CAAC;AAsGjC;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,gBAAgB,GAC3B,WAAW,aAAa,EACxB,QAAQ,cAAc,KACrB;IAAE,GAAG,EAAE,QAAQ,CAAC;IAAC,eAAe,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;CAqKvD,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,2FAuB7B,CAAC"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
import { ShutdownRegistryTag, WiringRegistryTag } from '@entifix/business';
|
|
2
|
-
import {
|
|
3
|
+
import { isFailure, makeEventEnvelope, readEventEnvelope, Unavailable } from '@entifix/core';
|
|
3
4
|
import { EventBusTag } from '@entifix/transactions';
|
|
4
5
|
import { Duration, Effect, Layer } from 'effect';
|
|
5
6
|
import { AmqpChannelTag, EVENTS_DLX, EVENTS_EXCHANGE } from '../amqp-connection/amqp-connection.js';
|
|
@@ -13,6 +14,28 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
|
|
|
13
14
|
* shutdown hook's, so this interval only decides how promptly the process
|
|
14
15
|
* notices it is free to go.
|
|
15
16
|
*/ const DELIVERY_POLL = Duration.millis(25);
|
|
17
|
+
/**
|
|
18
|
+
* The poison rule, by category and never by class
|
|
19
|
+
* ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)):
|
|
20
|
+
* an answer redelivered is the same answer, so every business failure is
|
|
21
|
+
* poison, and so is `protocol` — bytes that did not decode will not decode on
|
|
22
|
+
* the fifth try. The transient categories are redelivered, bounded by
|
|
23
|
+
* `x-delivery-limit`, and so is anything uncategorised: not knowing what went
|
|
24
|
+
* wrong is not evidence that retrying is pointless.
|
|
25
|
+
*/ const classify = (error)=>isFailure(error) ? {
|
|
26
|
+
kind: error.family === 'business' || error.category === 'protocol' ? 'poison' : 'transient',
|
|
27
|
+
code: error.code,
|
|
28
|
+
category: error.category
|
|
29
|
+
} : {
|
|
30
|
+
kind: 'transient',
|
|
31
|
+
code: 'internal',
|
|
32
|
+
category: 'internal'
|
|
33
|
+
};
|
|
34
|
+
/** A payload that is not JSON: the sender's fault, decided before any handler. */ const UNREADABLE = {
|
|
35
|
+
kind: 'poison',
|
|
36
|
+
code: 'invalidEnvelope',
|
|
37
|
+
category: 'invalid'
|
|
38
|
+
};
|
|
16
39
|
/**
|
|
17
40
|
* The queue a subscription binds.
|
|
18
41
|
*
|
|
@@ -83,7 +106,37 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
|
|
|
83
106
|
* redelivery against `x-delivery-limit` and dead-letters at the ceiling, so a
|
|
84
107
|
* requeue is bounded. A broadcast queue has neither a ceiling nor a dead-letter
|
|
85
108
|
* path, so requeueing there is an unbounded loop.
|
|
86
|
-
*/
|
|
109
|
+
*/ /**
|
|
110
|
+
* Settles a delivery that failed.
|
|
111
|
+
*
|
|
112
|
+
* Poison on a `work` queue is republished to the dead-letter exchange **with
|
|
113
|
+
* its reason** — `x-failure-code` and `x-failure-category` — and then acked,
|
|
114
|
+
* rather than nacked: a nack cannot carry headers, and a quarantine of
|
|
115
|
+
* messages nobody can say anything about is a queue an operator has to replay
|
|
116
|
+
* blind. If that publish is refused the nack still quarantines it, reasonless.
|
|
117
|
+
*
|
|
118
|
+
* A transient failure is nacked for the broker to redeliver; a `broadcast`
|
|
119
|
+
* queue has no quarantine, so its failures are dropped.
|
|
120
|
+
*/ const settleFailure = (channel, message, queue, mode, failure)=>{
|
|
121
|
+
if (failure.kind === 'poison' && mode === 'work') {
|
|
122
|
+
try {
|
|
123
|
+
var _message_properties;
|
|
124
|
+
channel.publish(EVENTS_DLX, queue, message.content, _extends({}, message.properties, {
|
|
125
|
+
persistent: true,
|
|
126
|
+
headers: _extends({}, (_message_properties = message.properties) == null ? void 0 : _message_properties.headers, {
|
|
127
|
+
'x-failure-code': failure.code,
|
|
128
|
+
'x-failure-category': failure.category
|
|
129
|
+
})
|
|
130
|
+
}));
|
|
131
|
+
channel.ack(message);
|
|
132
|
+
return;
|
|
133
|
+
} catch (unused) {
|
|
134
|
+
// Fall through: quarantined without its reason beats not quarantined.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
channel.nack(message, false, requeues(failure, mode));
|
|
138
|
+
};
|
|
139
|
+
const requeues = (failure, mode)=>failure.kind === 'transient' && mode === 'work';
|
|
87
140
|
/**
|
|
88
141
|
* RabbitMQ-backed {@link EventBus}. Events go out as `event` envelopes on the
|
|
89
142
|
* shared topic exchange — the adapter owns the wire framing, so the port stays
|
|
@@ -126,9 +179,8 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
|
|
|
126
179
|
persistent: true
|
|
127
180
|
});
|
|
128
181
|
}),
|
|
129
|
-
catch: (error)=>new
|
|
130
|
-
|
|
131
|
-
name: event.name
|
|
182
|
+
catch: (error)=>new Unavailable({
|
|
183
|
+
dependency: 'amqp'
|
|
132
184
|
})
|
|
133
185
|
}).pipe(Effect.tap(()=>wiring.recordPublish(event.name).pipe(Effect.andThen(recordPublished(event.name)))), // Counted here and not in the `catch` above, which only builds the
|
|
134
186
|
// error: a rising publish-failure rate against a flat published rate
|
|
@@ -171,26 +223,22 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
|
|
|
171
223
|
// the message was never even nacked.
|
|
172
224
|
const settle = Effect.try({
|
|
173
225
|
try: ()=>JSON.parse(message.content.toString()),
|
|
174
|
-
catch: ()=>
|
|
175
|
-
}).pipe(Effect.flatMap((parsed)=>readEventEnvelope(parsed).pipe(Effect.mapError(
|
|
226
|
+
catch: ()=>UNREADABLE
|
|
227
|
+
}).pipe(Effect.flatMap((parsed)=>readEventEnvelope(parsed).pipe(Effect.mapError(classify))), // The handler carries no requirements (the subscriber closes over
|
|
176
228
|
// its store), so it runs standalone.
|
|
177
229
|
//
|
|
178
|
-
// ⚠️ A handler failure is
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
// poison zero retries avoids. Measured on
|
|
191
|
-
// `catalog.published`: a payload the contract rejects took
|
|
192
|
-
// five deliveries to quarantine.
|
|
193
|
-
Effect.flatMap((event)=>handler(event).pipe(Effect.mapError((error)=>error instanceof EntifixBuildError ? 'poison' : 'transient'))));
|
|
230
|
+
// ⚠️ A handler's failure is classified by its category, not
|
|
231
|
+
// treated as transient across the board. The transport
|
|
232
|
+
// validates `meta` and deliberately not `data` (inventing an
|
|
233
|
+
// opinion about a payload is how the bus would start knowing
|
|
234
|
+
// about domains), so a consumer that decodes its own payload
|
|
235
|
+
// is the *only* thing that can tell a malformed one from a bad
|
|
236
|
+
// afternoon at the database — and it says so by failing with
|
|
237
|
+
// a business or `protocol` failure, which quarantines at once
|
|
238
|
+
// instead of spending `x-delivery-limit` on every message
|
|
239
|
+
// behind it. Measured on `catalog.published`: a payload the
|
|
240
|
+
// contract rejected used to take five deliveries to quarantine.
|
|
241
|
+
Effect.flatMap((event)=>handler(event).pipe(Effect.mapError(classify))));
|
|
194
242
|
// Counted around the whole settle, so a shutdown waits for the ack
|
|
195
243
|
// as well as the handler: cancelling a consumer stops *new*
|
|
196
244
|
// deliveries and says nothing about this one.
|
|
@@ -202,7 +250,7 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
|
|
|
202
250
|
// inside the observability layer.
|
|
203
251
|
Effect.matchEffect({
|
|
204
252
|
onSuccess: ()=>recordConsumed(tags).pipe(Effect.andThen(Effect.sync(()=>channel.ack(message)))),
|
|
205
|
-
onFailure: (failure)=>recordFailed(tags, failure).pipe(Effect.andThen(Effect.sync(()=>channel
|
|
253
|
+
onFailure: (failure)=>recordFailed(tags, failure.kind).pipe(Effect.andThen(Effect.sync(()=>settleFailure(channel, message, queue, subscription.mode, failure))))
|
|
206
254
|
}))).finally(()=>{
|
|
207
255
|
inFlight -= 1;
|
|
208
256
|
});
|
|
@@ -211,9 +259,8 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
|
|
|
211
259
|
consumerTag
|
|
212
260
|
];
|
|
213
261
|
}),
|
|
214
|
-
catch: (error)=>new
|
|
215
|
-
|
|
216
|
-
pattern: subscription.pattern
|
|
262
|
+
catch: (error)=>new Unavailable({
|
|
263
|
+
dependency: 'amqp'
|
|
217
264
|
})
|
|
218
265
|
})
|
|
219
266
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/adapters/amqp-event-bus.ts"],"sourcesContent":["import type { WiringRegistry } from '@entifix/business';\nimport { ShutdownRegistryTag, WiringRegistryTag } from '@entifix/business';\nimport {\n EntifixBuildError,\n EntifixConnError,\n makeEventEnvelope,\n readEventEnvelope,\n} from '@entifix/core';\nimport {\n type EventBus,\n EventBusTag,\n type Subscription,\n type SubscriptionMode,\n} from '@entifix/transactions';\nimport type { Channel, ConsumeMessage } from 'amqplib';\nimport { Duration, Effect, Layer } from 'effect';\n\nimport {\n AmqpChannelTag,\n type AmqpConnector,\n EVENTS_DLX,\n EVENTS_EXCHANGE,\n} from '../amqp-connection/amqp-connection.js';\nimport {\n recordConsumed,\n recordFailed,\n recordPublished,\n recordPublishFailure,\n type SubscriptionTags,\n} from './bus-metrics.js';\n\n/** Appended to a work queue's name to make the queue that holds its failures. */\nconst QUARANTINE_SUFFIX = '.quarantine';\n\n/**\n * How often the shutdown drain re-checks whether the handlers have finished.\n *\n * A poll rather than a latch because a delivery is started from amqplib's own\n * callback, outside any fiber this module holds; the drain's own bound is the\n * shutdown hook's, so this interval only decides how promptly the process\n * notices it is free to go.\n */\nconst DELIVERY_POLL = Duration.millis(25);\n\n/**\n * Why a delivery did not succeed, which is the whole of the nack decision.\n *\n * `transient` is a handler that threw — a dependency briefly gone, a timeout —\n * and is worth another delivery. `poison` is a payload this process cannot\n * read, and is worth **none**: a message that cannot be deserialized never\n * becomes deserializable, so retrying it only spends the delivery budget of the\n * messages queued behind it. Conflating the two is what the adapter once did,\n * on a queue that discarded both.\n *\n * A *business* failure is neither, and never reaches here: that message was\n * handled successfully and produced its own `failed` event.\n */\ntype DeliveryFailure = 'transient' | 'poison';\n\n/**\n * The queue a subscription binds.\n *\n * `<subscribing slice>.<pattern>`, so the name says who is owed the messages in\n * it — readable in the broker's own UI, which is where someone looks when a\n * queue has depth. The slice is the *subscriber's*, never the publisher's:\n * `EventSourceTag` names the emitter, and one deployment hosts several slices\n * (a host may co-deploy two slices in one process), so\n * deriving from it would file a consumer's queue under whoever happened to\n * publish from the same process.\n *\n * `*` and `#` are spelled out because AMQP 0-9-1 restricts a queue name to\n * letters, digits, hyphen, underscore, period and colon. RabbitMQ is lenient in\n * practice, but the name also has to survive being a URL path segment in the\n * management API, and `%2A` in an operator's address bar is a poor trade for\n * three saved characters.\n */\nexport const queueNameFor = (subscription: Subscription): string =>\n `${subscription.slice}.${subscription.pattern\n .replaceAll('*', '_star_')\n .replaceAll('#', '_hash_')}`;\n\n/**\n * Declares the queue a subscription consumes and returns its name.\n *\n * A **work** queue is durable and quorum-backed, so it survives the consumer's\n * restart and accumulates while it is gone — the hop the outbox relay's\n * durability chain was missing. Its retry ceiling is `x-delivery-limit`, a queue argument\n * rather than logic every consumer reimplements, so it also counts a\n * redelivery after a crash.\n *\n * The quarantine is declared **first**: a message dead-lettered the instant the\n * work queue exists must have somewhere to land, and a `direct` exchange with\n * no matching binding drops what it routes.\n *\n * `x-delivery-limit` is immutable once a queue exists. Re-declaring with a\n * different `maxAttempts` fails `PRECONDITION_FAILED` and closes the channel,\n * and there is no safe automatic recovery — so changing the ceiling means\n * deleting the queue, which locally is `pnpm run <app>:dev:reset`.\n */\nconst declareQueue = async (\n channel: Channel,\n subscription: Subscription,\n): Promise<string> => {\n if (subscription.mode === 'broadcast') {\n // Connection-scoped and retained by nobody, which is right only when the\n // replicas differ from one another, as a per-connection push does.\n const { queue } = await channel.assertQueue('', { exclusive: true });\n return queue;\n }\n\n const queue = queueNameFor(subscription);\n const quarantine = `${queue}${QUARANTINE_SUFFIX}`;\n\n await channel.assertQueue(quarantine, {\n durable: true,\n arguments: { 'x-queue-type': 'quorum' },\n });\n await channel.bindQueue(quarantine, EVENTS_DLX, queue);\n await channel.assertQueue(queue, {\n durable: true,\n arguments: {\n 'x-queue-type': 'quorum',\n 'x-delivery-limit': subscription.maxAttempts,\n 'x-dead-letter-exchange': EVENTS_DLX,\n 'x-dead-letter-routing-key': queue,\n },\n });\n\n return queue;\n};\n\n/**\n * Whether a failed delivery should go back on the queue.\n *\n * Only a transient failure on a **work** queue: there the broker counts the\n * redelivery against `x-delivery-limit` and dead-letters at the ceiling, so a\n * requeue is bounded. A broadcast queue has neither a ceiling nor a dead-letter\n * path, so requeueing there is an unbounded loop.\n */\nconst requeues = (failure: DeliveryFailure, mode: SubscriptionMode): boolean =>\n failure === 'transient' && mode === 'work';\n\n/**\n * RabbitMQ-backed {@link EventBus}. Events go out as `event` envelopes on the\n * shared topic exchange — the adapter owns the wire framing, so the port stays\n * event-typed.\n *\n * **The event's own name is the routing key**, which is what lets a subscriber\n * bind the exact pattern it declared in `tools/slices/*.slice.ts`.\n *\n * The queue a subscriber binds is its own choice of shape. A `work`\n * subscription gets a named durable queue that replicas share and that keeps\n * accumulating while the consumer is down; a `broadcast` one gets an exclusive\n * queue, where every replica sees every event and nothing\n * survives a restart. Choosing broadcast for a workload that is work is how an\n * event published during a rollout is dropped by the broker while the outbox\n * has already recorded it sent.\n *\n * Both methods go through the {@link AmqpConnector} rather than holding a\n * channel, because a channel does not survive the broker restarting. `publish`\n * asks for a live one per call, and `subscribe` registers its setup so the\n * connector can re-run it against the new channel after a reconnect. A durable\n * queue outlives that reconnect; the consumer bound to it does not.\n *\n * It returns the bus **and** its `drainDeliveries`, rather than only the port:\n * waiting for in-flight handlers is an adapter fact — it is this module that\n * runs them — and putting it on `EventBus` would make every implementation of\n * a framework-free port carry a shutdown concern it has no deliveries to drain.\n */\nexport const makeAmqpEventBus = (\n connector: AmqpConnector,\n wiring: WiringRegistry,\n): { bus: EventBus; drainDeliveries: Effect.Effect<void> } => {\n /**\n * Handlers started and not yet settled.\n *\n * Nothing tracked them before, so a SIGTERM killed them mid-flight and the\n * broker redelivered whatever was unacked — safe only while every consumer\n * happens to be idempotent, which is the assumption the command inbox stops\n * making.\n */\n let inFlight = 0;\n\n const bus: EventBus = {\n publish: event =>\n Effect.tryPromise({\n try: () =>\n connector.withChannel(async channel => {\n channel.publish(\n EVENTS_EXCHANGE,\n event.name,\n Buffer.from(JSON.stringify(makeEventEnvelope(event))),\n { persistent: true },\n );\n }),\n catch: error =>\n new EntifixConnError('AMQP publish failed', error, {\n eventId: event.id,\n name: event.name,\n }),\n // Recorded **after** the publish succeeds, so `GET /api/$service` reports\n // what this process actually put on the exchange. Recording the intent\n // instead would make the declared-vs-observed diff pass on a service whose\n // every publish is failing.\n }).pipe(\n Effect.tap(() =>\n wiring\n .recordPublish(event.name)\n .pipe(Effect.andThen(recordPublished(event.name))),\n ),\n // Counted here and not in the `catch` above, which only builds the\n // error: a rising publish-failure rate against a flat published rate\n // is what a broker outage looks like from the publisher's side, and\n // the outbox's own backlog is the confirmation.\n Effect.tapError(() => recordPublishFailure(event.name)),\n ),\n\n subscribe: (subscription, handler) =>\n Effect.tryPromise({\n try: () =>\n connector.addConsumer(async channel => {\n // One unacked message at a time: events for a transaction are then\n // folded serially (never concurrently), so an `accepted`/`completed`\n // pair can't race into two upserts.\n await channel.prefetch(1);\n const queue = await declareQueue(channel, subscription);\n await channel.bindQueue(\n queue,\n EVENTS_EXCHANGE,\n subscription.pattern,\n );\n // What the process **bound**, recorded at the point it bound it. The\n // registry deduplicates, so re-running this setup after a reconnect\n // does not double the entry.\n await Effect.runPromise(\n wiring.recordSubscription({\n slice: subscription.slice,\n pattern: subscription.pattern,\n mode: subscription.mode,\n queue,\n }),\n );\n // Resolved once per binding rather than per delivery: `queue` is the\n // durable name for a `work` subscription and the broker's generated\n // one for a `broadcast` queue, so it is only knowable here.\n const tags: SubscriptionTags = {\n queue,\n slice: subscription.slice,\n mode: subscription.mode,\n };\n const { consumerTag } = await channel.consume(\n queue,\n (message: ConsumeMessage | null) => {\n if (message === null) {\n return;\n }\n\n // Reading the payload and running the handler are separated on\n // purpose: they fail for different reasons and earn different\n // treatment. `JSON.parse` is inside the Effect because it throws\n // *synchronously* — outside, it escaped into amqplib's callback and\n // the message was never even nacked.\n const settle = Effect.try({\n try: () => JSON.parse(message.content.toString()) as unknown,\n catch: (): DeliveryFailure => 'poison',\n }).pipe(\n Effect.flatMap(parsed =>\n readEventEnvelope(parsed).pipe(\n Effect.mapError((): DeliveryFailure => 'poison'),\n ),\n ),\n // The handler carries no requirements (the subscriber closes over\n // its store), so it runs standalone.\n //\n // ⚠️ A handler failure is transient **except** an\n // `EntifixBuildError`, which means malformed input — the\n // client's fault, by that class's own definition. The\n // transport validates `meta` and deliberately not `data`\n // (`readEventEnvelope` says so: inventing an opinion about a\n // payload is how the bus would start knowing about domains),\n // so a consumer that decodes its own payload is the *only*\n // thing that can tell a malformed one from a bad afternoon at\n // the database. Without this arm such a message is requeued\n // and retried to `x-delivery-limit` before it reaches the\n // quarantine it belonged in immediately — spending the budget\n // of every message behind it, which is exactly what giving\n // poison zero retries avoids. Measured on\n // `catalog.published`: a payload the contract rejects took\n // five deliveries to quarantine.\n Effect.flatMap(event =>\n handler(event).pipe(\n Effect.mapError((error): DeliveryFailure =>\n error instanceof EntifixBuildError\n ? 'poison'\n : 'transient',\n ),\n ),\n ),\n );\n\n // Counted around the whole settle, so a shutdown waits for the ack\n // as well as the handler: cancelling a consumer stops *new*\n // deliveries and says nothing about this one.\n inFlight += 1;\n void Effect.runPromise(\n settle.pipe(\n // Effect's metric registry is a process-wide singleton, so an\n // increment inside this detached `runPromise` reaches the same\n // producer `NodeSdk` exports from. That is the only reason a\n // metric works here at all — nothing about this fiber is\n // inside the observability layer.\n Effect.matchEffect({\n onSuccess: () =>\n recordConsumed(tags).pipe(\n Effect.andThen(\n Effect.sync(() => channel.ack(message)),\n ),\n ),\n onFailure: failure =>\n recordFailed(tags, failure).pipe(\n Effect.andThen(\n Effect.sync(() =>\n channel.nack(\n message,\n false,\n requeues(failure, subscription.mode),\n ),\n ),\n ),\n ),\n }),\n ),\n ).finally(() => {\n inFlight -= 1;\n });\n },\n );\n return [consumerTag];\n }),\n catch: error =>\n new EntifixConnError('AMQP subscribe failed', error, {\n slice: subscription.slice,\n pattern: subscription.pattern,\n }),\n }),\n };\n\n return {\n bus,\n drainDeliveries: Effect.gen(function* () {\n while (inFlight > 0) {\n yield* Effect.sleep(DELIVERY_POLL);\n }\n }),\n };\n};\n\n/**\n * Provides {@link EventBusTag} from an {@link AmqpChannelTag}.\n *\n * It also takes {@link WiringRegistryTag}, which `makeServerLayer` provides once\n * per service — so the bus and `GET /api/$service` share one instance, and the\n * document reports the bindings this very process made.\n */\nexport const AmqpEventBusLayer = Layer.effect(\n EventBusTag,\n Effect.gen(function* () {\n const connector = yield* AmqpChannelTag;\n const wiring = yield* WiringRegistryTag;\n const shutdown = yield* ShutdownRegistryTag;\n const { bus, drainDeliveries } = makeAmqpEventBus(connector, wiring);\n\n // `stop-intake`, and both halves are it: cancelling stops the broker\n // handing over anything new, then the drain waits for the deliveries\n // already in progress. Registered here rather than in a service's `main.ts`\n // for the reason every registry in this repo exists — a service that gains\n // a bus gains its drain, with nothing to remember.\n yield* shutdown.register({\n name: 'amqp-consumers',\n phase: 'stop-intake',\n run: Effect.promise(() => connector.cancelConsumers()).pipe(\n Effect.andThen(drainDeliveries),\n ),\n });\n\n return bus;\n }),\n);\n"],"names":["ShutdownRegistryTag","WiringRegistryTag","EntifixBuildError","EntifixConnError","makeEventEnvelope","readEventEnvelope","EventBusTag","Duration","Effect","Layer","AmqpChannelTag","EVENTS_DLX","EVENTS_EXCHANGE","recordConsumed","recordFailed","recordPublished","recordPublishFailure","QUARANTINE_SUFFIX","DELIVERY_POLL","millis","queueNameFor","subscription","slice","pattern","replaceAll","declareQueue","channel","mode","queue","assertQueue","exclusive","quarantine","durable","arguments","bindQueue","maxAttempts","requeues","failure","makeAmqpEventBus","connector","wiring","inFlight","bus","publish","event","tryPromise","try","withChannel","name","Buffer","from","JSON","stringify","persistent","catch","error","eventId","id","pipe","tap","recordPublish","andThen","tapError","subscribe","handler","addConsumer","prefetch","runPromise","recordSubscription","tags","consumerTag","consume","message","settle","parse","content","toString","flatMap","parsed","mapError","matchEffect","onSuccess","sync","ack","onFailure","nack","finally","drainDeliveries","gen","sleep","AmqpEventBusLayer","effect","shutdown","register","phase","run","promise","cancelConsumers"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/adapters/amqp-event-bus.ts"],"sourcesContent":["import type { WiringRegistry } from '@entifix/business';\nimport { ShutdownRegistryTag, WiringRegistryTag } from '@entifix/business';\nimport {\n isFailure,\n makeEventEnvelope,\n readEventEnvelope,\n Unavailable,\n} from '@entifix/core';\nimport {\n type EventBus,\n EventBusTag,\n type Subscription,\n type SubscriptionMode,\n} from '@entifix/transactions';\nimport type { Channel, ConsumeMessage } from 'amqplib';\nimport { Duration, Effect, Layer } from 'effect';\n\nimport {\n AmqpChannelTag,\n type AmqpConnector,\n EVENTS_DLX,\n EVENTS_EXCHANGE,\n} from '../amqp-connection/amqp-connection.js';\nimport {\n recordConsumed,\n recordFailed,\n recordPublished,\n recordPublishFailure,\n type SubscriptionTags,\n} from './bus-metrics.js';\n\n/** Appended to a work queue's name to make the queue that holds its failures. */\nconst QUARANTINE_SUFFIX = '.quarantine';\n\n/**\n * How often the shutdown drain re-checks whether the handlers have finished.\n *\n * A poll rather than a latch because a delivery is started from amqplib's own\n * callback, outside any fiber this module holds; the drain's own bound is the\n * shutdown hook's, so this interval only decides how promptly the process\n * notices it is free to go.\n */\nconst DELIVERY_POLL = Duration.millis(25);\n\n/**\n * Why a delivery did not succeed, which is the whole of the nack decision.\n *\n * `transient` is a handler that threw — a dependency briefly gone, a timeout —\n * and is worth another delivery. `poison` is a payload this process cannot\n * read, and is worth **none**: a message that cannot be deserialized never\n * becomes deserializable, so retrying it only spends the delivery budget of the\n * messages queued behind it. Conflating the two is what the adapter once did,\n * on a queue that discarded both.\n *\n * A *business* failure is neither, and never reaches here: that message was\n * handled successfully and produced its own `failed` event.\n */\ninterface DeliveryFailure {\n readonly kind: 'transient' | 'poison';\n /** The failure's code and category, carried onto a quarantined message. */\n readonly code: string;\n readonly category: string;\n}\n\n/**\n * The poison rule, by category and never by class\n * ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)):\n * an answer redelivered is the same answer, so every business failure is\n * poison, and so is `protocol` — bytes that did not decode will not decode on\n * the fifth try. The transient categories are redelivered, bounded by\n * `x-delivery-limit`, and so is anything uncategorised: not knowing what went\n * wrong is not evidence that retrying is pointless.\n */\nconst classify = (error: unknown): DeliveryFailure =>\n isFailure(error)\n ? {\n kind:\n error.family === 'business' || error.category === 'protocol'\n ? 'poison'\n : 'transient',\n code: error.code,\n category: error.category,\n }\n : { kind: 'transient', code: 'internal', category: 'internal' };\n\n/** A payload that is not JSON: the sender's fault, decided before any handler. */\nconst UNREADABLE: DeliveryFailure = {\n kind: 'poison',\n code: 'invalidEnvelope',\n category: 'invalid',\n};\n\n/**\n * The queue a subscription binds.\n *\n * `<subscribing slice>.<pattern>`, so the name says who is owed the messages in\n * it — readable in the broker's own UI, which is where someone looks when a\n * queue has depth. The slice is the *subscriber's*, never the publisher's:\n * `EventSourceTag` names the emitter, and one deployment hosts several slices\n * (a host may co-deploy two slices in one process), so\n * deriving from it would file a consumer's queue under whoever happened to\n * publish from the same process.\n *\n * `*` and `#` are spelled out because AMQP 0-9-1 restricts a queue name to\n * letters, digits, hyphen, underscore, period and colon. RabbitMQ is lenient in\n * practice, but the name also has to survive being a URL path segment in the\n * management API, and `%2A` in an operator's address bar is a poor trade for\n * three saved characters.\n */\nexport const queueNameFor = (subscription: Subscription): string =>\n `${subscription.slice}.${subscription.pattern\n .replaceAll('*', '_star_')\n .replaceAll('#', '_hash_')}`;\n\n/**\n * Declares the queue a subscription consumes and returns its name.\n *\n * A **work** queue is durable and quorum-backed, so it survives the consumer's\n * restart and accumulates while it is gone — the hop the outbox relay's\n * durability chain was missing. Its retry ceiling is `x-delivery-limit`, a queue argument\n * rather than logic every consumer reimplements, so it also counts a\n * redelivery after a crash.\n *\n * The quarantine is declared **first**: a message dead-lettered the instant the\n * work queue exists must have somewhere to land, and a `direct` exchange with\n * no matching binding drops what it routes.\n *\n * `x-delivery-limit` is immutable once a queue exists. Re-declaring with a\n * different `maxAttempts` fails `PRECONDITION_FAILED` and closes the channel,\n * and there is no safe automatic recovery — so changing the ceiling means\n * deleting the queue, which locally is `pnpm run <app>:dev:reset`.\n */\nconst declareQueue = async (\n channel: Channel,\n subscription: Subscription,\n): Promise<string> => {\n if (subscription.mode === 'broadcast') {\n // Connection-scoped and retained by nobody, which is right only when the\n // replicas differ from one another, as a per-connection push does.\n const { queue } = await channel.assertQueue('', { exclusive: true });\n return queue;\n }\n\n const queue = queueNameFor(subscription);\n const quarantine = `${queue}${QUARANTINE_SUFFIX}`;\n\n await channel.assertQueue(quarantine, {\n durable: true,\n arguments: { 'x-queue-type': 'quorum' },\n });\n await channel.bindQueue(quarantine, EVENTS_DLX, queue);\n await channel.assertQueue(queue, {\n durable: true,\n arguments: {\n 'x-queue-type': 'quorum',\n 'x-delivery-limit': subscription.maxAttempts,\n 'x-dead-letter-exchange': EVENTS_DLX,\n 'x-dead-letter-routing-key': queue,\n },\n });\n\n return queue;\n};\n\n/**\n * Whether a failed delivery should go back on the queue.\n *\n * Only a transient failure on a **work** queue: there the broker counts the\n * redelivery against `x-delivery-limit` and dead-letters at the ceiling, so a\n * requeue is bounded. A broadcast queue has neither a ceiling nor a dead-letter\n * path, so requeueing there is an unbounded loop.\n */\n/**\n * Settles a delivery that failed.\n *\n * Poison on a `work` queue is republished to the dead-letter exchange **with\n * its reason** — `x-failure-code` and `x-failure-category` — and then acked,\n * rather than nacked: a nack cannot carry headers, and a quarantine of\n * messages nobody can say anything about is a queue an operator has to replay\n * blind. If that publish is refused the nack still quarantines it, reasonless.\n *\n * A transient failure is nacked for the broker to redeliver; a `broadcast`\n * queue has no quarantine, so its failures are dropped.\n */\nconst settleFailure = (\n channel: Channel,\n message: ConsumeMessage,\n queue: string,\n mode: SubscriptionMode,\n failure: DeliveryFailure,\n): void => {\n if (failure.kind === 'poison' && mode === 'work') {\n try {\n channel.publish(EVENTS_DLX, queue, message.content, {\n ...message.properties,\n persistent: true,\n headers: {\n ...message.properties?.headers,\n 'x-failure-code': failure.code,\n 'x-failure-category': failure.category,\n },\n });\n channel.ack(message);\n return;\n } catch {\n // Fall through: quarantined without its reason beats not quarantined.\n }\n }\n channel.nack(message, false, requeues(failure, mode));\n};\n\nconst requeues = (failure: DeliveryFailure, mode: SubscriptionMode): boolean =>\n failure.kind === 'transient' && mode === 'work';\n\n/**\n * RabbitMQ-backed {@link EventBus}. Events go out as `event` envelopes on the\n * shared topic exchange — the adapter owns the wire framing, so the port stays\n * event-typed.\n *\n * **The event's own name is the routing key**, which is what lets a subscriber\n * bind the exact pattern it declared in `tools/slices/*.slice.ts`.\n *\n * The queue a subscriber binds is its own choice of shape. A `work`\n * subscription gets a named durable queue that replicas share and that keeps\n * accumulating while the consumer is down; a `broadcast` one gets an exclusive\n * queue, where every replica sees every event and nothing\n * survives a restart. Choosing broadcast for a workload that is work is how an\n * event published during a rollout is dropped by the broker while the outbox\n * has already recorded it sent.\n *\n * Both methods go through the {@link AmqpConnector} rather than holding a\n * channel, because a channel does not survive the broker restarting. `publish`\n * asks for a live one per call, and `subscribe` registers its setup so the\n * connector can re-run it against the new channel after a reconnect. A durable\n * queue outlives that reconnect; the consumer bound to it does not.\n *\n * It returns the bus **and** its `drainDeliveries`, rather than only the port:\n * waiting for in-flight handlers is an adapter fact — it is this module that\n * runs them — and putting it on `EventBus` would make every implementation of\n * a framework-free port carry a shutdown concern it has no deliveries to drain.\n */\nexport const makeAmqpEventBus = (\n connector: AmqpConnector,\n wiring: WiringRegistry,\n): { bus: EventBus; drainDeliveries: Effect.Effect<void> } => {\n /**\n * Handlers started and not yet settled.\n *\n * Nothing tracked them before, so a SIGTERM killed them mid-flight and the\n * broker redelivered whatever was unacked — safe only while every consumer\n * happens to be idempotent, which is the assumption the command inbox stops\n * making.\n */\n let inFlight = 0;\n\n const bus: EventBus = {\n publish: event =>\n Effect.tryPromise({\n try: () =>\n connector.withChannel(async channel => {\n channel.publish(\n EVENTS_EXCHANGE,\n event.name,\n Buffer.from(JSON.stringify(makeEventEnvelope(event))),\n { persistent: true },\n );\n }),\n catch: error => new Unavailable({ dependency: 'amqp' }),\n // Recorded **after** the publish succeeds, so `GET /api/$service` reports\n // what this process actually put on the exchange. Recording the intent\n // instead would make the declared-vs-observed diff pass on a service whose\n // every publish is failing.\n }).pipe(\n Effect.tap(() =>\n wiring\n .recordPublish(event.name)\n .pipe(Effect.andThen(recordPublished(event.name))),\n ),\n // Counted here and not in the `catch` above, which only builds the\n // error: a rising publish-failure rate against a flat published rate\n // is what a broker outage looks like from the publisher's side, and\n // the outbox's own backlog is the confirmation.\n Effect.tapError(() => recordPublishFailure(event.name)),\n ),\n\n subscribe: (subscription, handler) =>\n Effect.tryPromise({\n try: () =>\n connector.addConsumer(async channel => {\n // One unacked message at a time: events for a transaction are then\n // folded serially (never concurrently), so an `accepted`/`completed`\n // pair can't race into two upserts.\n await channel.prefetch(1);\n const queue = await declareQueue(channel, subscription);\n await channel.bindQueue(\n queue,\n EVENTS_EXCHANGE,\n subscription.pattern,\n );\n // What the process **bound**, recorded at the point it bound it. The\n // registry deduplicates, so re-running this setup after a reconnect\n // does not double the entry.\n await Effect.runPromise(\n wiring.recordSubscription({\n slice: subscription.slice,\n pattern: subscription.pattern,\n mode: subscription.mode,\n queue,\n }),\n );\n // Resolved once per binding rather than per delivery: `queue` is the\n // durable name for a `work` subscription and the broker's generated\n // one for a `broadcast` queue, so it is only knowable here.\n const tags: SubscriptionTags = {\n queue,\n slice: subscription.slice,\n mode: subscription.mode,\n };\n const { consumerTag } = await channel.consume(\n queue,\n (message: ConsumeMessage | null) => {\n if (message === null) {\n return;\n }\n\n // Reading the payload and running the handler are separated on\n // purpose: they fail for different reasons and earn different\n // treatment. `JSON.parse` is inside the Effect because it throws\n // *synchronously* — outside, it escaped into amqplib's callback and\n // the message was never even nacked.\n const settle = Effect.try({\n try: () => JSON.parse(message.content.toString()) as unknown,\n catch: (): DeliveryFailure => UNREADABLE,\n }).pipe(\n Effect.flatMap(parsed =>\n readEventEnvelope(parsed).pipe(Effect.mapError(classify)),\n ),\n // The handler carries no requirements (the subscriber closes over\n // its store), so it runs standalone.\n //\n // ⚠️ A handler's failure is classified by its category, not\n // treated as transient across the board. The transport\n // validates `meta` and deliberately not `data` (inventing an\n // opinion about a payload is how the bus would start knowing\n // about domains), so a consumer that decodes its own payload\n // is the *only* thing that can tell a malformed one from a bad\n // afternoon at the database — and it says so by failing with\n // a business or `protocol` failure, which quarantines at once\n // instead of spending `x-delivery-limit` on every message\n // behind it. Measured on `catalog.published`: a payload the\n // contract rejected used to take five deliveries to quarantine.\n Effect.flatMap(event =>\n handler(event).pipe(Effect.mapError(classify)),\n ),\n );\n\n // Counted around the whole settle, so a shutdown waits for the ack\n // as well as the handler: cancelling a consumer stops *new*\n // deliveries and says nothing about this one.\n inFlight += 1;\n void Effect.runPromise(\n settle.pipe(\n // Effect's metric registry is a process-wide singleton, so an\n // increment inside this detached `runPromise` reaches the same\n // producer `NodeSdk` exports from. That is the only reason a\n // metric works here at all — nothing about this fiber is\n // inside the observability layer.\n Effect.matchEffect({\n onSuccess: () =>\n recordConsumed(tags).pipe(\n Effect.andThen(\n Effect.sync(() => channel.ack(message)),\n ),\n ),\n onFailure: failure =>\n recordFailed(tags, failure.kind).pipe(\n Effect.andThen(\n Effect.sync(() =>\n settleFailure(\n channel,\n message,\n queue,\n subscription.mode,\n failure,\n ),\n ),\n ),\n ),\n }),\n ),\n ).finally(() => {\n inFlight -= 1;\n });\n },\n );\n return [consumerTag];\n }),\n catch: error => new Unavailable({ dependency: 'amqp' }),\n }),\n };\n\n return {\n bus,\n drainDeliveries: Effect.gen(function* () {\n while (inFlight > 0) {\n yield* Effect.sleep(DELIVERY_POLL);\n }\n }),\n };\n};\n\n/**\n * Provides {@link EventBusTag} from an {@link AmqpChannelTag}.\n *\n * It also takes {@link WiringRegistryTag}, which `makeServerLayer` provides once\n * per service — so the bus and `GET /api/$service` share one instance, and the\n * document reports the bindings this very process made.\n */\nexport const AmqpEventBusLayer = Layer.effect(\n EventBusTag,\n Effect.gen(function* () {\n const connector = yield* AmqpChannelTag;\n const wiring = yield* WiringRegistryTag;\n const shutdown = yield* ShutdownRegistryTag;\n const { bus, drainDeliveries } = makeAmqpEventBus(connector, wiring);\n\n // `stop-intake`, and both halves are it: cancelling stops the broker\n // handing over anything new, then the drain waits for the deliveries\n // already in progress. Registered here rather than in a service's `main.ts`\n // for the reason every registry in this repo exists — a service that gains\n // a bus gains its drain, with nothing to remember.\n yield* shutdown.register({\n name: 'amqp-consumers',\n phase: 'stop-intake',\n run: Effect.promise(() => connector.cancelConsumers()).pipe(\n Effect.andThen(drainDeliveries),\n ),\n });\n\n return bus;\n }),\n);\n"],"names":["ShutdownRegistryTag","WiringRegistryTag","isFailure","makeEventEnvelope","readEventEnvelope","Unavailable","EventBusTag","Duration","Effect","Layer","AmqpChannelTag","EVENTS_DLX","EVENTS_EXCHANGE","recordConsumed","recordFailed","recordPublished","recordPublishFailure","QUARANTINE_SUFFIX","DELIVERY_POLL","millis","classify","error","kind","family","category","code","UNREADABLE","queueNameFor","subscription","slice","pattern","replaceAll","declareQueue","channel","mode","queue","assertQueue","exclusive","quarantine","durable","arguments","bindQueue","maxAttempts","settleFailure","message","failure","publish","content","properties","persistent","headers","ack","nack","requeues","makeAmqpEventBus","connector","wiring","inFlight","bus","event","tryPromise","try","withChannel","name","Buffer","from","JSON","stringify","catch","dependency","pipe","tap","recordPublish","andThen","tapError","subscribe","handler","addConsumer","prefetch","runPromise","recordSubscription","tags","consumerTag","consume","settle","parse","toString","flatMap","parsed","mapError","matchEffect","onSuccess","sync","onFailure","finally","drainDeliveries","gen","sleep","AmqpEventBusLayer","effect","shutdown","register","phase","run","promise","cancelConsumers"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Unavailable } from '@entifix/core';
|
|
2
2
|
import * as amqp from 'amqplib';
|
|
3
3
|
import { Context, Layer } from 'effect';
|
|
4
4
|
/**
|
|
@@ -99,6 +99,6 @@ export declare const makeAmqpConnector: (uri: string) => {
|
|
|
99
99
|
* the service's startup exactly as it did before — a service silently starting
|
|
100
100
|
* without its bus would be a worse trade than the reconnection buys.
|
|
101
101
|
*/
|
|
102
|
-
export declare const AmqpLayer: (settings: AmqpSettings) => Layer.Layer<AmqpChannelTag,
|
|
102
|
+
export declare const AmqpLayer: (settings: AmqpSettings) => Layer.Layer<AmqpChannelTag, Unavailable>;
|
|
103
103
|
export {};
|
|
104
104
|
//# sourceMappingURL=amqp-connection.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"amqp-connection.d.ts","sourceRoot":"","sources":["../../src/amqp-connection/amqp-connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"amqp-connection.d.ts","sourceRoot":"","sources":["../../src/amqp-connection/amqp-connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,KAAK,IAAI,MAAM,SAAS,CAAC;AAChC,OAAO,EAAE,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AAEhD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,eAAe,mBAAmB,CAAC;AAEhD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,uBAAuB,CAAC;AAE/C;;;;;;;;GAQG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAC9B,OAAO,EAAE,IAAI,CAAC,OAAO,KAClB,OAAO,CAAC,IAAI,GAAG,SAAS,MAAM,EAAE,CAAC,CAAC;AAEvC;;;;;;;;;GASG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,WAAW,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACvE;;;;OAIG;IACH,WAAW,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD;;;;;;;;;OASG;IACH,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAClC;;AAED,uEAAuE;AACvE,qBAAa,cAAe,SAAQ,mBAGf;CAAG;AAExB,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAsBD;;GAEG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM;;;CAsH5C,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,SAAS,GACpB,UAAU,YAAY,KACrB,KAAK,CAAC,KAAK,CAAC,cAAc,EAAE,WAAW,CAcvC,CAAC"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Unavailable } from '@entifix/core';
|
|
2
2
|
import * as amqp from 'amqplib';
|
|
3
3
|
import { Context, Effect, Layer } from 'effect';
|
|
4
4
|
/**
|
|
@@ -168,7 +168,9 @@ import { Context, Effect, Layer } from 'effect';
|
|
|
168
168
|
await built.connector.withChannel(async ()=>undefined);
|
|
169
169
|
return built;
|
|
170
170
|
},
|
|
171
|
-
catch: (error)=>new
|
|
171
|
+
catch: (error)=>new Unavailable({
|
|
172
|
+
dependency: 'amqp'
|
|
173
|
+
})
|
|
172
174
|
}), (built)=>Effect.promise(()=>built.close())).pipe(Effect.map((built)=>built.connector)));
|
|
173
175
|
|
|
174
176
|
//# sourceMappingURL=amqp-connection.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/amqp-connection/amqp-connection.ts"],"sourcesContent":["import { EntifixConnError } from '@entifix/core';\nimport * as amqp from 'amqplib';\nimport { Context, Effect, Layer } from 'effect';\n\n/**\n * The topic exchange every domain event is published to, keyed on the event's\n * own name (`transaction.completed`, `catalog.published`).\n *\n * Topic rather than fanout, and a **new name** rather than a redeclaration: a\n * broker will not change an existing exchange's type, so the old\n * `entifix.transactions` fanout had to be left behind rather than migrated. It\n * lingers, unbound and unused, in any dev broker that predates this — a\n * `dev:reset` is what clears it.\n *\n * Fanout meant every subscriber received every publisher's traffic and threw\n * away what it did not want, in its own handler. That put the routing rule in\n * the one place no register can check it, and it does not survive a second\n * publisher: a projection consuming its own event would have been folding\n * transaction events to find it.\n */\nexport const EVENTS_EXCHANGE = 'entifix.events';\n\n/**\n * Where a message goes when its queue gives up on it.\n *\n * **`direct`**, not topic: it routes on the *queue's* name rather than on the\n * event's, because a quarantine is per queue. One shared quarantine would mean\n * a replay redelivers another subscriber's messages — a recovery step that\n * causes a second incident.\n *\n * Declared beside the event exchange rather than inside `subscribe`, so it is\n * restored on every reconnect and a publisher-only service still agrees with\n * the fleet about the topology it is part of.\n */\nexport const EVENTS_DLX = 'entifix.events.dlx';\n\n/**\n * Re-establishes a consumer against a freshly opened channel.\n *\n * It returns the consumer tags it created, which is what makes cancelling\n * possible: `channel.consume` hands its tag back to the caller and nowhere\n * else, so a setup that swallowed it would leave the connector able to open\n * consumers and never to stop them. `void` is accepted for a setup that starts\n * none (a declaration-only one).\n */\nexport type AmqpConsumerSetup = (\n channel: amqp.Channel,\n) => Promise<void | readonly string[]>;\n\n/**\n * A RabbitMQ connection that survives the broker going away.\n *\n * This is not a convenience. `amqplib` does not reconnect: a connection opened\n * at boot and held in a `Layer` is dead permanently once the broker restarts,\n * and both halves of the bus die with it — publishes fail forever, and the saga\n * tracker's consumer stops folding events without ever erroring again. The\n * transactional outbox makes an event durable, but durability is worthless if\n * nothing can ever carry it, so the transport has to heal itself.\n */\nexport interface AmqpConnector {\n /**\n * Run `use` against a live channel, opening or reopening one as needed. Retries\n * once on a broken channel so a publish that races a broker restart succeeds\n * rather than surfacing an error the caller cannot act on.\n */\n withChannel<A>(use: (channel: amqp.Channel) => Promise<A>): Promise<A>;\n /**\n * Register a consumer, and re-register it after every reconnect. The setup is\n * re-run against the new channel, which is what makes a subscriber survive an\n * outage — its exclusive queue died with the old connection.\n */\n addConsumer(setup: AmqpConsumerSetup): Promise<void>;\n /**\n * Stop delivering. Cancels every registered consumer and latches the\n * connector so a reconnect does not quietly re-arm them.\n *\n * The first phase of a graceful shutdown: the messages already\n * being handled are still being handled — cancelling only stops *new*\n * deliveries — so the caller waits for those separately before the connection\n * closes. Without this a rollout kills handlers mid-flight and the broker\n * redelivers whatever was unacked.\n */\n cancelConsumers(): Promise<void>;\n}\n\n/** DI tag carrying the self-healing connection (closed on release). */\nexport class AmqpChannelTag extends Context.Service<\n AmqpChannelTag,\n AmqpConnector\n>()('AmqpChannelTag') {}\n\nexport interface AmqpSettings {\n readonly uri: string;\n}\n\ninterface Live {\n connection: amqp.ChannelModel;\n channel: amqp.Channel;\n}\n\n/**\n * A registered consumer and the channel it is currently bound to.\n *\n * Tracking the channel is what makes binding idempotent. Without it, a consumer\n * registered while the first connection is opening is run twice — once by the\n * open itself and once by `addConsumer` — and a doubly-bound subscriber folds\n * every event twice.\n */\ninterface Consumer {\n setup: AmqpConsumerSetup;\n boundTo?: amqp.Channel;\n /** The tags this consumer holds on {@link Consumer.boundTo}, for cancelling. */\n tags: readonly string[];\n}\n\n/**\n * Builds the connector. Exported for the specs; services take it from the layer.\n */\nexport const makeAmqpConnector = (uri: string) => {\n let live: Live | undefined;\n let opening: Promise<Live> | undefined;\n let closed = false;\n let cancelled = false;\n const consumers: Consumer[] = [];\n\n /**\n * Bind every consumer that is not already bound to this channel.\n *\n * A no-op once the consumers have been cancelled: a shutdown that races a\n * broker reconnect would otherwise re-arm the very consumers the drain just\n * stopped, and start taking deliveries the process has no time left to\n * finish.\n */\n const bindAll = async (channel: amqp.Channel) => {\n if (cancelled) {\n return;\n }\n for (const consumer of consumers) {\n if (consumer.boundTo !== channel) {\n consumer.tags = (await consumer.setup(channel)) ?? [];\n consumer.boundTo = channel;\n }\n }\n };\n\n const open = async (): Promise<Live> => {\n const connection = await amqp.connect(uri);\n const channel = await connection.createChannel();\n await channel.assertExchange(EVENTS_EXCHANGE, 'topic', {\n durable: true,\n });\n await channel.assertExchange(EVENTS_DLX, 'direct', { durable: true });\n\n // Forget this connection the moment it breaks, so the next call reopens\n // instead of writing into a dead socket. The handlers are also what keep an\n // 'error' event from reaching Node's default handler and killing the\n // process — an unhandled 'error' on an amqplib connection is fatal.\n const drop = () => {\n if (live?.connection === connection) {\n live = undefined;\n }\n };\n connection.on('close', drop);\n connection.on('error', drop);\n channel.on('close', drop);\n channel.on('error', drop);\n\n live = { connection, channel };\n\n // Re-arm every subscriber against the new channel. Nothing else does this:\n // the queues they were consuming from went away with the old connection.\n await bindAll(channel);\n return live;\n };\n\n const ensure = async (): Promise<Live> => {\n if (closed) {\n throw new Error('AMQP connector is closed');\n }\n if (live) {\n return live;\n }\n // One open at a time: a burst of publishes after an outage would otherwise\n // race and leave orphaned connections behind, each with its own consumers.\n opening ??= open().finally(() => {\n opening = undefined;\n });\n return opening;\n };\n\n const connector: AmqpConnector = {\n withChannel: async use => {\n try {\n return await use((await ensure()).channel);\n } catch (error) {\n if (closed) {\n throw error;\n }\n // The channel may have died between `ensure` and `use`. Drop it and try\n // once more; a second failure is a real failure.\n live = undefined;\n return use((await ensure()).channel);\n }\n },\n addConsumer: async setup => {\n consumers.push({ setup, tags: [] });\n // `ensure` may have bound it already, if it opened a connection for this\n // very call — `bindAll` is what makes running it twice impossible.\n await bindAll((await ensure()).channel);\n },\n cancelConsumers: async () => {\n cancelled = true;\n const channel = live?.channel;\n if (channel === undefined) {\n return;\n }\n for (const consumer of consumers) {\n for (const tag of consumer.tags) {\n // A cancel that fails has already achieved what it was for — the\n // channel is gone, so nothing is being delivered on it — and a\n // shutdown must not stall on the way out.\n await channel.cancel(tag).catch(() => undefined);\n }\n consumer.tags = [];\n }\n },\n };\n\n const close = async () => {\n closed = true;\n const current = live;\n live = undefined;\n await current?.connection.close().catch(() => undefined);\n };\n\n return { connector, close };\n};\n\n/**\n * A scoped {@link Layer} that provides a self-healing RabbitMQ connection and\n * closes it on release. The exchange is asserted on every (re)connect, so\n * publishers and subscribers can always assume it exists.\n *\n * Connecting is **eager**, so a broker that is unreachable at boot still fails\n * the service's startup exactly as it did before — a service silently starting\n * without its bus would be a worse trade than the reconnection buys.\n */\nexport const AmqpLayer = (\n settings: AmqpSettings,\n): Layer.Layer<AmqpChannelTag, EntifixConnError> =>\n Layer.effect(\n AmqpChannelTag,\n Effect.acquireRelease(\n Effect.tryPromise({\n try: async () => {\n const built = makeAmqpConnector(settings.uri);\n await built.connector.withChannel(async () => undefined);\n return built;\n },\n catch: error =>\n new EntifixConnError('Failed to connect to RabbitMQ', error),\n }),\n built => Effect.promise(() => built.close()),\n ).pipe(Effect.map(built => built.connector)),\n );\n"],"names":["EntifixConnError","amqp","Context","Effect","Layer","EVENTS_EXCHANGE","EVENTS_DLX","AmqpChannelTag","Service","makeAmqpConnector","uri","live","opening","closed","cancelled","consumers","bindAll","channel","consumer","boundTo","tags","setup","open","connection","connect","createChannel","assertExchange","durable","drop","undefined","on","ensure","Error","finally","connector","withChannel","use","error","addConsumer","push","cancelConsumers","tag","cancel","catch","close","current","AmqpLayer","settings","effect","acquireRelease","tryPromise","try","built","promise","pipe","map"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/amqp-connection/amqp-connection.ts"],"sourcesContent":["import { Unavailable } from '@entifix/core';\nimport * as amqp from 'amqplib';\nimport { Context, Effect, Layer } from 'effect';\n\n/**\n * The topic exchange every domain event is published to, keyed on the event's\n * own name (`transaction.completed`, `catalog.published`).\n *\n * Topic rather than fanout, and a **new name** rather than a redeclaration: a\n * broker will not change an existing exchange's type, so the old\n * `entifix.transactions` fanout had to be left behind rather than migrated. It\n * lingers, unbound and unused, in any dev broker that predates this — a\n * `dev:reset` is what clears it.\n *\n * Fanout meant every subscriber received every publisher's traffic and threw\n * away what it did not want, in its own handler. That put the routing rule in\n * the one place no register can check it, and it does not survive a second\n * publisher: a projection consuming its own event would have been folding\n * transaction events to find it.\n */\nexport const EVENTS_EXCHANGE = 'entifix.events';\n\n/**\n * Where a message goes when its queue gives up on it.\n *\n * **`direct`**, not topic: it routes on the *queue's* name rather than on the\n * event's, because a quarantine is per queue. One shared quarantine would mean\n * a replay redelivers another subscriber's messages — a recovery step that\n * causes a second incident.\n *\n * Declared beside the event exchange rather than inside `subscribe`, so it is\n * restored on every reconnect and a publisher-only service still agrees with\n * the fleet about the topology it is part of.\n */\nexport const EVENTS_DLX = 'entifix.events.dlx';\n\n/**\n * Re-establishes a consumer against a freshly opened channel.\n *\n * It returns the consumer tags it created, which is what makes cancelling\n * possible: `channel.consume` hands its tag back to the caller and nowhere\n * else, so a setup that swallowed it would leave the connector able to open\n * consumers and never to stop them. `void` is accepted for a setup that starts\n * none (a declaration-only one).\n */\nexport type AmqpConsumerSetup = (\n channel: amqp.Channel,\n) => Promise<void | readonly string[]>;\n\n/**\n * A RabbitMQ connection that survives the broker going away.\n *\n * This is not a convenience. `amqplib` does not reconnect: a connection opened\n * at boot and held in a `Layer` is dead permanently once the broker restarts,\n * and both halves of the bus die with it — publishes fail forever, and the saga\n * tracker's consumer stops folding events without ever erroring again. The\n * transactional outbox makes an event durable, but durability is worthless if\n * nothing can ever carry it, so the transport has to heal itself.\n */\nexport interface AmqpConnector {\n /**\n * Run `use` against a live channel, opening or reopening one as needed. Retries\n * once on a broken channel so a publish that races a broker restart succeeds\n * rather than surfacing an error the caller cannot act on.\n */\n withChannel<A>(use: (channel: amqp.Channel) => Promise<A>): Promise<A>;\n /**\n * Register a consumer, and re-register it after every reconnect. The setup is\n * re-run against the new channel, which is what makes a subscriber survive an\n * outage — its exclusive queue died with the old connection.\n */\n addConsumer(setup: AmqpConsumerSetup): Promise<void>;\n /**\n * Stop delivering. Cancels every registered consumer and latches the\n * connector so a reconnect does not quietly re-arm them.\n *\n * The first phase of a graceful shutdown: the messages already\n * being handled are still being handled — cancelling only stops *new*\n * deliveries — so the caller waits for those separately before the connection\n * closes. Without this a rollout kills handlers mid-flight and the broker\n * redelivers whatever was unacked.\n */\n cancelConsumers(): Promise<void>;\n}\n\n/** DI tag carrying the self-healing connection (closed on release). */\nexport class AmqpChannelTag extends Context.Service<\n AmqpChannelTag,\n AmqpConnector\n>()('AmqpChannelTag') {}\n\nexport interface AmqpSettings {\n readonly uri: string;\n}\n\ninterface Live {\n connection: amqp.ChannelModel;\n channel: amqp.Channel;\n}\n\n/**\n * A registered consumer and the channel it is currently bound to.\n *\n * Tracking the channel is what makes binding idempotent. Without it, a consumer\n * registered while the first connection is opening is run twice — once by the\n * open itself and once by `addConsumer` — and a doubly-bound subscriber folds\n * every event twice.\n */\ninterface Consumer {\n setup: AmqpConsumerSetup;\n boundTo?: amqp.Channel;\n /** The tags this consumer holds on {@link Consumer.boundTo}, for cancelling. */\n tags: readonly string[];\n}\n\n/**\n * Builds the connector. Exported for the specs; services take it from the layer.\n */\nexport const makeAmqpConnector = (uri: string) => {\n let live: Live | undefined;\n let opening: Promise<Live> | undefined;\n let closed = false;\n let cancelled = false;\n const consumers: Consumer[] = [];\n\n /**\n * Bind every consumer that is not already bound to this channel.\n *\n * A no-op once the consumers have been cancelled: a shutdown that races a\n * broker reconnect would otherwise re-arm the very consumers the drain just\n * stopped, and start taking deliveries the process has no time left to\n * finish.\n */\n const bindAll = async (channel: amqp.Channel) => {\n if (cancelled) {\n return;\n }\n for (const consumer of consumers) {\n if (consumer.boundTo !== channel) {\n consumer.tags = (await consumer.setup(channel)) ?? [];\n consumer.boundTo = channel;\n }\n }\n };\n\n const open = async (): Promise<Live> => {\n const connection = await amqp.connect(uri);\n const channel = await connection.createChannel();\n await channel.assertExchange(EVENTS_EXCHANGE, 'topic', {\n durable: true,\n });\n await channel.assertExchange(EVENTS_DLX, 'direct', { durable: true });\n\n // Forget this connection the moment it breaks, so the next call reopens\n // instead of writing into a dead socket. The handlers are also what keep an\n // 'error' event from reaching Node's default handler and killing the\n // process — an unhandled 'error' on an amqplib connection is fatal.\n const drop = () => {\n if (live?.connection === connection) {\n live = undefined;\n }\n };\n connection.on('close', drop);\n connection.on('error', drop);\n channel.on('close', drop);\n channel.on('error', drop);\n\n live = { connection, channel };\n\n // Re-arm every subscriber against the new channel. Nothing else does this:\n // the queues they were consuming from went away with the old connection.\n await bindAll(channel);\n return live;\n };\n\n const ensure = async (): Promise<Live> => {\n if (closed) {\n throw new Error('AMQP connector is closed');\n }\n if (live) {\n return live;\n }\n // One open at a time: a burst of publishes after an outage would otherwise\n // race and leave orphaned connections behind, each with its own consumers.\n opening ??= open().finally(() => {\n opening = undefined;\n });\n return opening;\n };\n\n const connector: AmqpConnector = {\n withChannel: async use => {\n try {\n return await use((await ensure()).channel);\n } catch (error) {\n if (closed) {\n throw error;\n }\n // The channel may have died between `ensure` and `use`. Drop it and try\n // once more; a second failure is a real failure.\n live = undefined;\n return use((await ensure()).channel);\n }\n },\n addConsumer: async setup => {\n consumers.push({ setup, tags: [] });\n // `ensure` may have bound it already, if it opened a connection for this\n // very call — `bindAll` is what makes running it twice impossible.\n await bindAll((await ensure()).channel);\n },\n cancelConsumers: async () => {\n cancelled = true;\n const channel = live?.channel;\n if (channel === undefined) {\n return;\n }\n for (const consumer of consumers) {\n for (const tag of consumer.tags) {\n // A cancel that fails has already achieved what it was for — the\n // channel is gone, so nothing is being delivered on it — and a\n // shutdown must not stall on the way out.\n await channel.cancel(tag).catch(() => undefined);\n }\n consumer.tags = [];\n }\n },\n };\n\n const close = async () => {\n closed = true;\n const current = live;\n live = undefined;\n await current?.connection.close().catch(() => undefined);\n };\n\n return { connector, close };\n};\n\n/**\n * A scoped {@link Layer} that provides a self-healing RabbitMQ connection and\n * closes it on release. The exchange is asserted on every (re)connect, so\n * publishers and subscribers can always assume it exists.\n *\n * Connecting is **eager**, so a broker that is unreachable at boot still fails\n * the service's startup exactly as it did before — a service silently starting\n * without its bus would be a worse trade than the reconnection buys.\n */\nexport const AmqpLayer = (\n settings: AmqpSettings,\n): Layer.Layer<AmqpChannelTag, Unavailable> =>\n Layer.effect(\n AmqpChannelTag,\n Effect.acquireRelease(\n Effect.tryPromise({\n try: async () => {\n const built = makeAmqpConnector(settings.uri);\n await built.connector.withChannel(async () => undefined);\n return built;\n },\n catch: error => new Unavailable({ dependency: 'amqp' }),\n }),\n built => Effect.promise(() => built.close()),\n ).pipe(Effect.map(built => built.connector)),\n );\n"],"names":["Unavailable","amqp","Context","Effect","Layer","EVENTS_EXCHANGE","EVENTS_DLX","AmqpChannelTag","Service","makeAmqpConnector","uri","live","opening","closed","cancelled","consumers","bindAll","channel","consumer","boundTo","tags","setup","open","connection","connect","createChannel","assertExchange","durable","drop","undefined","on","ensure","Error","finally","connector","withChannel","use","error","addConsumer","push","cancelConsumers","tag","cancel","catch","close","current","AmqpLayer","settings","effect","acquireRelease","tryPromise","try","built","dependency","promise","pipe","map"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entifix/amqp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "RabbitMQ connection and health probe, and an optional event bus for @entifix/transactions with retry and quarantine.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -57,18 +57,18 @@
|
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@swc/helpers": "0.5.23",
|
|
59
59
|
"amqplib": "2.2.0",
|
|
60
|
-
"@entifix/business": "0.
|
|
61
|
-
"@entifix/core": "0.
|
|
60
|
+
"@entifix/business": "0.10.0",
|
|
61
|
+
"@entifix/core": "0.10.0"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@types/amqplib": "0.10.8",
|
|
65
|
-
"effect": "4.0.
|
|
66
|
-
"@entifix/
|
|
67
|
-
"@entifix/
|
|
65
|
+
"effect": "4.0.6",
|
|
66
|
+
"@entifix/transactions": "0.10.0",
|
|
67
|
+
"@entifix/testing-unit": "0.10.0"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"effect": "^4.0.
|
|
71
|
-
"@entifix/transactions": "0.
|
|
70
|
+
"effect": "^4.0.6",
|
|
71
|
+
"@entifix/transactions": "0.10.0"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|
|
74
74
|
"@entifix/transactions": {
|