@entifix/amqp 0.10.0 → 0.10.2

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.
@@ -1,7 +1,7 @@
1
1
  import type { WiringRegistry } from '@entifix/business';
2
2
  import { ShutdownRegistryTag, WiringRegistryTag } from '@entifix/business';
3
3
  import { type EventBus, EventBusTag, type Subscription } from '@entifix/transactions';
4
- import { Effect, Layer } from 'effect';
4
+ import { Context, Duration, Effect, Layer } from 'effect';
5
5
  import { AmqpChannelTag, type AmqpConnector } from '../amqp-connection/amqp-connection.js';
6
6
  /**
7
7
  * The queue a subscription binds.
@@ -21,6 +21,46 @@ import { AmqpChannelTag, type AmqpConnector } from '../amqp-connection/amqp-conn
21
21
  * three saved characters.
22
22
  */
23
23
  export declare const queueNameFor: (subscription: Subscription) => string;
24
+ /**
25
+ * The adapter's own count of the attempts a message has failed, carried on the
26
+ * copy it republishes (#130).
27
+ *
28
+ * The broker cannot be trusted with it. RabbitMQ 4 does not count a requeued
29
+ * `basic.nack` toward a quorum queue's `x-delivery-limit` — measured on 4.3.5:
30
+ * a queue limited to 2 delivered one message 8 times and never set
31
+ * `x-delivery-count` — so a transient failure that never cleared was
32
+ * redelivered without a ceiling and without a pause. `x-delivery-limit` stays
33
+ * on the queue, where it still bounds the redeliveries a crashed consumer
34
+ * causes, which the broker does count.
35
+ */
36
+ export declare const ATTEMPTS_HEADER = "x-entifix-attempts";
37
+ /** How long a transient failure waits before its copy goes back on the queue. */
38
+ export type RedeliveryBackoff = (attempt: number) => Duration.Duration;
39
+ /**
40
+ * A backoff doubling from `baseMs`, capped at `capMs`. The wait holds the
41
+ * delivery — the queue is `prefetch(1)`, so it pauses this consumer — which is
42
+ * the point: a dependency that is down is not helped by asking it again at
43
+ * once.
44
+ */
45
+ export declare const exponentialRedeliveryBackoff: (dials: {
46
+ readonly baseMs: number;
47
+ readonly capMs: number;
48
+ }) => RedeliveryBackoff;
49
+ /** Doubling from 100 ms, capped at two seconds. */
50
+ export declare const DEFAULT_REDELIVERY_BACKOFF: RedeliveryBackoff;
51
+ declare const RedeliveryBackoffTag_base: Context.ServiceClass<RedeliveryBackoffTag, "RedeliveryBackoffTag", RedeliveryBackoff>;
52
+ /**
53
+ * The redelivery backoff {@link AmqpEventBusLayer} uses, when a composition
54
+ * provides one; {@link DEFAULT_REDELIVERY_BACKOFF} otherwise.
55
+ *
56
+ * A tag rather than a layer option because the dials it is sized from are
57
+ * runtime configuration a host reads at boot, after the layer value already
58
+ * exists. Read with `Effect.serviceOption`, so a host that never sizes it
59
+ * provides nothing — which also means nothing forces one that meant to, and
60
+ * the spec pins both arms.
61
+ */
62
+ export declare class RedeliveryBackoffTag extends RedeliveryBackoffTag_base {
63
+ }
24
64
  /**
25
65
  * RabbitMQ-backed {@link EventBus}. Events go out as `event` envelopes on the
26
66
  * shared topic exchange — the adapter owns the wire framing, so the port stays
@@ -48,7 +88,9 @@ export declare const queueNameFor: (subscription: Subscription) => string;
48
88
  * runs them — and putting it on `EventBus` would make every implementation of
49
89
  * a framework-free port carry a shutdown concern it has no deliveries to drain.
50
90
  */
51
- export declare const makeAmqpEventBus: (connector: AmqpConnector, wiring: WiringRegistry) => {
91
+ export declare const makeAmqpEventBus: (connector: AmqpConnector, wiring: WiringRegistry, options?: {
92
+ readonly redeliveryBackoff?: RedeliveryBackoff;
93
+ }) => {
52
94
  bus: EventBus;
53
95
  drainDeliveries: Effect.Effect<void>;
54
96
  };
@@ -60,4 +102,5 @@ export declare const makeAmqpEventBus: (connector: AmqpConnector, wiring: Wiring
60
102
  * document reports the bindings this very process made.
61
103
  */
62
104
  export declare const AmqpEventBusLayer: Layer.Layer<EventBusTag, never, AmqpChannelTag | ShutdownRegistryTag | WiringRegistryTag>;
105
+ export {};
63
106
  //# sourceMappingURL=amqp-event-bus.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"amqp-event-bus.d.ts","sourceRoot":"","sources":["../../src/adapters/amqp-event-bus.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAO3E,OAAO,EACL,KAAK,QAAQ,EACb,WAAW,EACX,KAAK,YAAY,EAElB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAY,MAAM,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAEjD,OAAO,EACL,cAAc,EACd,KAAK,aAAa,EAGnB,MAAM,uCAAuC,CAAC;AAsE/C;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,YAAY,GAAI,cAAc,YAAY,KAAG,MAG1B,CAAC;AAsGjC;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,gBAAgB,GAC3B,WAAW,aAAa,EACxB,QAAQ,cAAc,KACrB;IAAE,GAAG,EAAE,QAAQ,CAAC;IAAC,eAAe,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;CAqKvD,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,2FAuB7B,CAAC"}
1
+ {"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,EAGnB,MAAM,uCAAuC,CAAC;AAwE/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;AAqF9B;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,gBAAgB,GAC3B,WAAW,aAAa,EACxB,QAAQ,cAAc,EACtB,UAAS;IAAE,QAAQ,CAAC,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;CAAO,KAC/D;IAAE,GAAG,EAAE,QAAQ,CAAC;IAAC,eAAe,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;CAsKvD,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,2FA0B7B,CAAC"}
@@ -2,9 +2,9 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
2
2
  import { ShutdownRegistryTag, WiringRegistryTag } from '@entifix/business';
3
3
  import { isFailure, makeEventEnvelope, readEventEnvelope, Unavailable } from '@entifix/core';
4
4
  import { EventBusTag } from '@entifix/transactions';
5
- import { Duration, Effect, Layer } from 'effect';
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 } from './bus-metrics.js';
7
+ import { recordConsumed, recordFailed, 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.
@@ -19,9 +19,10 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
19
19
  * ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)):
