@entifix/amqp 0.11.0 → 0.12.1
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 +7 -1
- package/dist/adapters/amqp-event-bus.d.ts.map +1 -1
- package/dist/adapters/amqp-event-bus.js +78 -7
- package/dist/adapters/amqp-event-bus.js.map +1 -1
- package/dist/adapters/bus-metrics.d.ts +8 -1
- package/dist/adapters/bus-metrics.d.ts.map +1 -1
- package/dist/adapters/bus-metrics.js +10 -0
- package/dist/adapters/bus-metrics.js.map +1 -1
- package/dist/amqp-connection/amqp-connection.d.ts +11 -1
- package/dist/amqp-connection/amqp-connection.d.ts.map +1 -1
- package/dist/amqp-connection/amqp-connection.js +6 -1
- package/dist/amqp-connection/amqp-connection.js.map +1 -1
- package/package.json +6 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { WiringRegistry } from '@entifix/business';
|
|
2
2
|
import { ShutdownRegistryTag, WiringRegistryTag } from '@entifix/business';
|
|
3
|
-
import { type EventBus, EventBusTag, type Subscription } from '@entifix/transactions';
|
|
3
|
+
import { type CircuitBreaker, type EventBus, EventBusTag, type Subscription } from '@entifix/transactions';
|
|
4
4
|
import { Context, Duration, Effect, Layer } from 'effect';
|
|
5
5
|
import { AmqpChannelTag, type AmqpConnector } from '../amqp-connection/amqp-connection.js';
|
|
6
6
|
/**
|
|
@@ -90,10 +90,16 @@ export declare class RedeliveryBackoffTag extends RedeliveryBackoffTag_base {
|
|
|
90
90
|
*/
|
|
91
91
|
export declare const makeAmqpEventBus: (connector: AmqpConnector, wiring: WiringRegistry, options?: {
|
|
92
92
|
readonly redeliveryBackoff?: RedeliveryBackoff;
|
|
93
|
+
/** What `pauseWhileOpen` reads; without one no subscription pauses. */
|
|
94
|
+
readonly breaker?: CircuitBreaker;
|
|
95
|
+
/** How often a pausable consumer reads its breakers. */
|
|
96
|
+
readonly pausePoll?: Duration.Duration;
|
|
93
97
|
}) => {
|
|
94
98
|
bus: EventBus;
|
|
95
99
|
drainDeliveries: Effect.Effect<void>;
|
|
96
100
|
};
|
|
101
|
+
/** A pausable consumer reads its breakers once a second. */
|
|
102
|
+
export declare const DEFAULT_PAUSE_POLL: Duration.Duration;
|
|
97
103
|
/**
|
|
98
104
|
* Provides {@link EventBusTag} from an {@link AmqpChannelTag}.
|
|
99
105
|
*
|
|
@@ -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,EAClB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAU,MAAM,QAAQ,CAAC;AAElE,OAAO,EACL,cAAc,EACd,KAAK,aAAa,
|
|
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,cAAc,EAEnB,KAAK,QAAQ,EACb,WAAW,EACX,KAAK,YAAY,EAClB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAU,MAAM,QAAQ,CAAC;AAElE,OAAO,EACL,cAAc,EACd,KAAK,aAAa,EAInB,MAAM,uCAAuC,CAAC;AAgF/C;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,YAAY,GAAI,cAAc,YAAY,KAAG,MAG1B,CAAC;AAsDjC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,eAAe,uBAAuB,CAAC;AASpD,iFAAiF;AACjF,MAAM,MAAM,iBAAiB,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,QAAQ,CAAC,QAAQ,CAAC;AAEvE;;;;;GAKG;AACH,eAAO,MAAM,4BAA4B,GACtC,OAAO;IACN,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB,KAAG,iBAEuE,CAAC;AAE9E,mDAAmD;AACnD,eAAO,MAAM,0BAA0B,EAAE,iBACoB,CAAC;;AAE9D;;;;;;;;;GASG;AACH,qBAAa,oBAAqB,SAAQ,yBAGf;CAAG;AA6G9B;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,gBAAgB,GAC3B,WAAW,aAAa,EACxB,QAAQ,cAAc,EACtB,UAAS;IACP,QAAQ,CAAC,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;IAC/C,uEAAuE;IACvE,QAAQ,CAAC,OAAO,CAAC,EAAE,cAAc,CAAC;IAClC,wDAAwD;IACxD,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC;CACnC,KACL;IAAE,GAAG,EAAE,QAAQ,CAAC;IAAC,eAAe,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;CAgLvD,CAAC;AAEF,4DAA4D;AAC5D,eAAO,MAAM,kBAAkB,mBAAsB,CAAC;AA0DtD;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,2FA4B7B,CAAC"}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
2
|
import { ShutdownRegistryTag, WiringRegistryTag } from '@entifix/business';
|
|
3
3
|
import { decodeEvent, encodeEvent, isFailure, Unavailable } from '@entifix/core';
|
|
4
|
-
import { EventBusTag } from '@entifix/transactions';
|
|
4
|
+
import { CircuitBreakerTag, EventBusTag } from '@entifix/transactions';
|
|
5
5
|
import { Context, Duration, Effect, Layer, Option } from 'effect';
|
|
6
6
|
import { AmqpChannelTag, EVENTS_DLX, EVENTS_EXCHANGE } from '../amqp-connection/amqp-connection.js';
|
|
7
|
-
import { recordConsumed, recordFailed, recordPublished, recordPublishFailure, recordQuarantined } from './bus-metrics.js';
|
|
7
|
+
import { recordConsumed, recordFailed, recordPaused, recordPublished, recordPublishFailure, recordQuarantined } from './bus-metrics.js';
|
|
8
8
|
/** Appended to a work queue's name to make the queue that holds its failures. */ const QUARANTINE_SUFFIX = '.quarantine';
|
|
9
9
|
/**
|
|
10
10
|
* How often the shutdown drain re-checks whether the handlers have finished.
|
|
@@ -24,7 +24,7 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure, re
|
|
|
24
24
|
* uncategorised: not knowing what went wrong is not evidence that retrying is
|
|
25
25
|
* pointless.
|
|
26
26
|
*/ const classify = (error)=>isFailure(error) ? {
|
|
27
|
-
kind: error.family === 'business' || error.category === 'protocol' ? 'poison' : 'transient',
|
|
27
|
+
kind: error._tag === 'BreakerOpen' ? 'paused' : error.family === 'business' || error.category === 'protocol' ? 'poison' : 'transient',
|
|
28
28
|
code: error.code,
|
|
29
29
|
category: error.category
|
|
30
30
|
} : {
|
|
@@ -160,6 +160,7 @@ const attemptsSoFar = (message)=>{
|
|
|
160
160
|
if (subscription.mode === 'broadcast') {
|
|
161
161
|
return Effect.sync(()=>channel.nack(message, false, false));
|
|
162
162
|
}
|
|
163
|
+
if (failure.kind === 'paused') return requeueUncounted(channel, message, queue);
|
|
163
164
|
const attempts = failure.kind === 'transient' ? attemptsSoFar(message) + 1 : 0;
|
|
164
165
|
if (failure.kind === 'poison' || attempts >= subscription.maxAttempts) {
|
|
165
166
|
return Effect.sync(()=>quarantine(channel, message, queue, failure, attempts)).pipe(Effect.andThen(recordQuarantined(tags, failure.category)));
|
|
@@ -180,6 +181,20 @@ const attemptsSoFar = (message)=>{
|
|
|
180
181
|
}
|
|
181
182
|
})));
|
|
182
183
|
};
|
|
184
|
+
/**
|
|
185
|
+
* The message back on its queue with the attempts it already had: the broker's
|
|
186
|
+
* own requeue would count against `x-delivery-limit`, so it is republished and
|
|
187
|
+
* acked instead, the way a counted retry is.
|
|
188
|
+
*/ const requeueUncounted = (channel, message, queue)=>Effect.sync(()=>{
|
|
189
|
+
try {
|
|
190
|
+
channel.publish('', queue, message.content, _extends({}, message.properties, {
|
|
191
|
+
persistent: true
|
|
192
|
+
}));
|
|
193
|
+
channel.ack(message);
|
|
194
|
+
} catch (unused) {
|
|
195
|
+
channel.nack(message, false, true);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
183
198
|
const quarantine = (channel, message, queue, failure, attempts)=>{
|
|
184
199
|
try {
|
|
185
200
|
var _message_properties;
|
|
@@ -225,8 +240,9 @@ const quarantine = (channel, message, queue, failure, attempts)=>{
|
|
|
225
240
|
* runs them — and putting it on `EventBus` would make every implementation of
|
|
226
241
|
* a framework-free port carry a shutdown concern it has no deliveries to drain.
|
|
227
242
|
*/ export const makeAmqpEventBus = (connector, wiring, options = {})=>{
|
|
228
|
-
var _options_redeliveryBackoff;
|
|
243
|
+
var _options_redeliveryBackoff, _options_pausePoll;
|
|
229
244
|
const backoff = (_options_redeliveryBackoff = options.redeliveryBackoff) != null ? _options_redeliveryBackoff : DEFAULT_REDELIVERY_BACKOFF;
|
|
245
|
+
const pausePoll = Duration.toMillis((_options_pausePoll = options.pausePoll) != null ? _options_pausePoll : DEFAULT_PAUSE_POLL);
|
|
230
246
|
/**
|
|
231
247
|
* Handlers started and not yet settled.
|
|
232
248
|
*
|
|
@@ -258,7 +274,8 @@ const quarantine = (channel, message, queue, failure, attempts)=>{
|
|
|
258
274
|
// the outbox's own backlog is the confirmation.
|
|
259
275
|
Effect.tapError(()=>recordPublishFailure(event.name))),
|
|
260
276
|
subscribe: (subscription, handler)=>Effect.tryPromise({
|
|
261
|
-
try: ()=>connector.addConsumer(async (channel)=>{
|
|
277
|
+
try: ()=>connector.addConsumer(async (channel, control)=>{
|
|
278
|
+
var _subscription_pauseWhileOpen;
|
|
262
279
|
// One unacked message at a time: events for a transaction are then
|
|
263
280
|
// folded serially (never concurrently), so an `accepted`/`completed`
|
|
264
281
|
// pair can't race into two upserts.
|
|
@@ -282,7 +299,7 @@ const quarantine = (channel, message, queue, failure, attempts)=>{
|
|
|
282
299
|
slice: subscription.slice,
|
|
283
300
|
mode: subscription.mode
|
|
284
301
|
};
|
|
285
|
-
const
|
|
302
|
+
const onMessage = (message)=>{
|
|
286
303
|
if (message === null) {
|
|
287
304
|
return;
|
|
288
305
|
}
|
|
@@ -319,6 +336,19 @@ const quarantine = (channel, message, queue, failure, attempts)=>{
|
|
|
319
336
|
}))).finally(()=>{
|
|
320
337
|
inFlight -= 1;
|
|
321
338
|
});
|
|
339
|
+
};
|
|
340
|
+
const { consumerTag } = await channel.consume(queue, onMessage);
|
|
341
|
+
const keys = (_subscription_pauseWhileOpen = subscription.pauseWhileOpen) != null ? _subscription_pauseWhileOpen : [];
|
|
342
|
+
if (keys.length > 0 && options.breaker !== undefined) watchBreakers({
|
|
343
|
+
channel,
|
|
344
|
+
control,
|
|
345
|
+
queue,
|
|
346
|
+
onMessage,
|
|
347
|
+
consumerTag,
|
|
348
|
+
keys,
|
|
349
|
+
breaker: options.breaker,
|
|
350
|
+
tags,
|
|
351
|
+
pollMs: pausePoll
|
|
322
352
|
});
|
|
323
353
|
return [
|
|
324
354
|
consumerTag
|
|
@@ -338,6 +368,45 @@ const quarantine = (channel, message, queue, failure, attempts)=>{
|
|
|
338
368
|
})
|
|
339
369
|
};
|
|
340
370
|
};
|
|
371
|
+
/** A pausable consumer reads its breakers once a second. */ export const DEFAULT_PAUSE_POLL = Duration.seconds(1);
|
|
372
|
+
/**
|
|
373
|
+
* Stops consuming while any breaker the subscription waits on is open, and
|
|
374
|
+
* consumes again once none is. Polled rather than pushed, because an open
|
|
375
|
+
* breaker becomes half-open by its wait elapsing, and only reading its state
|
|
376
|
+
* notices that. The watch ends with the channel, and does not resume into a
|
|
377
|
+
* shutdown that stopped intake.
|
|
378
|
+
*/ const watchBreakers = (watched)=>{
|
|
379
|
+
let tag = watched.consumerTag;
|
|
380
|
+
const tick = Effect.gen(function*() {
|
|
381
|
+
if (watched.control.cancelled()) return;
|
|
382
|
+
const states = yield* Effect.forEach(watched.keys, (key)=>watched.breaker.state(key));
|
|
383
|
+
const open = states.some((state)=>state === 'open' || state === 'forcedOpen');
|
|
384
|
+
if (open && tag !== undefined) {
|
|
385
|
+
const paused = tag;
|
|
386
|
+
tag = undefined;
|
|
387
|
+
watched.control.replaceTags([]);
|
|
388
|
+
yield* Effect.tryPromise(()=>watched.channel.cancel(paused));
|
|
389
|
+
yield* recordPaused(watched.tags, true);
|
|
390
|
+
} else if (!open && tag === undefined) {
|
|
391
|
+
const { consumerTag } = yield* Effect.tryPromise(()=>watched.channel.consume(watched.queue, watched.onMessage));
|
|
392
|
+
tag = consumerTag;
|
|
393
|
+
watched.control.replaceTags([
|
|
394
|
+
consumerTag
|
|
395
|
+
]);
|
|
396
|
+
yield* recordPaused(watched.tags, false);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
// A channel that dies mid-tick is the connector's to replace; the watch on
|
|
400
|
+
// the next channel starts with its setup. Its calls are `tryPromise`, so the
|
|
401
|
+
// rejection is a failure `ignore` drops — as `Effect.promise` it would be a
|
|
402
|
+
// defect, `ignore` would pass it on, and the detached `runPromise` below
|
|
403
|
+
// would reject with nothing to catch it: an unhandled rejection, which ends
|
|
404
|
+
// a Node process.
|
|
405
|
+
const timer = setInterval(()=>{
|
|
406
|
+
void Effect.runPromise(Effect.ignore(tick));
|
|
407
|
+
}, watched.pollMs);
|
|
408
|
+
watched.channel.once('close', ()=>clearInterval(timer));
|
|
409
|
+
};
|
|
341
410
|
/**
|
|
342
411
|
* Provides {@link EventBusTag} from an {@link AmqpChannelTag}.
|
|
343
412
|
*
|
|
@@ -349,8 +418,10 @@ const quarantine = (channel, message, queue, failure, attempts)=>{
|
|
|
349
418
|
const wiring = yield* WiringRegistryTag;
|
|
350
419
|
const shutdown = yield* ShutdownRegistryTag;
|
|
351
420
|
const redeliveryBackoff = yield* Effect.serviceOption(RedeliveryBackoffTag);
|
|
421
|
+
const breaker = yield* Effect.serviceOption(CircuitBreakerTag);
|
|
352
422
|
const { bus, drainDeliveries } = makeAmqpEventBus(connector, wiring, {
|
|
353
|
-
redeliveryBackoff: Option.getOrUndefined(redeliveryBackoff)
|
|
423
|
+
redeliveryBackoff: Option.getOrUndefined(redeliveryBackoff),
|
|
424
|
+
breaker: Option.getOrUndefined(breaker)
|
|
354
425
|
});
|
|
355
426
|
// `stop-intake`, and both halves are it: cancelling stops the broker
|
|
356
427
|
// handing over anything new, then the drain waits for the deliveries
|
|
@@ -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 decodeEvent,\n encodeEvent,\n isFailure,\n Unavailable,\n} from '@entifix/core';\nimport {\n type EventBus,\n EventBusTag,\n type Subscription,\n} from '@entifix/transactions';\nimport type { Channel, ConsumeMessage } from 'amqplib';\nimport { Context, Duration, Effect, Layer, Option } 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 recordQuarantined,\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 the\n * subscription's `maxAttempts` as the adapter counts them, and so is anything\n * uncategorised: not knowing what went wrong is not evidence that retrying is\n * 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 the subscription's\n * `maxAttempts`, counted by the adapter in {@link ATTEMPTS_HEADER} because the\n * broker does not count a requeued nack (#130). The same number is the queue's\n * `x-delivery-limit`, which bounds what the broker *does* count — a redelivery\n * after a consumer 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 * The adapter's own count of the attempts a message has failed, carried on the\n * copy it republishes (#130).\n *\n * The broker cannot be trusted with it. RabbitMQ 4 does not count a requeued\n * `basic.nack` toward a quorum queue's `x-delivery-limit` — measured on 4.3.5:\n * a queue limited to 2 delivered one message 8 times and never set\n * `x-delivery-count` — so a transient failure that never cleared was\n * redelivered without a ceiling and without a pause. `x-delivery-limit` stays\n * on the queue, where it still bounds the redeliveries a crashed consumer\n * causes, which the broker does count.\n */\nexport const ATTEMPTS_HEADER = 'x-entifix-attempts';\n\nconst attemptsSoFar = (message: ConsumeMessage): number => {\n const value: unknown = message.properties?.headers?.[ATTEMPTS_HEADER];\n return typeof value === 'number' && Number.isInteger(value) && value > 0\n ? value\n : 0;\n};\n\n/** How long a transient failure waits before its copy goes back on the queue. */\nexport type RedeliveryBackoff = (attempt: number) => Duration.Duration;\n\n/**\n * A backoff doubling from `baseMs`, capped at `capMs`. The wait holds the\n * delivery — the queue is `prefetch(1)`, so it pauses this consumer — which is\n * the point: a dependency that is down is not helped by asking it again at\n * once.\n */\nexport const exponentialRedeliveryBackoff =\n (dials: {\n readonly baseMs: number;\n readonly capMs: number;\n }): RedeliveryBackoff =>\n attempt =>\n Duration.millis(Math.min(dials.baseMs * 2 ** (attempt - 1), dials.capMs));\n\n/** Doubling from 100 ms, capped at two seconds. */\nexport const DEFAULT_REDELIVERY_BACKOFF: RedeliveryBackoff =\n exponentialRedeliveryBackoff({ baseMs: 100, capMs: 2_000 });\n\n/**\n * The redelivery backoff {@link AmqpEventBusLayer} uses, when a composition\n * provides one; {@link DEFAULT_REDELIVERY_BACKOFF} otherwise.\n *\n * A tag rather than a layer option because the dials it is sized from are\n * runtime configuration a host reads at boot, after the layer value already\n * exists. Read with `Effect.serviceOption`, so a host that never sizes it\n * provides nothing — which also means nothing forces one that meant to, and\n * the spec pins both arms.\n */\nexport class RedeliveryBackoffTag extends Context.Service<\n RedeliveryBackoffTag,\n RedeliveryBackoff\n>()('RedeliveryBackoffTag') {}\n\n/**\n * Settles a delivery that failed.\n *\n * Poison on a `work` queue — and a transient failure that has spent the\n * subscription's `maxAttempts` — is republished to the dead-letter exchange\n * **with its reason**: `x-failure-code`, `x-failure-category` and the attempts\n * spent. It is then acked rather than nacked, because a nack cannot carry\n * headers, and a quarantine of messages nobody can say anything about is a\n * queue an operator has to replay blind. If that publish is refused the nack\n * still quarantines it, reasonless.\n *\n * A transient failure under the ceiling waits its backoff, goes back on its own\n * queue as a copy carrying the attempt count, and the original is acked. If\n * that publish is refused it is nacked for the broker to redeliver, which is\n * the old behaviour and still better than losing it. A `broadcast` queue has no\n * quarantine and no ceiling, so its failures are dropped.\n */\nconst settleFailure = (\n channel: Channel,\n message: ConsumeMessage,\n queue: string,\n subscription: Subscription,\n failure: DeliveryFailure,\n backoff: RedeliveryBackoff,\n tags: SubscriptionTags,\n): Effect.Effect<void> => {\n if (subscription.mode === 'broadcast') {\n return Effect.sync(() => channel.nack(message, false, false));\n }\n const attempts =\n failure.kind === 'transient' ? attemptsSoFar(message) + 1 : 0;\n if (failure.kind === 'poison' || attempts >= subscription.maxAttempts) {\n return Effect.sync(() =>\n quarantine(channel, message, queue, failure, attempts),\n ).pipe(Effect.andThen(recordQuarantined(tags, failure.category)));\n }\n const wait = backoff(attempts);\n return (Duration.isZero(wait) ? Effect.void : Effect.sleep(wait)).pipe(\n Effect.andThen(\n Effect.sync(() => {\n try {\n channel.publish('', queue, message.content, {\n ...message.properties,\n persistent: true,\n headers: {\n ...message.properties?.headers,\n [ATTEMPTS_HEADER]: attempts,\n },\n });\n channel.ack(message);\n } catch {\n channel.nack(message, false, true);\n }\n }),\n ),\n );\n};\n\nconst quarantine = (\n channel: Channel,\n message: ConsumeMessage,\n queue: string,\n failure: DeliveryFailure,\n attempts: number,\n): void => {\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 ...(attempts > 0 ? { [ATTEMPTS_HEADER]: attempts } : {}),\n },\n });\n channel.ack(message);\n } catch {\n // Quarantined without its reason beats not quarantined.\n channel.nack(message, false, false);\n }\n};\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 options: { readonly redeliveryBackoff?: RedeliveryBackoff } = {},\n): { bus: EventBus; drainDeliveries: Effect.Effect<void> } => {\n const backoff = options.redeliveryBackoff ?? DEFAULT_REDELIVERY_BACKOFF;\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 // Encoded before the channel is touched: a message its registration\n // refuses — no schema, no audience, no version — fails the publish as a\n // protocol failure instead of reaching a consumer that would quarantine it.\n publish: event =>\n Effect.flatMap(encodeEvent(event), envelope =>\n Effect.tryPromise({\n try: () =>\n connector.withChannel(async channel => {\n channel.publish(\n EVENTS_EXCHANGE,\n event.name,\n Buffer.from(JSON.stringify(envelope)),\n { persistent: true },\n );\n }),\n catch: () => new Unavailable({ dependency: 'amqp' }),\n }),\n ).pipe(\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 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 decodeEvent(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 // ⚠️ `data` is decoded against the schema its event name\n // registered, so a malformed payload is a `protocol` failure\n // here — poison, quarantined at once — before any handler\n // runs. A handler's own failure is classified by its category\n // the same way: a business or `protocol` failure quarantines\n // instead of spending `maxAttempts` on every message behind it.\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 settleFailure(\n channel,\n message,\n queue,\n subscription,\n failure,\n backoff,\n tags,\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 redeliveryBackoff = yield* Effect.serviceOption(RedeliveryBackoffTag);\n const { bus, drainDeliveries } = makeAmqpEventBus(connector, wiring, {\n redeliveryBackoff: Option.getOrUndefined(redeliveryBackoff),\n });\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","decodeEvent","encodeEvent","isFailure","Unavailable","EventBusTag","Context","Duration","Effect","Layer","Option","AmqpChannelTag","EVENTS_DLX","EVENTS_EXCHANGE","recordConsumed","recordFailed","recordPublished","recordPublishFailure","recordQuarantined","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","ATTEMPTS_HEADER","attemptsSoFar","message","value","properties","headers","Number","isInteger","exponentialRedeliveryBackoff","dials","attempt","Math","min","baseMs","capMs","DEFAULT_REDELIVERY_BACKOFF","RedeliveryBackoffTag","Service","settleFailure","failure","backoff","tags","sync","nack","attempts","pipe","andThen","wait","isZero","void","sleep","publish","content","persistent","ack","makeAmqpEventBus","connector","wiring","options","redeliveryBackoff","inFlight","bus","event","flatMap","envelope","tryPromise","try","withChannel","name","Buffer","from","JSON","stringify","catch","dependency","tap","recordPublish","tapError","subscribe","handler","addConsumer","prefetch","runPromise","recordSubscription","consumerTag","consume","settle","parse","toString","parsed","mapError","matchEffect","onSuccess","onFailure","finally","drainDeliveries","gen","AmqpEventBusLayer","effect","shutdown","serviceOption","getOrUndefined","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 decodeEvent,\n encodeEvent,\n isFailure,\n Unavailable,\n} from '@entifix/core';\nimport {\n type CircuitBreaker,\n CircuitBreakerTag,\n type EventBus,\n EventBusTag,\n type Subscription,\n} from '@entifix/transactions';\nimport type { Channel, ConsumeMessage } from 'amqplib';\nimport { Context, Duration, Effect, Layer, Option } from 'effect';\n\nimport {\n AmqpChannelTag,\n type AmqpConnector,\n type AmqpConsumerControl,\n EVENTS_DLX,\n EVENTS_EXCHANGE,\n} from '../amqp-connection/amqp-connection.js';\nimport {\n recordConsumed,\n recordFailed,\n recordPaused,\n recordPublished,\n recordPublishFailure,\n recordQuarantined,\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 /**\n * `paused`: an open breaker refused the handler's call, so the message goes\n * back without counting an attempt — the dependency, not the message, is\n * what failed, and the consumer is about to stop taking deliveries.\n */\n readonly kind: 'transient' | 'poison' | 'paused';\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 the\n * subscription's `maxAttempts` as the adapter counts them, and so is anything\n * uncategorised: not knowing what went wrong is not evidence that retrying is\n * pointless.\n */\nconst classify = (error: unknown): DeliveryFailure =>\n isFailure(error)\n ? {\n kind:\n error._tag === 'BreakerOpen'\n ? 'paused'\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 the subscription's\n * `maxAttempts`, counted by the adapter in {@link ATTEMPTS_HEADER} because the\n * broker does not count a requeued nack (#130). The same number is the queue's\n * `x-delivery-limit`, which bounds what the broker *does* count — a redelivery\n * after a consumer 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 * The adapter's own count of the attempts a message has failed, carried on the\n * copy it republishes (#130).\n *\n * The broker cannot be trusted with it. RabbitMQ 4 does not count a requeued\n * `basic.nack` toward a quorum queue's `x-delivery-limit` — measured on 4.3.5:\n * a queue limited to 2 delivered one message 8 times and never set\n * `x-delivery-count` — so a transient failure that never cleared was\n * redelivered without a ceiling and without a pause. `x-delivery-limit` stays\n * on the queue, where it still bounds the redeliveries a crashed consumer\n * causes, which the broker does count.\n */\nexport const ATTEMPTS_HEADER = 'x-entifix-attempts';\n\nconst attemptsSoFar = (message: ConsumeMessage): number => {\n const value: unknown = message.properties?.headers?.[ATTEMPTS_HEADER];\n return typeof value === 'number' && Number.isInteger(value) && value > 0\n ? value\n : 0;\n};\n\n/** How long a transient failure waits before its copy goes back on the queue. */\nexport type RedeliveryBackoff = (attempt: number) => Duration.Duration;\n\n/**\n * A backoff doubling from `baseMs`, capped at `capMs`. The wait holds the\n * delivery — the queue is `prefetch(1)`, so it pauses this consumer — which is\n * the point: a dependency that is down is not helped by asking it again at\n * once.\n */\nexport const exponentialRedeliveryBackoff =\n (dials: {\n readonly baseMs: number;\n readonly capMs: number;\n }): RedeliveryBackoff =>\n attempt =>\n Duration.millis(Math.min(dials.baseMs * 2 ** (attempt - 1), dials.capMs));\n\n/** Doubling from 100 ms, capped at two seconds. */\nexport const DEFAULT_REDELIVERY_BACKOFF: RedeliveryBackoff =\n exponentialRedeliveryBackoff({ baseMs: 100, capMs: 2_000 });\n\n/**\n * The redelivery backoff {@link AmqpEventBusLayer} uses, when a composition\n * provides one; {@link DEFAULT_REDELIVERY_BACKOFF} otherwise.\n *\n * A tag rather than a layer option because the dials it is sized from are\n * runtime configuration a host reads at boot, after the layer value already\n * exists. Read with `Effect.serviceOption`, so a host that never sizes it\n * provides nothing — which also means nothing forces one that meant to, and\n * the spec pins both arms.\n */\nexport class RedeliveryBackoffTag extends Context.Service<\n RedeliveryBackoffTag,\n RedeliveryBackoff\n>()('RedeliveryBackoffTag') {}\n\n/**\n * Settles a delivery that failed.\n *\n * Poison on a `work` queue — and a transient failure that has spent the\n * subscription's `maxAttempts` — is republished to the dead-letter exchange\n * **with its reason**: `x-failure-code`, `x-failure-category` and the attempts\n * spent. It is then acked rather than nacked, because a nack cannot carry\n * headers, and a quarantine of messages nobody can say anything about is a\n * queue an operator has to replay blind. If that publish is refused the nack\n * still quarantines it, reasonless.\n *\n * A transient failure under the ceiling waits its backoff, goes back on its own\n * queue as a copy carrying the attempt count, and the original is acked. If\n * that publish is refused it is nacked for the broker to redeliver, which is\n * the old behaviour and still better than losing it. A `broadcast` queue has no\n * quarantine and no ceiling, so its failures are dropped.\n */\nconst settleFailure = (\n channel: Channel,\n message: ConsumeMessage,\n queue: string,\n subscription: Subscription,\n failure: DeliveryFailure,\n backoff: RedeliveryBackoff,\n tags: SubscriptionTags,\n): Effect.Effect<void> => {\n if (subscription.mode === 'broadcast') {\n return Effect.sync(() => channel.nack(message, false, false));\n }\n if (failure.kind === 'paused')\n return requeueUncounted(channel, message, queue);\n const attempts =\n failure.kind === 'transient' ? attemptsSoFar(message) + 1 : 0;\n if (failure.kind === 'poison' || attempts >= subscription.maxAttempts) {\n return Effect.sync(() =>\n quarantine(channel, message, queue, failure, attempts),\n ).pipe(Effect.andThen(recordQuarantined(tags, failure.category)));\n }\n const wait = backoff(attempts);\n return (Duration.isZero(wait) ? Effect.void : Effect.sleep(wait)).pipe(\n Effect.andThen(\n Effect.sync(() => {\n try {\n channel.publish('', queue, message.content, {\n ...message.properties,\n persistent: true,\n headers: {\n ...message.properties?.headers,\n [ATTEMPTS_HEADER]: attempts,\n },\n });\n channel.ack(message);\n } catch {\n channel.nack(message, false, true);\n }\n }),\n ),\n );\n};\n\n/**\n * The message back on its queue with the attempts it already had: the broker's\n * own requeue would count against `x-delivery-limit`, so it is republished and\n * acked instead, the way a counted retry is.\n */\nconst requeueUncounted = (\n channel: Channel,\n message: ConsumeMessage,\n queue: string,\n): Effect.Effect<void> =>\n Effect.sync(() => {\n try {\n channel.publish('', queue, message.content, {\n ...message.properties,\n persistent: true,\n });\n channel.ack(message);\n } catch {\n channel.nack(message, false, true);\n }\n });\n\nconst quarantine = (\n channel: Channel,\n message: ConsumeMessage,\n queue: string,\n failure: DeliveryFailure,\n attempts: number,\n): void => {\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 ...(attempts > 0 ? { [ATTEMPTS_HEADER]: attempts } : {}),\n },\n });\n channel.ack(message);\n } catch {\n // Quarantined without its reason beats not quarantined.\n channel.nack(message, false, false);\n }\n};\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 options: {\n readonly redeliveryBackoff?: RedeliveryBackoff;\n /** What `pauseWhileOpen` reads; without one no subscription pauses. */\n readonly breaker?: CircuitBreaker;\n /** How often a pausable consumer reads its breakers. */\n readonly pausePoll?: Duration.Duration;\n } = {},\n): { bus: EventBus; drainDeliveries: Effect.Effect<void> } => {\n const backoff = options.redeliveryBackoff ?? DEFAULT_REDELIVERY_BACKOFF;\n const pausePoll = Duration.toMillis(options.pausePoll ?? DEFAULT_PAUSE_POLL);\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 // Encoded before the channel is touched: a message its registration\n // refuses — no schema, no audience, no version — fails the publish as a\n // protocol failure instead of reaching a consumer that would quarantine it.\n publish: event =>\n Effect.flatMap(encodeEvent(event), envelope =>\n Effect.tryPromise({\n try: () =>\n connector.withChannel(async channel => {\n channel.publish(\n EVENTS_EXCHANGE,\n event.name,\n Buffer.from(JSON.stringify(envelope)),\n { persistent: true },\n );\n }),\n catch: () => new Unavailable({ dependency: 'amqp' }),\n }),\n ).pipe(\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 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, control) => {\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 onMessage = (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 decodeEvent(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 // ⚠️ `data` is decoded against the schema its event name\n // registered, so a malformed payload is a `protocol` failure\n // here — poison, quarantined at once — before any handler\n // runs. A handler's own failure is classified by its category\n // the same way: a business or `protocol` failure quarantines\n // instead of spending `maxAttempts` on every message behind it.\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(Effect.sync(() => channel.ack(message))),\n ),\n onFailure: failure =>\n recordFailed(tags, failure.kind).pipe(\n Effect.andThen(\n settleFailure(\n channel,\n message,\n queue,\n subscription,\n failure,\n backoff,\n tags,\n ),\n ),\n ),\n }),\n ),\n ).finally(() => {\n inFlight -= 1;\n });\n };\n const { consumerTag } = await channel.consume(queue, onMessage);\n const keys = subscription.pauseWhileOpen ?? [];\n if (keys.length > 0 && options.breaker !== undefined)\n watchBreakers({\n channel,\n control,\n queue,\n onMessage,\n consumerTag,\n keys,\n breaker: options.breaker,\n tags,\n pollMs: pausePoll,\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/** A pausable consumer reads its breakers once a second. */\nexport const DEFAULT_PAUSE_POLL = Duration.seconds(1);\n\ninterface Watched {\n readonly channel: Channel;\n readonly control: AmqpConsumerControl;\n readonly queue: string;\n readonly onMessage: (message: ConsumeMessage | null) => void;\n readonly consumerTag: string;\n readonly keys: readonly string[];\n readonly breaker: CircuitBreaker;\n readonly tags: SubscriptionTags;\n readonly pollMs: number;\n}\n\n/**\n * Stops consuming while any breaker the subscription waits on is open, and\n * consumes again once none is. Polled rather than pushed, because an open\n * breaker becomes half-open by its wait elapsing, and only reading its state\n * notices that. The watch ends with the channel, and does not resume into a\n * shutdown that stopped intake.\n */\nconst watchBreakers = (watched: Watched): void => {\n let tag: string | undefined = watched.consumerTag;\n const tick = Effect.gen(function* () {\n if (watched.control.cancelled()) return;\n const states = yield* Effect.forEach(watched.keys, key =>\n watched.breaker.state(key),\n );\n const open = states.some(\n state => state === 'open' || state === 'forcedOpen',\n );\n if (open && tag !== undefined) {\n const paused = tag;\n tag = undefined;\n watched.control.replaceTags([]);\n yield* Effect.tryPromise(() => watched.channel.cancel(paused));\n yield* recordPaused(watched.tags, true);\n } else if (!open && tag === undefined) {\n const { consumerTag } = yield* Effect.tryPromise(() =>\n watched.channel.consume(watched.queue, watched.onMessage),\n );\n tag = consumerTag;\n watched.control.replaceTags([consumerTag]);\n yield* recordPaused(watched.tags, false);\n }\n });\n // A channel that dies mid-tick is the connector's to replace; the watch on\n // the next channel starts with its setup. Its calls are `tryPromise`, so the\n // rejection is a failure `ignore` drops — as `Effect.promise` it would be a\n // defect, `ignore` would pass it on, and the detached `runPromise` below\n // would reject with nothing to catch it: an unhandled rejection, which ends\n // a Node process.\n const timer = setInterval(() => {\n void Effect.runPromise(Effect.ignore(tick));\n }, watched.pollMs);\n watched.channel.once('close', () => clearInterval(timer));\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 redeliveryBackoff = yield* Effect.serviceOption(RedeliveryBackoffTag);\n const breaker = yield* Effect.serviceOption(CircuitBreakerTag);\n const { bus, drainDeliveries } = makeAmqpEventBus(connector, wiring, {\n redeliveryBackoff: Option.getOrUndefined(redeliveryBackoff),\n breaker: Option.getOrUndefined(breaker),\n });\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","decodeEvent","encodeEvent","isFailure","Unavailable","CircuitBreakerTag","EventBusTag","Context","Duration","Effect","Layer","Option","AmqpChannelTag","EVENTS_DLX","EVENTS_EXCHANGE","recordConsumed","recordFailed","recordPaused","recordPublished","recordPublishFailure","recordQuarantined","QUARANTINE_SUFFIX","DELIVERY_POLL","millis","classify","error","kind","_tag","family","category","code","UNREADABLE","queueNameFor","subscription","slice","pattern","replaceAll","declareQueue","channel","mode","queue","assertQueue","exclusive","quarantine","durable","arguments","bindQueue","maxAttempts","ATTEMPTS_HEADER","attemptsSoFar","message","value","properties","headers","Number","isInteger","exponentialRedeliveryBackoff","dials","attempt","Math","min","baseMs","capMs","DEFAULT_REDELIVERY_BACKOFF","RedeliveryBackoffTag","Service","settleFailure","failure","backoff","tags","sync","nack","requeueUncounted","attempts","pipe","andThen","wait","isZero","void","sleep","publish","content","persistent","ack","makeAmqpEventBus","connector","wiring","options","redeliveryBackoff","pausePoll","toMillis","DEFAULT_PAUSE_POLL","inFlight","bus","event","flatMap","envelope","tryPromise","try","withChannel","name","Buffer","from","JSON","stringify","catch","dependency","tap","recordPublish","tapError","subscribe","handler","addConsumer","control","prefetch","runPromise","recordSubscription","onMessage","settle","parse","toString","parsed","mapError","matchEffect","onSuccess","onFailure","finally","consumerTag","consume","keys","pauseWhileOpen","length","breaker","undefined","watchBreakers","pollMs","drainDeliveries","gen","seconds","watched","tag","tick","cancelled","states","forEach","key","state","open","some","paused","replaceTags","cancel","timer","setInterval","ignore","once","clearInterval","AmqpEventBusLayer","effect","shutdown","serviceOption","getOrUndefined","register","phase","run","promise","cancelConsumers"],"mappings":"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"}
|
|
@@ -78,7 +78,7 @@ export declare const recordConsumed: (tags: SubscriptionTags) => import("effect/
|
|
|
78
78
|
* dimensioning only the arm that has one would make the two counts
|
|
79
79
|
* incomparable.
|
|
80
80
|
*/
|
|
81
|
-
export declare const recordFailed: (tags: SubscriptionTags, failure: "transient" | "poison") => import("effect/Effect").Effect<void, never, never>;
|
|
81
|
+
export declare const recordFailed: (tags: SubscriptionTags, failure: "transient" | "poison" | "paused") => import("effect/Effect").Effect<void, never, never>;
|
|
82
82
|
/**
|
|
83
83
|
* Record one message moved to its quarantine queue.
|
|
84
84
|
*
|
|
@@ -87,4 +87,11 @@ export declare const recordFailed: (tags: SubscriptionTags, failure: "transient"
|
|
|
87
87
|
* typo.
|
|
88
88
|
*/
|
|
89
89
|
export declare const recordQuarantined: (tags: SubscriptionTags, category: string) => import("effect/Effect").Effect<void, never, never>;
|
|
90
|
+
/**
|
|
91
|
+
* Whether a subscription is paused because a breaker it waits on is open:
|
|
92
|
+
* 1 while paused, 0 while consuming, by queue.
|
|
93
|
+
*/
|
|
94
|
+
export declare const busConsumerPaused: Metric.Gauge<number>;
|
|
95
|
+
/** Sets {@link busConsumerPaused} for a subscription. */
|
|
96
|
+
export declare const recordPaused: (tags: SubscriptionTags, paused: boolean) => import("effect/Effect").Effect<void, never, never>;
|
|
90
97
|
//# sourceMappingURL=bus-metrics.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bus-metrics.d.ts","sourceRoot":"","sources":["../../src/adapters/bus-metrics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC;;;;;;;;;;GAUG;AAEH;;;;;;GAMG;AAEH,kDAAkD;AAClD,eAAO,MAAM,kBAAkB,wBAE7B,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,wBAE7B,CAAC;AAEH,0EAA0E;AAC1E,eAAO,MAAM,iBAAiB,wBAE5B,CAAC;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,wBAI1B,CAAC;AAEH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,wBAOhC,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,gBAAgB,CAAC;CACxB;AAYD,+CAA+C;AAC/C,eAAO,MAAM,eAAe,GAAI,WAAW,MAAM,uDAI9C,CAAC;AAEJ,6CAA6C;AAC7C,eAAO,MAAM,oBAAoB,GAAI,WAAW,MAAM,uDAInD,CAAC;AAEJ,6DAA6D;AAC7D,eAAO,MAAM,cAAc,GAAI,MAAM,gBAAgB,uDACQ,CAAC;AAE9D;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,GACvB,MAAM,gBAAgB,EACtB,SAAS,WAAW,GAAG,QAAQ,
|
|
1
|
+
{"version":3,"file":"bus-metrics.d.ts","sourceRoot":"","sources":["../../src/adapters/bus-metrics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC;;;;;;;;;;GAUG;AAEH;;;;;;GAMG;AAEH,kDAAkD;AAClD,eAAO,MAAM,kBAAkB,wBAE7B,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,wBAE7B,CAAC;AAEH,0EAA0E;AAC1E,eAAO,MAAM,iBAAiB,wBAE5B,CAAC;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,wBAI1B,CAAC;AAEH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,wBAOhC,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,gBAAgB,CAAC;CACxB;AAYD,+CAA+C;AAC/C,eAAO,MAAM,eAAe,GAAI,WAAW,MAAM,uDAI9C,CAAC;AAEJ,6CAA6C;AAC7C,eAAO,MAAM,oBAAoB,GAAI,WAAW,MAAM,uDAInD,CAAC;AAEJ,6DAA6D;AAC7D,eAAO,MAAM,cAAc,GAAI,MAAM,gBAAgB,uDACQ,CAAC;AAE9D;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,GACvB,MAAM,gBAAgB,EACtB,SAAS,WAAW,GAAG,QAAQ,GAAG,QAAQ,uDAKzC,CAAC;AAEJ;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAI,MAAM,gBAAgB,EAAE,UAAU,MAAM,uDAMvE,CAAC;AAEJ;;;GAGG;AACH,eAAO,MAAM,iBAAiB,sBAE5B,CAAC;AAEH,yDAAyD;AACzD,eAAO,MAAM,YAAY,GAAI,MAAM,gBAAgB,EAAE,QAAQ,OAAO,uDAOjE,CAAC"}
|
|
@@ -86,5 +86,15 @@ const withSubscription = (metric, tags)=>metric.pipe(Metric.withAttributes({
|
|
|
86
86
|
*/ export const recordQuarantined = (tags, category)=>Metric.update(Metric.withAttributes(withSubscription(busEventsQuarantined, tags), {
|
|
87
87
|
category
|
|
88
88
|
}), 1);
|
|
89
|
+
/**
|
|
90
|
+
* Whether a subscription is paused because a breaker it waits on is open:
|
|
91
|
+
* 1 while paused, 0 while consuming, by queue.
|
|
92
|
+
*/ export const busConsumerPaused = Metric.gauge('bus_consumer_paused', {
|
|
93
|
+
description: 'Whether a consumer is paused on an open breaker, by queue.'
|
|
94
|
+
});
|
|
95
|
+
/** Sets {@link busConsumerPaused} for a subscription. */ export const recordPaused = (tags, paused)=>Metric.update(Metric.withAttributes(busConsumerPaused, {
|
|
96
|
+
queue: tags.queue,
|
|
97
|
+
slice: tags.slice
|
|
98
|
+
}), paused ? 1 : 0);
|
|
89
99
|
|
|
90
100
|
//# sourceMappingURL=bus-metrics.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/adapters/bus-metrics.ts"],"sourcesContent":["import type { SubscriptionMode } from '@entifix/transactions';\nimport { Metric } from 'effect';\n\n/**\n * The bus half of the framework's first metric set; the outbox half is in\n * `@entifix/mongo`.\n *\n * Counters rather than logs because a quarantined message that is only ever a\n * log line is indistinguishable from a dropped one when the question is *how\n * many*. Nothing here needs plumbing: `NodeSdk` wires the metric reader through\n * `@effect/opentelemetry`'s `Metrics.layer`, whose producer reads **Effect's own\n * global registry**, so an increment in any fiber — including the detached one\n * the AMQP consumer settles a delivery in — is exported.\n */\n\n/**\n * ⚠️ These metric objects are **exported so a reader uses the same instance**.\n * An Effect metric's registry key includes its description, so\n * `Metric.counter('bus_events_published_total')` written out again — without the\n * description — addresses a *different* series that is always zero. That is a\n * silent way to write a dashboard query against nothing.\n */\n\n/** Events handed to the broker, by event name. */\nexport const busEventsPublished = Metric.counter('bus_events_published_total', {\n description: 'Events accepted by the broker, by event name.',\n});\n\n/**\n * Publishes the broker refused, by event name.\n *\n * Separate from the outbox's own failure count on purpose: this one says the\n * broker would not take it, the outbox's says how many attempts that entry has\n * spent. One is a rate, the other is a backlog.\n */\nexport const busPublishFailures = Metric.counter('bus_publish_failures_total', {\n description: 'Publishes the broker refused, by event name.',\n});\n\n/** Deliveries whose handler completed and were acked, by subscription. */\nexport const busEventsConsumed = Metric.counter('bus_events_consumed_total', {\n description: 'Deliveries acked after their handler completed.',\n});\n\n/**\n * Deliveries that failed, by subscription and failure class.\n *\n * Every failed delivery, including the ones that go on to be retried, so a\n * transient failure on its fifth attempt is counted here exactly like its\n * first. How many messages were finally given up on is\n * {@link busEventsQuarantined}.\n */\nexport const busEventsFailed = Metric.counter('bus_events_failed_total', {\n description:\n 'Deliveries whose handler or payload failed, by subscription and ' +\n 'failure class, retried or not.',\n});\n\n/**\n * Messages moved to `<queue>.quarantine`, by subscription and failure category.\n *\n * Possible since the adapter, not the broker, decides the ceiling (#130): every\n * quarantine now goes through one call site in the adapter, so this is the\n * dead-letter count `bus_events_failed_total` could never be. A poison message\n * is counted on its first delivery, and a transient one when it has spent its\n * subscription's `maxAttempts`. A redelivery the broker dead-letters after a\n * consumer crash (`x-delivery-limit`) still bypasses the adapter, so the\n * quarantine queue's depth stays the confirmation.\n */\nexport const busEventsQuarantined = Metric.counter(\n 'bus_events_quarantined_total',\n {\n description:\n 'Messages the adapter moved to a quarantine queue, by subscription ' +\n 'and failure category.',\n },\n);\n\n/**\n * Identity of a subscription, as metric tags.\n *\n * `queue` is the durable work-queue name and so the only stable identity a\n * broadcast subscription does *not* have — its queue is anonymous and\n * regenerated per connection — which is why `slice` and `mode` are carried\n * beside it rather than derived from it.\n */\nexport interface SubscriptionTags {\n queue: string;\n slice: string;\n mode: SubscriptionMode;\n}\n\nconst withSubscription = (\n metric: Metric.Counter<number>,\n tags: SubscriptionTags,\n) =>\n metric.pipe(\n Metric.withAttributes({ queue: tags.queue }),\n Metric.withAttributes({ slice: tags.slice }),\n Metric.withAttributes({ mode: tags.mode }),\n );\n\n/** Record one event accepted by the broker. */\nexport const recordPublished = (eventName: string) =>\n Metric.update(\n Metric.withAttributes(busEventsPublished, { event: eventName }),\n 1,\n );\n\n/** Record one publish the broker refused. */\nexport const recordPublishFailure = (eventName: string) =>\n Metric.update(\n Metric.withAttributes(busPublishFailures, { event: eventName }),\n 1,\n );\n\n/** Record one delivery acked after its handler completed. */\nexport const recordConsumed = (tags: SubscriptionTags) =>\n Metric.update(withSubscription(busEventsConsumed, tags), 1);\n\n/**\n * Record one nacked delivery.\n *\n * `failure` is the class, never the event name: a poison message by definition\n * could not be parsed, so it has no `event.name` to attribute it to, and\n * dimensioning only the arm that has one would make the two counts\n * incomparable.\n */\nexport const recordFailed = (\n tags: SubscriptionTags,\n failure: 'transient' | 'poison',\n) =>\n Metric.update(\n Metric.withAttributes(withSubscription(busEventsFailed, tags), { failure }),\n 1,\n );\n\n/**\n * Record one message moved to its quarantine queue.\n *\n * `category` rather than `code`: the categories are a closed set and the codes\n * are not, and a metric attribute with an open set of values is a series per\n * typo.\n */\nexport const recordQuarantined = (tags: SubscriptionTags, category: string) =>\n Metric.update(\n Metric.withAttributes(withSubscription(busEventsQuarantined, tags), {\n category,\n }),\n 1,\n );\n"],"names":["Metric","busEventsPublished","counter","description","busPublishFailures","busEventsConsumed","busEventsFailed","busEventsQuarantined","withSubscription","metric","tags","pipe","withAttributes","queue","slice","mode","recordPublished","eventName","update","event","recordPublishFailure","recordConsumed","recordFailed","failure","recordQuarantined","category"],"mappings":"AACA,SAASA,MAAM,QAAQ,SAAS;AAEhC;;;;;;;;;;CAUC,GAED;;;;;;CAMC,GAED,gDAAgD,GAChD,OAAO,MAAMC,qBAAqBD,OAAOE,OAAO,CAAC,8BAA8B;IAC7EC,aAAa;AACf,GAAG;AAEH;;;;;;CAMC,GACD,OAAO,MAAMC,qBAAqBJ,OAAOE,OAAO,CAAC,8BAA8B;IAC7EC,aAAa;AACf,GAAG;AAEH,wEAAwE,GACxE,OAAO,MAAME,oBAAoBL,OAAOE,OAAO,CAAC,6BAA6B;IAC3EC,aAAa;AACf,GAAG;AAEH;;;;;;;CAOC,GACD,OAAO,MAAMG,kBAAkBN,OAAOE,OAAO,CAAC,2BAA2B;IACvEC,aACE,qEACA;AACJ,GAAG;AAEH;;;;;;;;;;CAUC,GACD,OAAO,MAAMI,uBAAuBP,OAAOE,OAAO,CAChD,gCACA;IACEC,aACE,uEACA;AACJ,GACA;AAgBF,MAAMK,mBAAmB,CACvBC,QACAC,OAEAD,OAAOE,IAAI,CACTX,OAAOY,cAAc,CAAC;QAAEC,OAAOH,KAAKG,KAAK;IAAC,IAC1Cb,OAAOY,cAAc,CAAC;QAAEE,OAAOJ,KAAKI,KAAK;IAAC,IAC1Cd,OAAOY,cAAc,CAAC;QAAEG,MAAML,KAAKK,IAAI;IAAC;AAG5C,6CAA6C,GAC7C,OAAO,MAAMC,kBAAkB,CAACC,YAC9BjB,OAAOkB,MAAM,CACXlB,OAAOY,cAAc,CAACX,oBAAoB;QAAEkB,OAAOF;IAAU,IAC7D,GACA;AAEJ,2CAA2C,GAC3C,OAAO,MAAMG,uBAAuB,CAACH,YACnCjB,OAAOkB,MAAM,CACXlB,OAAOY,cAAc,CAACR,oBAAoB;QAAEe,OAAOF;IAAU,IAC7D,GACA;AAEJ,2DAA2D,GAC3D,OAAO,MAAMI,iBAAiB,CAACX,OAC7BV,OAAOkB,MAAM,CAACV,iBAAiBH,mBAAmBK,OAAO,GAAG;AAE9D;;;;;;;CAOC,GACD,OAAO,MAAMY,eAAe,CAC1BZ,MACAa,UAEAvB,OAAOkB,MAAM,CACXlB,OAAOY,cAAc,CAACJ,iBAAiBF,iBAAiBI,OAAO;QAAEa;IAAQ,IACzE,GACA;AAEJ;;;;;;CAMC,GACD,OAAO,MAAMC,oBAAoB,CAACd,MAAwBe,WACxDzB,OAAOkB,MAAM,CACXlB,OAAOY,cAAc,CAACJ,iBAAiBD,sBAAsBG,OAAO;QAClEe;IACF,IACA,GACA"}
|
|
1
|
+
{"version":3,"sources":["../../src/adapters/bus-metrics.ts"],"sourcesContent":["import type { SubscriptionMode } from '@entifix/transactions';\nimport { Metric } from 'effect';\n\n/**\n * The bus half of the framework's first metric set; the outbox half is in\n * `@entifix/mongo`.\n *\n * Counters rather than logs because a quarantined message that is only ever a\n * log line is indistinguishable from a dropped one when the question is *how\n * many*. Nothing here needs plumbing: `NodeSdk` wires the metric reader through\n * `@effect/opentelemetry`'s `Metrics.layer`, whose producer reads **Effect's own\n * global registry**, so an increment in any fiber — including the detached one\n * the AMQP consumer settles a delivery in — is exported.\n */\n\n/**\n * ⚠️ These metric objects are **exported so a reader uses the same instance**.\n * An Effect metric's registry key includes its description, so\n * `Metric.counter('bus_events_published_total')` written out again — without the\n * description — addresses a *different* series that is always zero. That is a\n * silent way to write a dashboard query against nothing.\n */\n\n/** Events handed to the broker, by event name. */\nexport const busEventsPublished = Metric.counter('bus_events_published_total', {\n description: 'Events accepted by the broker, by event name.',\n});\n\n/**\n * Publishes the broker refused, by event name.\n *\n * Separate from the outbox's own failure count on purpose: this one says the\n * broker would not take it, the outbox's says how many attempts that entry has\n * spent. One is a rate, the other is a backlog.\n */\nexport const busPublishFailures = Metric.counter('bus_publish_failures_total', {\n description: 'Publishes the broker refused, by event name.',\n});\n\n/** Deliveries whose handler completed and were acked, by subscription. */\nexport const busEventsConsumed = Metric.counter('bus_events_consumed_total', {\n description: 'Deliveries acked after their handler completed.',\n});\n\n/**\n * Deliveries that failed, by subscription and failure class.\n *\n * Every failed delivery, including the ones that go on to be retried, so a\n * transient failure on its fifth attempt is counted here exactly like its\n * first. How many messages were finally given up on is\n * {@link busEventsQuarantined}.\n */\nexport const busEventsFailed = Metric.counter('bus_events_failed_total', {\n description:\n 'Deliveries whose handler or payload failed, by subscription and ' +\n 'failure class, retried or not.',\n});\n\n/**\n * Messages moved to `<queue>.quarantine`, by subscription and failure category.\n *\n * Possible since the adapter, not the broker, decides the ceiling (#130): every\n * quarantine now goes through one call site in the adapter, so this is the\n * dead-letter count `bus_events_failed_total` could never be. A poison message\n * is counted on its first delivery, and a transient one when it has spent its\n * subscription's `maxAttempts`. A redelivery the broker dead-letters after a\n * consumer crash (`x-delivery-limit`) still bypasses the adapter, so the\n * quarantine queue's depth stays the confirmation.\n */\nexport const busEventsQuarantined = Metric.counter(\n 'bus_events_quarantined_total',\n {\n description:\n 'Messages the adapter moved to a quarantine queue, by subscription ' +\n 'and failure category.',\n },\n);\n\n/**\n * Identity of a subscription, as metric tags.\n *\n * `queue` is the durable work-queue name and so the only stable identity a\n * broadcast subscription does *not* have — its queue is anonymous and\n * regenerated per connection — which is why `slice` and `mode` are carried\n * beside it rather than derived from it.\n */\nexport interface SubscriptionTags {\n queue: string;\n slice: string;\n mode: SubscriptionMode;\n}\n\nconst withSubscription = (\n metric: Metric.Counter<number>,\n tags: SubscriptionTags,\n) =>\n metric.pipe(\n Metric.withAttributes({ queue: tags.queue }),\n Metric.withAttributes({ slice: tags.slice }),\n Metric.withAttributes({ mode: tags.mode }),\n );\n\n/** Record one event accepted by the broker. */\nexport const recordPublished = (eventName: string) =>\n Metric.update(\n Metric.withAttributes(busEventsPublished, { event: eventName }),\n 1,\n );\n\n/** Record one publish the broker refused. */\nexport const recordPublishFailure = (eventName: string) =>\n Metric.update(\n Metric.withAttributes(busPublishFailures, { event: eventName }),\n 1,\n );\n\n/** Record one delivery acked after its handler completed. */\nexport const recordConsumed = (tags: SubscriptionTags) =>\n Metric.update(withSubscription(busEventsConsumed, tags), 1);\n\n/**\n * Record one nacked delivery.\n *\n * `failure` is the class, never the event name: a poison message by definition\n * could not be parsed, so it has no `event.name` to attribute it to, and\n * dimensioning only the arm that has one would make the two counts\n * incomparable.\n */\nexport const recordFailed = (\n tags: SubscriptionTags,\n failure: 'transient' | 'poison' | 'paused',\n) =>\n Metric.update(\n Metric.withAttributes(withSubscription(busEventsFailed, tags), { failure }),\n 1,\n );\n\n/**\n * Record one message moved to its quarantine queue.\n *\n * `category` rather than `code`: the categories are a closed set and the codes\n * are not, and a metric attribute with an open set of values is a series per\n * typo.\n */\nexport const recordQuarantined = (tags: SubscriptionTags, category: string) =>\n Metric.update(\n Metric.withAttributes(withSubscription(busEventsQuarantined, tags), {\n category,\n }),\n 1,\n );\n\n/**\n * Whether a subscription is paused because a breaker it waits on is open:\n * 1 while paused, 0 while consuming, by queue.\n */\nexport const busConsumerPaused = Metric.gauge('bus_consumer_paused', {\n description: 'Whether a consumer is paused on an open breaker, by queue.',\n});\n\n/** Sets {@link busConsumerPaused} for a subscription. */\nexport const recordPaused = (tags: SubscriptionTags, paused: boolean) =>\n Metric.update(\n Metric.withAttributes(busConsumerPaused, {\n queue: tags.queue,\n slice: tags.slice,\n }),\n paused ? 1 : 0,\n );\n"],"names":["Metric","busEventsPublished","counter","description","busPublishFailures","busEventsConsumed","busEventsFailed","busEventsQuarantined","withSubscription","metric","tags","pipe","withAttributes","queue","slice","mode","recordPublished","eventName","update","event","recordPublishFailure","recordConsumed","recordFailed","failure","recordQuarantined","category","busConsumerPaused","gauge","recordPaused","paused"],"mappings":"AACA,SAASA,MAAM,QAAQ,SAAS;AAEhC;;;;;;;;;;CAUC,GAED;;;;;;CAMC,GAED,gDAAgD,GAChD,OAAO,MAAMC,qBAAqBD,OAAOE,OAAO,CAAC,8BAA8B;IAC7EC,aAAa;AACf,GAAG;AAEH;;;;;;CAMC,GACD,OAAO,MAAMC,qBAAqBJ,OAAOE,OAAO,CAAC,8BAA8B;IAC7EC,aAAa;AACf,GAAG;AAEH,wEAAwE,GACxE,OAAO,MAAME,oBAAoBL,OAAOE,OAAO,CAAC,6BAA6B;IAC3EC,aAAa;AACf,GAAG;AAEH;;;;;;;CAOC,GACD,OAAO,MAAMG,kBAAkBN,OAAOE,OAAO,CAAC,2BAA2B;IACvEC,aACE,qEACA;AACJ,GAAG;AAEH;;;;;;;;;;CAUC,GACD,OAAO,MAAMI,uBAAuBP,OAAOE,OAAO,CAChD,gCACA;IACEC,aACE,uEACA;AACJ,GACA;AAgBF,MAAMK,mBAAmB,CACvBC,QACAC,OAEAD,OAAOE,IAAI,CACTX,OAAOY,cAAc,CAAC;QAAEC,OAAOH,KAAKG,KAAK;IAAC,IAC1Cb,OAAOY,cAAc,CAAC;QAAEE,OAAOJ,KAAKI,KAAK;IAAC,IAC1Cd,OAAOY,cAAc,CAAC;QAAEG,MAAML,KAAKK,IAAI;IAAC;AAG5C,6CAA6C,GAC7C,OAAO,MAAMC,kBAAkB,CAACC,YAC9BjB,OAAOkB,MAAM,CACXlB,OAAOY,cAAc,CAACX,oBAAoB;QAAEkB,OAAOF;IAAU,IAC7D,GACA;AAEJ,2CAA2C,GAC3C,OAAO,MAAMG,uBAAuB,CAACH,YACnCjB,OAAOkB,MAAM,CACXlB,OAAOY,cAAc,CAACR,oBAAoB;QAAEe,OAAOF;IAAU,IAC7D,GACA;AAEJ,2DAA2D,GAC3D,OAAO,MAAMI,iBAAiB,CAACX,OAC7BV,OAAOkB,MAAM,CAACV,iBAAiBH,mBAAmBK,OAAO,GAAG;AAE9D;;;;;;;CAOC,GACD,OAAO,MAAMY,eAAe,CAC1BZ,MACAa,UAEAvB,OAAOkB,MAAM,CACXlB,OAAOY,cAAc,CAACJ,iBAAiBF,iBAAiBI,OAAO;QAAEa;IAAQ,IACzE,GACA;AAEJ;;;;;;CAMC,GACD,OAAO,MAAMC,oBAAoB,CAACd,MAAwBe,WACxDzB,OAAOkB,MAAM,CACXlB,OAAOY,cAAc,CAACJ,iBAAiBD,sBAAsBG,OAAO;QAClEe;IACF,IACA,GACA;AAEJ;;;CAGC,GACD,OAAO,MAAMC,oBAAoB1B,OAAO2B,KAAK,CAAC,uBAAuB;IACnExB,aAAa;AACf,GAAG;AAEH,uDAAuD,GACvD,OAAO,MAAMyB,eAAe,CAAClB,MAAwBmB,SACnD7B,OAAOkB,MAAM,CACXlB,OAAOY,cAAc,CAACc,mBAAmB;QACvCb,OAAOH,KAAKG,KAAK;QACjBC,OAAOJ,KAAKI,KAAK;IACnB,IACAe,SAAS,IAAI,GACb"}
|
|
@@ -40,7 +40,17 @@ export declare const EVENTS_DLX = "entifix.events.dlx";
|
|
|
40
40
|
* consumers and never to stop them. `void` is accepted for a setup that starts
|
|
41
41
|
* none (a declaration-only one).
|
|
42
42
|
*/
|
|
43
|
-
export type AmqpConsumerSetup = (channel: amqp.Channel) => Promise<void | readonly string[]>;
|
|
43
|
+
export type AmqpConsumerSetup = (channel: amqp.Channel, control: AmqpConsumerControl) => Promise<void | readonly string[]>;
|
|
44
|
+
/**
|
|
45
|
+
* What a consumer may tell the connector after its setup ran: that it now
|
|
46
|
+
* holds other tags — it paused, or resumed under a new one — so a shutdown
|
|
47
|
+
* cancels what it actually holds; and whether intake has been stopped, so a
|
|
48
|
+
* consumer does not resume into a shutdown.
|
|
49
|
+
*/
|
|
50
|
+
export interface AmqpConsumerControl {
|
|
51
|
+
readonly replaceTags: (tags: readonly string[]) => void;
|
|
52
|
+
readonly cancelled: () => boolean;
|
|
53
|
+
}
|
|
44
54
|
/**
|
|
45
55
|
* A RabbitMQ connection that survives the broker going away.
|
|
46
56
|
*
|
|
@@ -1 +1 @@
|
|
|
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,
|
|
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,EACrB,OAAO,EAAE,mBAAmB,KACzB,OAAO,CAAC,IAAI,GAAG,SAAS,MAAM,EAAE,CAAC,CAAC;AAEvC;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,KAAK,IAAI,CAAC;IACxD,QAAQ,CAAC,SAAS,EAAE,MAAM,OAAO,CAAC;CACnC;AAED;;;;;;;;;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;;;CA4H5C,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,SAAS,GACpB,UAAU,YAAY,KACrB,KAAK,CAAC,KAAK,CAAC,cAAc,EAAE,WAAW,CAcvC,CAAC"}
|
|
@@ -53,7 +53,12 @@ import { Context, Effect, Layer } from 'effect';
|
|
|
53
53
|
for (const consumer of consumers){
|
|
54
54
|
if (consumer.boundTo !== channel) {
|
|
55
55
|
var _ref;
|
|
56
|
-
consumer.tags = (_ref = await consumer.setup(channel
|
|
56
|
+
consumer.tags = (_ref = await consumer.setup(channel, {
|
|
57
|
+
replaceTags: (tags)=>{
|
|
58
|
+
consumer.tags = tags;
|
|
59
|
+
},
|
|
60
|
+
cancelled: ()=>cancelled
|
|
61
|
+
})) != null ? _ref : [];
|
|
57
62
|
consumer.boundTo = channel;
|
|
58
63
|
}
|
|
59
64
|
}
|
|
@@ -1 +1 @@
|
|
|
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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"}
|
|
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 control: AmqpConsumerControl,\n) => Promise<void | readonly string[]>;\n\n/**\n * What a consumer may tell the connector after its setup ran: that it now\n * holds other tags — it paused, or resumed under a new one — so a shutdown\n * cancels what it actually holds; and whether intake has been stopped, so a\n * consumer does not resume into a shutdown.\n */\nexport interface AmqpConsumerControl {\n readonly replaceTags: (tags: readonly string[]) => void;\n readonly cancelled: () => boolean;\n}\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 =\n (await consumer.setup(channel, {\n replaceTags: tags => {\n consumer.tags = tags;\n },\n cancelled: () => cancelled,\n })) ?? [];\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","replaceTags","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":"AAAA,SAASA,WAAW,QAAQ,gBAAgB;AAC5C,YAAYC,UAAU,UAAU;AAChC,SAASC,OAAO,EAAEC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAEhD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,kBAAkB,iBAAiB;AAEhD;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,aAAa,qBAAqB;AA+D/C,qEAAqE,GACrE,OAAO,MAAMC,uBAAuBL,QAAQM,OAAO,GAG/C;AAAmB;AA0BvB;;CAEC,GACD,OAAO,MAAMC,oBAAoB,CAACC;IAChC,IAAIC;IACJ,IAAIC;IACJ,IAAIC,SAAS;IACb,IAAIC,YAAY;IAChB,MAAMC,YAAwB,EAAE;IAEhC;;;;;;;GAOC,GACD,MAAMC,UAAU,OAAOC;QACrB,IAAIH,WAAW;YACb;QACF;QACA,KAAK,MAAMI,YAAYH,UAAW;YAChC,IAAIG,SAASC,OAAO,KAAKF,SAAS;oBAE7B;gBADHC,SAASE,IAAI,IACV,OAAA,MAAMF,SAASG,KAAK,CAACJ,SAAS;oBAC7BK,aAAaF,CAAAA;wBACXF,SAASE,IAAI,GAAGA;oBAClB;oBACAN,WAAW,IAAMA;gBACnB,cALC,OAKM,EAAE;gBACXI,SAASC,OAAO,GAAGF;YACrB;QACF;IACF;IAEA,MAAMM,OAAO;QACX,MAAMC,aAAa,MAAMvB,KAAKwB,OAAO,CAACf;QACtC,MAAMO,UAAU,MAAMO,WAAWE,aAAa;QAC9C,MAAMT,QAAQU,cAAc,CAACtB,iBAAiB,SAAS;YACrDuB,SAAS;QACX;QACA,MAAMX,QAAQU,cAAc,CAACrB,YAAY,UAAU;YAAEsB,SAAS;QAAK;QAEnE,wEAAwE;QACxE,4EAA4E;QAC5E,qEAAqE;QACrE,oEAAoE;QACpE,MAAMC,OAAO;YACX,IAAIlB,CAAAA,wBAAAA,KAAMa,UAAU,MAAKA,YAAY;gBACnCb,OAAOmB;YACT;QACF;QACAN,WAAWO,EAAE,CAAC,SAASF;QACvBL,WAAWO,EAAE,CAAC,SAASF;QACvBZ,QAAQc,EAAE,CAAC,SAASF;QACpBZ,QAAQc,EAAE,CAAC,SAASF;QAEpBlB,OAAO;YAAEa;YAAYP;QAAQ;QAE7B,2EAA2E;QAC3E,yEAAyE;QACzE,MAAMD,QAAQC;QACd,OAAON;IACT;IAEA,MAAMqB,SAAS;QACb,IAAInB,QAAQ;YACV,MAAM,IAAIoB,MAAM;QAClB;QACA,IAAItB,MAAM;YACR,OAAOA;QACT;QACA,2EAA2E;QAC3E,2EAA2E;QAC3EC,kBAAAA,UAAAA,UAAYW,OAAOW,OAAO,CAAC;YACzBtB,UAAUkB;QACZ;QACA,OAAOlB;IACT;IAEA,MAAMuB,YAA2B;QAC/BC,aAAa,OAAMC;YACjB,IAAI;gBACF,OAAO,MAAMA,IAAI,AAAC,CAAA,MAAML,QAAO,EAAGf,OAAO;YAC3C,EAAE,OAAOqB,OAAO;gBACd,IAAIzB,QAAQ;oBACV,MAAMyB;gBACR;gBACA,wEAAwE;gBACxE,iDAAiD;gBACjD3B,OAAOmB;gBACP,OAAOO,IAAI,AAAC,CAAA,MAAML,QAAO,EAAGf,OAAO;YACrC;QACF;QACAsB,aAAa,OAAMlB;YACjBN,UAAUyB,IAAI,CAAC;gBAAEnB;gBAAOD,MAAM,EAAE;YAAC;YACjC,yEAAyE;YACzE,mEAAmE;YACnE,MAAMJ,QAAQ,AAAC,CAAA,MAAMgB,QAAO,EAAGf,OAAO;QACxC;QACAwB,iBAAiB;YACf3B,YAAY;YACZ,MAAMG,UAAUN,wBAAAA,KAAMM,OAAO;YAC7B,IAAIA,YAAYa,WAAW;gBACzB;YACF;YACA,KAAK,MAAMZ,YAAYH,UAAW;gBAChC,KAAK,MAAM2B,OAAOxB,SAASE,IAAI,CAAE;oBAC/B,iEAAiE;oBACjE,+DAA+D;oBAC/D,0CAA0C;oBAC1C,MAAMH,QAAQ0B,MAAM,CAACD,KAAKE,KAAK,CAAC,IAAMd;gBACxC;gBACAZ,SAASE,IAAI,GAAG,EAAE;YACpB;QACF;IACF;IAEA,MAAMyB,QAAQ;QACZhC,SAAS;QACT,MAAMiC,UAAUnC;QAChBA,OAAOmB;QACP,OAAMgB,2BAAAA,QAAStB,UAAU,CAACqB,KAAK,GAAGD,KAAK,CAAC,IAAMd;IAChD;IAEA,OAAO;QAAEK;QAAWU;IAAM;AAC5B,EAAE;AAEF;;;;;;;;CAQC,GACD,OAAO,MAAME,YAAY,CACvBC,WAEA5C,MAAM6C,MAAM,CACV1C,gBACAJ,OAAO+C,cAAc,CACnB/C,OAAOgD,UAAU,CAAC;QAChBC,KAAK;YACH,MAAMC,QAAQ5C,kBAAkBuC,SAAStC,GAAG;YAC5C,MAAM2C,MAAMlB,SAAS,CAACC,WAAW,CAAC,UAAYN;YAC9C,OAAOuB;QACT;QACAT,OAAON,CAAAA,QAAS,IAAItC,YAAY;gBAAEsD,YAAY;YAAO;IACvD,IACAD,CAAAA,QAASlD,OAAOoD,OAAO,CAAC,IAAMF,MAAMR,KAAK,KACzCW,IAAI,CAACrD,OAAOsD,GAAG,CAACJ,CAAAA,QAASA,MAAMlB,SAAS,IAC1C"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entifix/amqp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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.12.1",
|
|
61
|
+
"@entifix/core": "0.12.1"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@types/amqplib": "0.10.8",
|
|
65
65
|
"effect": "4.0.6",
|
|
66
|
-
"@entifix/testing-unit": "0.
|
|
67
|
-
"@entifix/transactions": "0.
|
|
66
|
+
"@entifix/testing-unit": "0.12.1",
|
|
67
|
+
"@entifix/transactions": "0.12.1"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"effect": "^4.0.6",
|
|
71
|
-
"@entifix/transactions": "0.
|
|
71
|
+
"@entifix/transactions": "0.12.1"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|
|
74
74
|
"@entifix/transactions": {
|