20
20
  * an answer redelivered is the same answer, so every business failure is
21
21
  * poison, and so is `protocol` — bytes that did not decode will not decode on
22
- * the fifth try. The transient categories are redelivered, bounded by
23
- * `x-delivery-limit`, and so is anything uncategorised: not knowing what went
24
- * wrong is not evidence that retrying is pointless.
22
+ * the fifth try. The transient categories are redelivered, bounded by the
23
+ * subscription's `maxAttempts` as the adapter counts them, and so is anything
24
+ * uncategorised: not knowing what went wrong is not evidence that retrying is
25
+ * pointless.
25
26
  */ const classify = (error)=>isFailure(error) ? {
26
27
  kind: error.family === 'business' || error.category === 'protocol' ? 'poison' : 'transient',
27
28
  code: error.code,
@@ -58,9 +59,11 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
58
59
  *
59
60
  * A **work** queue is durable and quorum-backed, so it survives the consumer's
60
61
  * restart and accumulates while it is gone — the hop the outbox relay's
61
- * durability chain was missing. Its retry ceiling is `x-delivery-limit`, a queue argument
62
- * rather than logic every consumer reimplements, so it also counts a
63
- * redelivery after a crash.
62
+ * durability chain was missing. Its retry ceiling is the subscription's
63
+ * `maxAttempts`, counted by the adapter in {@link ATTEMPTS_HEADER} because the
64
+ * broker does not count a requeued nack (#130). The same number is the queue's
65
+ * `x-delivery-limit`, which bounds what the broker *does* count — a redelivery
66
+ * after a consumer crash.
64
67
  *
65
68
  * The quarantine is declared **first**: a message dead-lettered the instant the
66
69
  * work queue exists must have somewhere to land, and a `direct` exchange with
@@ -100,43 +103,101 @@ import { recordConsumed, recordFailed, recordPublished, recordPublishFailure } f
100
103
  return queue;
101
104
  };
102
105
  /**
103
- * Whether a failed delivery should go back on the queue.
106
+ * The adapter's own count of the attempts a message has failed, carried on the
107
+ * copy it republishes (#130).
104
108
  *
105
- * Only a transient failure on a **work** queue: there the broker counts the
106
- * redelivery against `x-delivery-limit` and dead-letters at the ceiling, so a
107
- * requeue is bounded. A broadcast queue has neither a ceiling nor a dead-letter
108
- * path, so requeueing there is an unbounded loop.
109
- */ /**
109
+ * The broker cannot be trusted with it. RabbitMQ 4 does not count a requeued
110
+ * `basic.nack` toward a quorum queue's `x-delivery-limit` — measured on 4.3.5:
111
+ * a queue limited to 2 delivered one message 8 times and never set
112
+ * `x-delivery-count` — so a transient failure that never cleared was
113
+ * redelivered without a ceiling and without a pause. `x-delivery-limit` stays
114
+ * on the queue, where it still bounds the redeliveries a crashed consumer
115
+ * causes, which the broker does count.
116
+ */ export const ATTEMPTS_HEADER = 'x-entifix-attempts';
117
+ const attemptsSoFar = (message)=>{
118
+ var _message_properties_headers, _message_properties;
119
+ const value = (_message_properties = message.properties) == null ? void 0 : (_message_properties_headers = _message_properties.headers) == null ? void 0 : _message_properties_headers[ATTEMPTS_HEADER];
120
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : 0;
121
+ };
122
+ /**
123
+ * A backoff doubling from `baseMs`, capped at `capMs`. The wait holds the
124
+ * delivery — the queue is `prefetch(1)`, so it pauses this consumer — which is
125
+ * the point: a dependency that is down is not helped by asking it again at
126
+ * once.
127
+ */ export const exponentialRedeliveryBackoff = (dials)=>(attempt)=>Duration.millis(Math.min(dials.baseMs * 2 ** (attempt - 1), dials.capMs));
128
+ /** Doubling from 100 ms, capped at two seconds. */ export const DEFAULT_REDELIVERY_BACKOFF = exponentialRedeliveryBackoff({
129
+ baseMs: 100,
130
+ capMs: 2000
131
+ });
132
+ /**
133
+ * The redelivery backoff {@link AmqpEventBusLayer} uses, when a composition
134
+ * provides one; {@link DEFAULT_REDELIVERY_BACKOFF} otherwise.
135
+ *
136
+ * A tag rather than a layer option because the dials it is sized from are
137
+ * runtime configuration a host reads at boot, after the layer value already
138
+ * exists. Read with `Effect.serviceOption`, so a host that never sizes it
139
+ * provides nothing — which also means nothing forces one that meant to, and
140
+ * the spec pins both arms.
141
+ */ export class RedeliveryBackoffTag extends Context.Service()('RedeliveryBackoffTag') {
142
+ }
143
+ /**
110
144
  * Settles a delivery that failed.
111
145
  *
112
- * Poison on a `work` queue is republished to the dead-letter exchange **with
113
- * its reason** — `x-failure-code` and `x-failure-category` — and then acked,
114
- * rather than nacked: a nack cannot carry headers, and a quarantine of
115
- * messages nobody can say anything about is a queue an operator has to replay
116
- * blind. If that publish is refused the nack still quarantines it, reasonless.
146
+ * Poison on a `work` queue — and a transient failure that has spent the
147
+ * subscription's `maxAttempts` — is republished to the dead-letter exchange
148
+ * **with its reason**: `x-failure-code`, `x-failure-category` and the attempts
149
+ * spent. It is then acked rather than nacked, because a nack cannot carry
150
+ * headers, and a quarantine of messages nobody can say anything about is a
151
+ * queue an operator has to replay blind. If that publish is refused the nack
152
+ * still quarantines it, reasonless.
117
153
  *
118
- * A transient failure is nacked for the broker to redeliver; a `broadcast`
119
- * queue has no quarantine, so its failures are dropped.
120
- */ const settleFailure = (channel, message, queue, mode, failure)=>{
121
- if (failure.kind === 'poison' && mode === 'work') {
154
+ * A transient failure under the ceiling waits its backoff, goes back on its own
155
+ * queue as a copy carrying the attempt count, and the original is acked. If
156
+ * that publish is refused it is nacked for the broker to redeliver, which is
157
+ * the old behaviour and still better than losing it. A `broadcast` queue has no
158
+ * quarantine and no ceiling, so its failures are dropped.
159
+ */ const settleFailure = (channel, message, queue, subscription, failure, backoff, tags)=>{
160
+ if (subscription.mode === 'broadcast') {
161
+ return Effect.sync(()=>channel.nack(message, false, false));
162
+ }
163
+ const attempts = failure.kind === 'transient' ? attemptsSoFar(message) + 1 : 0;
164
+ if (failure.kind === 'poison' || attempts >= subscription.maxAttempts) {
165
+ return Effect.sync(()=>quarantine(channel, message, queue, failure, attempts)).pipe(Effect.andThen(recordQuarantined(tags, failure.category)));
166
+ }
167
+ const wait = backoff(attempts);
168
+ return (Duration.isZero(wait) ? Effect.void : Effect.sleep(wait)).pipe(Effect.andThen(Effect.sync(()=>{
122
169
  try {
123
170
  var _message_properties;
124
- channel.publish(EVENTS_DLX, queue, message.content, _extends({}, message.properties, {
171
+ channel.publish('', queue, message.content, _extends({}, message.properties, {
125
172
  persistent: true,
126
173
  headers: _extends({}, (_message_properties = message.properties) == null ? void 0 : _message_properties.headers, {
127
- 'x-failure-code': failure.code,
128
- 'x-failure-category': failure.category
174
+ [ATTEMPTS_HEADER]: attempts
129
175
  })
130
176
  }));
131
177
  channel.ack(message);
132
- return;
133
178
  } catch (unused) {
134
- // Fall through: quarantined without its reason beats not quarantined.
179
+ channel.nack(message, false, true);
135
180
  }
181
+ })));
182
+ };
183
+ const quarantine = (channel, message, queue, failure, attempts)=>{
184
+ try {
185
+ var _message_properties;
186
+ channel.publish(EVENTS_DLX, queue, message.content, _extends({}, message.properties, {
187
+ persistent: true,
188
+ headers: _extends({}, (_message_properties = message.properties) == null ? void 0 : _message_properties.headers, {
189
+ 'x-failure-code': failure.code,
190
+ 'x-failure-category': failure.category
191
+ }, attempts > 0 ? {
192
+ [ATTEMPTS_HEADER]: attempts
193
+ } : {})
194
+ }));
195
+ channel.ack(message);
196
+ } catch (unused) {
197
+ // Quarantined without its reason beats not quarantined.
198
+ channel.nack(message, false, false);
136
199
  }
137
- channel.nack(message, false, requeues(failure, mode));
138
200
  };
139
- const requeues = (failure, mode)=>failure.kind === 'transient' && mode === 'work';
140
201
  /**
141
202
  * RabbitMQ-backed {@link EventBus}. Events go out as `event` envelopes on the
142
203
  * shared topic exchange — the adapter owns the wire framing, so the port stays
@@ -163,7 +224,9 @@ const requeues = (failure, mode)=>failure.kind === 'transient' && mode === 'work
163
224
  * waiting for in-flight handlers is an adapter fact — it is this module that
164
225
  * runs them — and putting it on `EventBus` would make every implementation of
165
226
  * a framework-free port carry a shutdown concern it has no deliveries to drain.
166
- */ export const makeAmqpEventBus = (connector, wiring)=>{
227
+ */ export const makeAmqpEventBus = (connector, wiring, options = {})=>{
228
+ var _options_redeliveryBackoff;
229
+ const backoff = (_options_redeliveryBackoff = options.redeliveryBackoff) != null ? _options_redeliveryBackoff : DEFAULT_REDELIVERY_BACKOFF;
167
230
  /**
168
231
  * Handlers started and not yet settled.
169
232
  *
@@ -235,7 +298,7 @@ const requeues = (failure, mode)=>failure.kind === 'transient' && mode === 'work
235
298
  // is the *only* thing that can tell a malformed one from a bad
236
299
  // afternoon at the database — and it says so by failing with
237
300
  // a business or `protocol` failure, which quarantines at once
238
- // instead of spending `x-delivery-limit` on every message
301
+ // instead of spending `maxAttempts` on every message
239
302
  // behind it. Measured on `catalog.published`: a payload the
240
303
  // contract rejected used to take five deliveries to quarantine.
241
304
  Effect.flatMap((event)=>handler(event).pipe(Effect.mapError(classify))));
@@ -250,7 +313,7 @@ const requeues = (failure, mode)=>failure.kind === 'transient' && mode === 'work
250
313
  // inside the observability layer.
251
314
  Effect.matchEffect({
252
315
  onSuccess: ()=>recordConsumed(tags).pipe(Effect.andThen(Effect.sync(()=>channel.ack(message)))),
253
- onFailure: (failure)=>recordFailed(tags, failure.kind).pipe(Effect.andThen(Effect.sync(()=>settleFailure(channel, message, queue, subscription.mode, failure))))
316
+ onFailure: (failure)=>recordFailed(tags, failure.kind).pipe(Effect.andThen(settleFailure(channel, message, queue, subscription, failure, backoff, tags)))
254
317
  }))).finally(()=>{
255
318
  inFlight -= 1;
256
319
  });
@@ -283,7 +346,10 @@ const requeues = (failure, mode)=>failure.kind === 'transient' && mode === 'work
283
346
  const connector = yield* AmqpChannelTag;
284
347
  const wiring = yield* WiringRegistryTag;
285
348
  const shutdown = yield* ShutdownRegistryTag;
286
- const { bus, drainDeliveries } = makeAmqpEventBus(connector, wiring);
349
+ const redeliveryBackoff = yield* Effect.serviceOption(RedeliveryBackoffTag);
350
+ const { bus, drainDeliveries } = makeAmqpEventBus(connector, wiring, {
351
+ redeliveryBackoff: Option.getOrUndefined(redeliveryBackoff)
352
+ });
287
353
  // `stop-intake`, and both halves are it: cancelling stops the broker
288
354
  // handing over anything new, then the drain waits for the deliveries
289
355
  // already in progress. Registered here rather than in a service's `main.ts`
@@ -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 isFailure,\n makeEventEnvelope,\n readEventEnvelope,\n Unavailable,\n} from '@entifix/core';\nimport {\n type EventBus,\n EventBusTag,\n type Subscription,\n type SubscriptionMode,\n} from '@entifix/transactions';\nimport type { Channel, ConsumeMessage } from 'amqplib';\nimport { Duration, Effect, Layer } from 'effect';\n\nimport {\n AmqpChannelTag,\n type AmqpConnector,\n EVENTS_DLX,\n EVENTS_EXCHANGE,\n} from '../amqp-connection/amqp-connection.js';\nimport {\n recordConsumed,\n recordFailed,\n recordPublished,\n recordPublishFailure,\n type SubscriptionTags,\n} from './bus-metrics.js';\n\n/** Appended to a work queue's name to make the queue that holds its failures. */\nconst QUARANTINE_SUFFIX = '.quarantine';\n\n/**\n * How often the shutdown drain re-checks whether the handlers have finished.\n *\n * A poll rather than a latch because a delivery is started from amqplib's own\n * callback, outside any fiber this module holds; the drain's own bound is the\n * shutdown hook's, so this interval only decides how promptly the process\n * notices it is free to go.\n */\nconst DELIVERY_POLL = Duration.millis(25);\n\n/**\n * Why a delivery did not succeed, which is the whole of the nack decision.\n *\n * `transient` is a handler that threw — a dependency briefly gone, a timeout —\n * and is worth another delivery. `poison` is a payload this process cannot\n * read, and is worth **none**: a message that cannot be deserialized never\n * becomes deserializable, so retrying it only spends the delivery budget of the\n * messages queued behind it. Conflating the two is what the adapter once did,\n * on a queue that discarded both.\n *\n * A *business* failure is neither, and never reaches here: that message was\n * handled successfully and produced its own `failed` event.\n */\ninterface DeliveryFailure {\n readonly kind: 'transient' | 'poison';\n /** The failure's code and category, carried onto a quarantined message. */\n readonly code: string;\n readonly category: string;\n}\n\n/**\n * The poison rule, by category and never by class\n * ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)):\n * an answer redelivered is the same answer, so every business failure is\n * poison, and so is `protocol` — bytes that did not decode will not decode on\n * the fifth try. The transient categories are redelivered, bounded by\n * `x-delivery-limit`, and so is anything uncategorised: not knowing what went\n * wrong is not evidence that retrying is pointless.\n */\nconst classify = (error: unknown): DeliveryFailure =>\n isFailure(error)\n ? {\n kind:\n error.family === 'business' || error.category === 'protocol'\n ? 'poison'\n : 'transient',\n code: error.code,\n category: error.category,\n }\n : { kind: 'transient', code: 'internal', category: 'internal' };\n\n/** A payload that is not JSON: the sender's fault, decided before any handler. */\nconst UNREADABLE: DeliveryFailure = {\n kind: 'poison',\n code: 'invalidEnvelope',\n category: 'invalid',\n};\n\n/**\n * The queue a subscription binds.\n *\n * `<subscribing slice>.<pattern>`, so the name says who is owed the messages in\n * it — readable in the broker's own UI, which is where someone looks when a\n * queue has depth. The slice is the *subscriber's*, never the publisher's:\n * `EventSourceTag` names the emitter, and one deployment hosts several slices\n * (a host may co-deploy two slices in one process), so\n * deriving from it would file a consumer's queue under whoever happened to\n * publish from the same process.\n *\n * `*` and `#` are spelled out because AMQP 0-9-1 restricts a queue name to\n * letters, digits, hyphen, underscore, period and colon. RabbitMQ is lenient in\n * practice, but the name also has to survive being a URL path segment in the\n * management API, and `%2A` in an operator's address bar is a poor trade for\n * three saved characters.\n */\nexport const queueNameFor = (subscription: Subscription): string =>\n `${subscription.slice}.${subscription.pattern\n .replaceAll('*', '_star_')\n .replaceAll('#', '_hash_')}`;\n\n/**\n * Declares the queue a subscription consumes and returns its name.\n *\n * A **work** queue is durable and quorum-backed, so it survives the consumer's\n * restart and accumulates while it is gone — the hop the outbox relay's\n * durability chain was missing. Its retry ceiling is `x-delivery-limit`, a queue argument\n * rather than logic every consumer reimplements, so it also counts a\n * redelivery after a crash.\n *\n * The quarantine is declared **first**: a message dead-lettered the instant the\n * work queue exists must have somewhere to land, and a `direct` exchange with\n * no matching binding drops what it routes.\n *\n * `x-delivery-limit` is immutable once a queue exists. Re-declaring with a\n * different `maxAttempts` fails `PRECONDITION_FAILED` and closes the channel,\n * and there is no safe automatic recovery — so changing the ceiling means\n * deleting the queue, which locally is `pnpm run <app>:dev:reset`.\n */\nconst declareQueue = async (\n channel: Channel,\n subscription: Subscription,\n): Promise<string> => {\n if (subscription.mode === 'broadcast') {\n // Connection-scoped and retained by nobody, which is right only when the\n // replicas differ from one another, as a per-connection push does.\n const { queue } = await channel.assertQueue('', { exclusive: true });\n return queue;\n }\n\n const queue = queueNameFor(subscription);\n const quarantine = `${queue}${QUARANTINE_SUFFIX}`;\n\n await channel.assertQueue(quarantine, {\n durable: true,\n arguments: { 'x-queue-type': 'quorum' },\n });\n await channel.bindQueue(quarantine, EVENTS_DLX, queue);\n await channel.assertQueue(queue, {\n durable: true,\n arguments: {\n 'x-queue-type': 'quorum',\n 'x-delivery-limit': subscription.maxAttempts,\n 'x-dead-letter-exchange': EVENTS_DLX,\n 'x-dead-letter-routing-key': queue,\n },\n });\n\n return queue;\n};\n\n/**\n * Whether a failed delivery should go back on the queue.\n *\n * Only a transient failure on a **work** queue: there the broker counts the\n * redelivery against `x-delivery-limit` and dead-letters at the ceiling, so a\n * requeue is bounded. A broadcast queue has neither a ceiling nor a dead-letter\n * path, so requeueing there is an unbounded loop.\n */\n/**\n * Settles a delivery that failed.\n *\n * Poison on a `work` queue is republished to the dead-letter exchange **with\n * its reason** — `x-failure-code` and `x-failure-category` — and then acked,\n * rather than nacked: a nack cannot carry headers, and a quarantine of\n * messages nobody can say anything about is a queue an operator has to replay\n * blind. If that publish is refused the nack still quarantines it, reasonless.\n *\n * A transient failure is nacked for the broker to redeliver; a `broadcast`\n * queue has no quarantine, so its failures are dropped.\n */\nconst settleFailure = (\n channel: Channel,\n message: ConsumeMessage,\n queue: string,\n mode: SubscriptionMode,\n failure: DeliveryFailure,\n): void => {\n if (failure.kind === 'poison' && mode === 'work') {\n try {\n channel.publish(EVENTS_DLX, queue, message.content, {\n ...message.properties,\n persistent: true,\n headers: {\n ...message.properties?.headers,\n 'x-failure-code': failure.code,\n 'x-failure-category': failure.category,\n },\n });\n channel.ack(message);\n return;\n } catch {\n // Fall through: quarantined without its reason beats not quarantined.\n }\n }\n channel.nack(message, false, requeues(failure, mode));\n};\n\nconst requeues = (failure: DeliveryFailure, mode: SubscriptionMode): boolean =>\n failure.kind === 'transient' && mode === 'work';\n\n/**\n * RabbitMQ-backed {@link EventBus}. Events go out as `event` envelopes on the\n * shared topic exchange — the adapter owns the wire framing, so the port stays\n * event-typed.\n *\n * **The event's own name is the routing key**, which is what lets a subscriber\n * bind the exact pattern it declared in `tools/slices/*.slice.ts`.\n *\n * The queue a subscriber binds is its own choice of shape. A `work`\n * subscription gets a named durable queue that replicas share and that keeps\n * accumulating while the consumer is down; a `broadcast` one gets an exclusive\n * queue, where every replica sees every event and nothing\n * survives a restart. Choosing broadcast for a workload that is work is how an\n * event published during a rollout is dropped by the broker while the outbox\n * has already recorded it sent.\n *\n * Both methods go through the {@link AmqpConnector} rather than holding a\n * channel, because a channel does not survive the broker restarting. `publish`\n * asks for a live one per call, and `subscribe` registers its setup so the\n * connector can re-run it against the new channel after a reconnect. A durable\n * queue outlives that reconnect; the consumer bound to it does not.\n *\n * It returns the bus **and** its `drainDeliveries`, rather than only the port:\n * waiting for in-flight handlers is an adapter fact — it is this module that\n * runs them — and putting it on `EventBus` would make every implementation of\n * a framework-free port carry a shutdown concern it has no deliveries to drain.\n */\nexport const makeAmqpEventBus = (\n connector: AmqpConnector,\n wiring: WiringRegistry,\n): { bus: EventBus; drainDeliveries: Effect.Effect<void> } => {\n /**\n * Handlers started and not yet settled.\n *\n * Nothing tracked them before, so a SIGTERM killed them mid-flight and the\n * broker redelivered whatever was unacked — safe only while every consumer\n * happens to be idempotent, which is the assumption the command inbox stops\n * making.\n */\n let inFlight = 0;\n\n const bus: EventBus = {\n publish: event =>\n Effect.tryPromise({\n try: () =>\n connector.withChannel(async channel => {\n channel.publish(\n EVENTS_EXCHANGE,\n event.name,\n Buffer.from(JSON.stringify(makeEventEnvelope(event))),\n { persistent: true },\n );\n }),\n catch: error => new Unavailable({ dependency: 'amqp' }),\n // Recorded **after** the publish succeeds, so `GET /api/$service` reports\n // what this process actually put on the exchange. Recording the intent\n // instead would make the declared-vs-observed diff pass on a service whose\n // every publish is failing.\n }).pipe(\n Effect.tap(() =>\n wiring\n .recordPublish(event.name)\n .pipe(Effect.andThen(recordPublished(event.name))),\n ),\n // Counted here and not in the `catch` above, which only builds the\n // error: a rising publish-failure rate against a flat published rate\n // is what a broker outage looks like from the publisher's side, and\n // the outbox's own backlog is the confirmation.\n Effect.tapError(() => recordPublishFailure(event.name)),\n ),\n\n subscribe: (subscription, handler) =>\n Effect.tryPromise({\n try: () =>\n connector.addConsumer(async channel => {\n // One unacked message at a time: events for a transaction are then\n // folded serially (never concurrently), so an `accepted`/`completed`\n // pair can't race into two upserts.\n await channel.prefetch(1);\n const queue = await declareQueue(channel, subscription);\n await channel.bindQueue(\n queue,\n EVENTS_EXCHANGE,\n subscription.pattern,\n );\n // What the process **bound**, recorded at the point it bound it. The\n // registry deduplicates, so re-running this setup after a reconnect\n // does not double the entry.\n await Effect.runPromise(\n wiring.recordSubscription({\n slice: subscription.slice,\n pattern: subscription.pattern,\n mode: subscription.mode,\n queue,\n }),\n );\n // Resolved once per binding rather than per delivery: `queue` is the\n // durable name for a `work` subscription and the broker's generated\n // one for a `broadcast` queue, so it is only knowable here.\n const tags: SubscriptionTags = {\n queue,\n slice: subscription.slice,\n mode: subscription.mode,\n };\n const { consumerTag } = await channel.consume(\n queue,\n (message: ConsumeMessage | null) => {\n if (message === null) {\n return;\n }\n\n // Reading the payload and running the handler are separated on\n // purpose: they fail for different reasons and earn different\n // treatment. `JSON.parse` is inside the Effect because it throws\n // *synchronously* — outside, it escaped into amqplib's callback and\n // the message was never even nacked.\n const settle = Effect.try({\n try: () => JSON.parse(message.content.toString()) as unknown,\n catch: (): DeliveryFailure => UNREADABLE,\n }).pipe(\n Effect.flatMap(parsed =>\n readEventEnvelope(parsed).pipe(Effect.mapError(classify)),\n ),\n // The handler carries no requirements (the subscriber closes over\n // its store), so it runs standalone.\n //\n // ⚠️ A handler's failure is classified by its category, not\n // treated as transient across the board. The transport\n // validates `meta` and deliberately not `data` (inventing an\n // opinion about a payload is how the bus would start knowing\n // about domains), so a consumer that decodes its own payload\n // is the *only* thing that can tell a malformed one from a bad\n // afternoon at the database — and it says so by failing with\n // a business or `protocol` failure, which quarantines at once\n // instead of spending `x-delivery-limit` on every message\n // behind it. Measured on `catalog.published`: a payload the\n // contract rejected used to take five deliveries to quarantine.\n Effect.flatMap(event =>\n handler(event).pipe(Effect.mapError(classify)),\n ),\n );\n\n // Counted around the whole settle, so a shutdown waits for the ack\n // as well as the handler: cancelling a consumer stops *new*\n // deliveries and says nothing about this one.\n inFlight += 1;\n void Effect.runPromise(\n settle.pipe(\n // Effect's metric registry is a process-wide singleton, so an\n // increment inside this detached `runPromise` reaches the same\n // producer `NodeSdk` exports from. That is the only reason a\n // metric works here at all — nothing about this fiber is\n // inside the observability layer.\n Effect.matchEffect({\n onSuccess: () =>\n recordConsumed(tags).pipe(\n Effect.andThen(\n Effect.sync(() => channel.ack(message)),\n ),\n ),\n onFailure: failure =>\n recordFailed(tags, failure.kind).pipe(\n Effect.andThen(\n Effect.sync(() =>\n settleFailure(\n channel,\n message,\n queue,\n subscription.mode,\n failure,\n ),\n ),\n ),\n ),\n }),\n ),\n ).finally(() => {\n inFlight -= 1;\n });\n },\n );\n return [consumerTag];\n }),\n catch: error => new Unavailable({ dependency: 'amqp' }),\n }),\n };\n\n return {\n bus,\n drainDeliveries: Effect.gen(function* () {\n while (inFlight > 0) {\n yield* Effect.sleep(DELIVERY_POLL);\n }\n }),\n };\n};\n\n/**\n * Provides {@link EventBusTag} from an {@link AmqpChannelTag}.\n *\n * It also takes {@link WiringRegistryTag}, which `makeServerLayer` provides once\n * per service — so the bus and `GET /api/$service` share one instance, and the\n * document reports the bindings this very process made.\n */\nexport const AmqpEventBusLayer = Layer.effect(\n EventBusTag,\n Effect.gen(function* () {\n const connector = yield* AmqpChannelTag;\n const wiring = yield* WiringRegistryTag;\n const shutdown = yield* ShutdownRegistryTag;\n const { bus, drainDeliveries } = makeAmqpEventBus(connector, wiring);\n\n // `stop-intake`, and both halves are it: cancelling stops the broker\n // handing over anything new, then the drain waits for the deliveries\n // already in progress. Registered here rather than in a service's `main.ts`\n // for the reason every registry in this repo exists — a service that gains\n // a bus gains its drain, with nothing to remember.\n yield* shutdown.register({\n name: 'amqp-consumers',\n phase: 'stop-intake',\n run: Effect.promise(() => connector.cancelConsumers()).pipe(\n Effect.andThen(drainDeliveries),\n ),\n });\n\n return bus;\n }),\n);\n"],"names":["ShutdownRegistryTag","WiringRegistryTag","isFailure","makeEventEnvelope","readEventEnvelope","Unavailable","EventBusTag","Duration","Effect","Layer","AmqpChannelTag","EVENTS_DLX","EVENTS_EXCHANGE","recordConsumed","recordFailed","recordPublished","recordPublishFailure","QUARANTINE_SUFFIX","DELIVERY_POLL","millis","classify","error","kind","family","category","code","UNREADABLE","queueNameFor","subscription","slice","pattern","replaceAll","declareQueue","channel","mode","queue","assertQueue","exclusive","quarantine","durable","arguments","bindQueue","maxAttempts","settleFailure","message","failure","publish","content","properties","persistent","headers","ack","nack","requeues","makeAmqpEventBus","connector","wiring","inFlight","bus","event","tryPromise","try","withChannel","name","Buffer","from","JSON","stringify","catch","dependency","pipe","tap","recordPublish","andThen","tapError","subscribe","handler","addConsumer","prefetch","runPromise","recordSubscription","tags","consumerTag","consume","settle","parse","toString","flatMap","parsed","mapError","matchEffect","onSuccess","sync","onFailure","finally","drainDeliveries","gen","sleep","AmqpEventBusLayer","effect","shutdown","register","phase","run","promise","cancelConsumers"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/adapters/amqp-event-bus.ts"],"sourcesContent":["import type { WiringRegistry } from '@entifix/business';\nimport { ShutdownRegistryTag, WiringRegistryTag } from '@entifix/business';\nimport {\n isFailure,\n makeEventEnvelope,\n readEventEnvelope,\n Unavailable,\n} from '@entifix/core';\nimport {\n type EventBus,\n EventBusTag,\n type Subscription,\n} 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 publish: event =>\n Effect.tryPromise({\n try: () =>\n connector.withChannel(async channel => {\n channel.publish(\n EVENTS_EXCHANGE,\n event.name,\n Buffer.from(JSON.stringify(makeEventEnvelope(event))),\n { persistent: true },\n );\n }),\n catch: error => new Unavailable({ dependency: 'amqp' }),\n // Recorded **after** the publish succeeds, so `GET /api/$service` reports\n // what this process actually put on the exchange. Recording the intent\n // instead would make the declared-vs-observed diff pass on a service whose\n // every publish is failing.\n }).pipe(\n Effect.tap(() =>\n wiring\n .recordPublish(event.name)\n .pipe(Effect.andThen(recordPublished(event.name))),\n ),\n // Counted here and not in the `catch` above, which only builds the\n // error: a rising publish-failure rate against a flat published rate\n // is what a broker outage looks like from the publisher's side, and\n // the outbox's own backlog is the confirmation.\n Effect.tapError(() => recordPublishFailure(event.name)),\n ),\n\n subscribe: (subscription, handler) =>\n Effect.tryPromise({\n try: () =>\n connector.addConsumer(async channel => {\n // One unacked message at a time: events for a transaction are then\n // folded serially (never concurrently), so an `accepted`/`completed`\n // pair can't race into two upserts.\n await channel.prefetch(1);\n const queue = await declareQueue(channel, subscription);\n await channel.bindQueue(\n queue,\n EVENTS_EXCHANGE,\n subscription.pattern,\n );\n // What the process **bound**, recorded at the point it bound it. The\n // registry deduplicates, so re-running this setup after a reconnect\n // does not double the entry.\n await Effect.runPromise(\n wiring.recordSubscription({\n slice: subscription.slice,\n pattern: subscription.pattern,\n mode: subscription.mode,\n queue,\n }),\n );\n // Resolved once per binding rather than per delivery: `queue` is the\n // durable name for a `work` subscription and the broker's generated\n // one for a `broadcast` queue, so it is only knowable here.\n const tags: SubscriptionTags = {\n queue,\n slice: subscription.slice,\n mode: subscription.mode,\n };\n const { consumerTag } = await channel.consume(\n queue,\n (message: ConsumeMessage | null) => {\n if (message === null) {\n return;\n }\n\n // Reading the payload and running the handler are separated on\n // purpose: they fail for different reasons and earn different\n // treatment. `JSON.parse` is inside the Effect because it throws\n // *synchronously* — outside, it escaped into amqplib's callback and\n // the message was never even nacked.\n const settle = Effect.try({\n try: () => JSON.parse(message.content.toString()) as unknown,\n catch: (): DeliveryFailure => UNREADABLE,\n }).pipe(\n Effect.flatMap(parsed =>\n readEventEnvelope(parsed).pipe(Effect.mapError(classify)),\n ),\n // The handler carries no requirements (the subscriber closes over\n // its store), so it runs standalone.\n //\n // ⚠️ A handler's failure is classified by its category, not\n // treated as transient across the board. The transport\n // validates `meta` and deliberately not `data` (inventing an\n // opinion about a payload is how the bus would start knowing\n // about domains), so a consumer that decodes its own payload\n // is the *only* thing that can tell a malformed one from a bad\n // afternoon at the database — and it says so by failing with\n // a business or `protocol` failure, which quarantines at once\n // instead of spending `maxAttempts` on every message\n // behind it. Measured on `catalog.published`: a payload the\n // contract rejected used to take five deliveries to quarantine.\n Effect.flatMap(event =>\n handler(event).pipe(Effect.mapError(classify)),\n ),\n );\n\n // Counted around the whole settle, so a shutdown waits for the ack\n // as well as the handler: cancelling a consumer stops *new*\n // deliveries and says nothing about this one.\n inFlight += 1;\n void Effect.runPromise(\n settle.pipe(\n // Effect's metric registry is a process-wide singleton, so an\n // increment inside this detached `runPromise` reaches the same\n // producer `NodeSdk` exports from. That is the only reason a\n // metric works here at all — nothing about this fiber is\n // inside the observability layer.\n Effect.matchEffect({\n onSuccess: () =>\n recordConsumed(tags).pipe(\n Effect.andThen(\n Effect.sync(() => channel.ack(message)),\n ),\n ),\n onFailure: failure =>\n recordFailed(tags, failure.kind).pipe(\n Effect.andThen(\n 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","isFailure","makeEventEnvelope","readEventEnvelope","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","tryPromise","try","withChannel","name","Buffer","from","JSON","stringify","catch","dependency","tap","recordPublish","tapError","subscribe","handler","addConsumer","prefetch","runPromise","recordSubscription","consumerTag","consume","settle","parse","toString","flatMap","parsed","mapError","matchEffect","onSuccess","onFailure","finally","drainDeliveries","gen","AmqpEventBusLayer","effect","shutdown","serviceOption","getOrUndefined","register","phase","run","promise","cancelConsumers"],"mappings":"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"}
@@ -31,17 +31,26 @@ export declare const busPublishFailures: Metric.Counter<number>;
31
31
  /** Deliveries whose handler completed and were acked, by subscription. */
32
32
  export declare const busEventsConsumed: Metric.Counter<number>;
33
33
  /**
34
- * Deliveries that were nacked, by subscription and failure class.
34
+ * Deliveries that failed, by subscription and failure class.
35
35
  *
36
- * ⚠️ **This is not a dead-letter count, and cannot be.** `x-delivery-limit` is
37
- * what moves a message to `<queue>.quarantine`, and the broker does that without
38
- * telling the adapter — there is no in-process call site for it. A transient
39
- * failure on its fifth delivery is counted here exactly like its first. The
40
- * honest quarantine count on the publisher side is the outbox's; on this side,
41
- * a rising `failure="transient"` rate against a fixed `maxAttempts` is the
42
- * signal, and the quarantine queue's own depth is the confirmation.
36
+ * Every failed delivery, including the ones that go on to be retried, so a
37
+ * transient failure on its fifth attempt is counted here exactly like its
38
+ * first. How many messages were finally given up on is
39
+ * {@link busEventsQuarantined}.
43
40
  */
44
41
  export declare const busEventsFailed: Metric.Counter<number>;
42
+ /**
43
+ * Messages moved to `<queue>.quarantine`, by subscription and failure category.
44
+ *
45
+ * Possible since the adapter, not the broker, decides the ceiling (#130): every
46
+ * quarantine now goes through one call site in the adapter, so this is the
47
+ * dead-letter count `bus_events_failed_total` could never be. A poison message
48
+ * is counted on its first delivery, and a transient one when it has spent its
49
+ * subscription's `maxAttempts`. A redelivery the broker dead-letters after a
50
+ * consumer crash (`x-delivery-limit`) still bypasses the adapter, so the
51
+ * quarantine queue's depth stays the confirmation.
52
+ */
53
+ export declare const busEventsQuarantined: Metric.Counter<number>;
45
54
  /**
46
55
  * Identity of a subscription, as metric tags.
47
56
  *
@@ -70,4 +79,12 @@ export declare const recordConsumed: (tags: SubscriptionTags) => import("effect/
70
79
  * incomparable.
71
80
  */
72
81
  export declare const recordFailed: (tags: SubscriptionTags, failure: "transient" | "poison") => import("effect/Effect").Effect<void, never, never>;
82
+ /**
83
+ * Record one message moved to its quarantine queue.
84
+ *
85
+ * `category` rather than `code`: the categories are a closed set and the codes
86
+ * are not, and a metric attribute with an open set of values is a series per
87
+ * typo.
88
+ */
89
+ export declare const recordQuarantined: (tags: SubscriptionTags, category: string) => import("effect/Effect").Effect<void, never, never>;
73
90
  //# 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;;;;;;;;;;GAUG;AACH,eAAO,MAAM,eAAe,wBAI1B,CAAC;AAEH;;;;;;;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,uDAK9B,CAAC"}
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,uDAK9B,CAAC;AAEJ;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAI,MAAM,gBAAgB,EAAE,UAAU,MAAM,uDAMvE,CAAC"}
@@ -31,17 +31,27 @@ import { Metric } from 'effect';
31
31
  description: 'Deliveries acked after their handler completed.'
32
32
  });
33
33
  /**
34
- * Deliveries that were nacked, by subscription and failure class.
34
+ * Deliveries that failed, by subscription and failure class.
35
35
  *
36
- * ⚠️ **This is not a dead-letter count, and cannot be.** `x-delivery-limit` is
37
- * what moves a message to `<queue>.quarantine`, and the broker does that without
38
- * telling the adapter — there is no in-process call site for it. A transient
39
- * failure on its fifth delivery is counted here exactly like its first. The
40
- * honest quarantine count on the publisher side is the outbox's; on this side,
41
- * a rising `failure="transient"` rate against a fixed `maxAttempts` is the
42
- * signal, and the quarantine queue's own depth is the confirmation.
36
+ * Every failed delivery, including the ones that go on to be retried, so a
37
+ * transient failure on its fifth attempt is counted here exactly like its
38
+ * first. How many messages were finally given up on is
39
+ * {@link busEventsQuarantined}.
43
40
  */ export const busEventsFailed = Metric.counter('bus_events_failed_total', {
44
- description: 'Deliveries nacked, by subscription and failure class. Not a ' + 'dead-letter count — the broker dead-letters without telling the adapter.'
41
+ description: 'Deliveries whose handler or payload failed, by subscription and ' + 'failure class, retried or not.'
42
+ });
43
+ /**
44
+ * Messages moved to `<queue>.quarantine`, by subscription and failure category.
45
+ *
46
+ * Possible since the adapter, not the broker, decides the ceiling (#130): every
47
+ * quarantine now goes through one call site in the adapter, so this is the
48
+ * dead-letter count `bus_events_failed_total` could never be. A poison message
49
+ * is counted on its first delivery, and a transient one when it has spent its
50
+ * subscription's `maxAttempts`. A redelivery the broker dead-letters after a
51
+ * consumer crash (`x-delivery-limit`) still bypasses the adapter, so the
52
+ * quarantine queue's depth stays the confirmation.
53
+ */ export const busEventsQuarantined = Metric.counter('bus_events_quarantined_total', {
54
+ description: 'Messages the adapter moved to a quarantine queue, by subscription ' + 'and failure category.'
45
55
  });
46
56
  const withSubscription = (metric, tags)=>metric.pipe(Metric.withAttributes({
47
57
  queue: tags.queue
@@ -67,5 +77,14 @@ const withSubscription = (metric, tags)=>metric.pipe(Metric.withAttributes({
67
77
  */ export const recordFailed = (tags, failure)=>Metric.update(Metric.withAttributes(withSubscription(busEventsFailed, tags), {
68
78
  failure
69
79
  }), 1);
80
+ /**
81
+ * Record one message moved to its quarantine queue.
82
+ *
83
+ * `category` rather than `code`: the categories are a closed set and the codes
84
+ * are not, and a metric attribute with an open set of values is a series per
85
+ * typo.
86
+ */ export const recordQuarantined = (tags, category)=>Metric.update(Metric.withAttributes(withSubscription(busEventsQuarantined, tags), {
87
+ category
88
+ }), 1);
70
89
 
71
90
  //# 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 were nacked, by subscription and failure class.\n *\n * ⚠️ **This is not a dead-letter count, and cannot be.** `x-delivery-limit` is\n * what moves a message to `<queue>.quarantine`, and the broker does that without\n * telling the adapter — there is no in-process call site for it. A transient\n * failure on its fifth delivery is counted here exactly like its first. The\n * honest quarantine count on the publisher side is the outbox's; on this side,\n * a rising `failure=\"transient\"` rate against a fixed `maxAttempts` is the\n * signal, and the quarantine queue's own depth is the confirmation.\n */\nexport const busEventsFailed = Metric.counter('bus_events_failed_total', {\n description:\n 'Deliveries nacked, by subscription and failure class. Not a ' +\n 'dead-letter count — the broker dead-letters without telling the adapter.',\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"],"names":["Metric","busEventsPublished","counter","description","busPublishFailures","busEventsConsumed","busEventsFailed","withSubscription","metric","tags","pipe","withAttributes","queue","slice","mode","recordPublished","eventName","update","event","recordPublishFailure","recordConsumed","recordFailed","failure"],"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;;;;;;;;;;CAUC,GACD,OAAO,MAAMG,kBAAkBN,OAAOE,OAAO,CAAC,2BAA2B;IACvEC,aACE,iEACA;AACJ,GAAG;AAgBH,MAAMI,mBAAmB,CACvBC,QACAC,OAEAD,OAAOE,IAAI,CACTV,OAAOW,cAAc,CAAC;QAAEC,OAAOH,KAAKG,KAAK;IAAC,IAC1CZ,OAAOW,cAAc,CAAC;QAAEE,OAAOJ,KAAKI,KAAK;IAAC,IAC1Cb,OAAOW,cAAc,CAAC;QAAEG,MAAML,KAAKK,IAAI;IAAC;AAG5C,6CAA6C,GAC7C,OAAO,MAAMC,kBAAkB,CAACC,YAC9BhB,OAAOiB,MAAM,CACXjB,OAAOW,cAAc,CAACV,oBAAoB;QAAEiB,OAAOF;IAAU,IAC7D,GACA;AAEJ,2CAA2C,GAC3C,OAAO,MAAMG,uBAAuB,CAACH,YACnChB,OAAOiB,MAAM,CACXjB,OAAOW,cAAc,CAACP,oBAAoB;QAAEc,OAAOF;IAAU,IAC7D,GACA;AAEJ,2DAA2D,GAC3D,OAAO,MAAMI,iBAAiB,CAACX,OAC7BT,OAAOiB,MAAM,CAACV,iBAAiBF,mBAAmBI,OAAO,GAAG;AAE9D;;;;;;;CAOC,GACD,OAAO,MAAMY,eAAe,CAC1BZ,MACAa,UAEAtB,OAAOiB,MAAM,CACXjB,OAAOW,cAAc,CAACJ,iBAAiBD,iBAAiBG,OAAO;QAAEa;IAAQ,IACzE,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',\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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entifix/amqp",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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.10.0",
61
- "@entifix/core": "0.10.0"
60
+ "@entifix/business": "0.10.2",
61
+ "@entifix/core": "0.10.2"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@types/amqplib": "0.10.8",
65
65
  "effect": "4.0.6",
66
- "@entifix/transactions": "0.10.0",
67
- "@entifix/testing-unit": "0.10.0"
66
+ "@entifix/transactions": "0.10.2",
67
+ "@entifix/testing-unit": "0.10.2"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "effect": "^4.0.6",
71
- "@entifix/transactions": "0.10.0"
71
+ "@entifix/transactions": "0.10.2"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@entifix/transactions": {