@kronos-ts/rabbitmq 0.2.1 → 0.3.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.
@@ -0,0 +1,286 @@
1
+ import type { Channel, ConsumeMessage } from "amqplib"
2
+ import type { AmqpConnection } from "./connection.js"
3
+ import type { RabbitMqResolvedConfig } from "./rabbitmq.js"
4
+
5
+ /**
6
+ * A subscription known to the cluster — either owned locally on this instance
7
+ * or owned by another instance and learned over gossip.
8
+ */
9
+ export interface SubscriberRecord {
10
+ readonly subId: string
11
+ readonly queryName: string
12
+ readonly payload: unknown
13
+ readonly ownerInstanceId: string
14
+ }
15
+
16
+ export interface SerializedError {
17
+ readonly name?: string
18
+ readonly message: string
19
+ readonly stack?: string
20
+ }
21
+
22
+ /**
23
+ * Targeted message delivered to the owner of a specific subscription. The
24
+ * owner applies it locally against the buffered subscriber stream.
25
+ */
26
+ export type DeliverEnvelope =
27
+ | { readonly kind: "update"; readonly subId: string; readonly update: unknown }
28
+ | { readonly kind: "complete"; readonly subId: string }
29
+ | {
30
+ readonly kind: "completeExceptionally"
31
+ readonly subId: string
32
+ readonly error: SerializedError
33
+ }
34
+
35
+ /**
36
+ * Gossip envelopes broadcast to every instance over the fanout exchange.
37
+ *
38
+ * `claim`/`release` keep the cluster-wide subscriber mirror in sync. `sync`
39
+ * announces a fresh instance and prompts peers to re-emit their owned claims
40
+ * so the joiner can bootstrap its mirror.
41
+ *
42
+ * Every envelope carries the publisher's `ownerInstanceId` so receivers can
43
+ * drop their own loopback (the local mirror was already updated synchronously
44
+ * on the publish path).
45
+ */
46
+ export type GossipEnvelope =
47
+ | {
48
+ readonly kind: "claim"
49
+ readonly ownerInstanceId: string
50
+ readonly subId: string
51
+ readonly queryName: string
52
+ readonly payload: unknown
53
+ }
54
+ | { readonly kind: "release"; readonly ownerInstanceId: string; readonly subId: string }
55
+ | { readonly kind: "syncRequest"; readonly requesterId: string }
56
+
57
+ export interface DistributedSubscriberRegistry {
58
+ /** Stable identifier for this process. */
59
+ readonly instanceId: string
60
+
61
+ /** Add (or overwrite) a locally-owned subscriber and broadcast the claim. */
62
+ claim(record: Omit<SubscriberRecord, "ownerInstanceId">): Promise<void>
63
+
64
+ /** Remove a locally-owned subscriber and broadcast the release. */
65
+ release(subId: string): Promise<void>
66
+
67
+ /** Iterate every record in the cluster-wide mirror (locally owned + remote). */
68
+ records(): IterableIterator<SubscriberRecord>
69
+
70
+ /**
71
+ * Route a delivery to the owner of `subId`. Local owners are dispatched
72
+ * synchronously to the in-process handler; remote owners receive a direct-
73
+ * queue publish keyed by their instanceId.
74
+ */
75
+ deliver(envelope: DeliverEnvelope): Promise<void>
76
+
77
+ /** Set the handler invoked when a `DeliverEnvelope` for a local sub arrives. */
78
+ setDeliverHandler(handler: (envelope: DeliverEnvelope) => void): void
79
+
80
+ connect(): Promise<void>
81
+ close(): Promise<void>
82
+ }
83
+
84
+ /**
85
+ * AMQP-backed implementation.
86
+ *
87
+ * Topology:
88
+ *
89
+ * - `<prefix>.subscribers.gossip` — fanout exchange. Every instance owns an
90
+ * exclusive auto-delete queue bound to it. Carries claim / release /
91
+ * syncRequest messages.
92
+ *
93
+ * - `<prefix>.subscribers.direct` — direct exchange. Every instance owns an
94
+ * exclusive auto-delete queue bound by routing key equal to its
95
+ * `instanceId`. Carries DeliverEnvelope messages targeted at the owner.
96
+ *
97
+ * Consume mode is no-ack on both queues — the registry is a best-effort eventual
98
+ * mirror. A dropped claim is healed by the next sync request; a dropped deliver
99
+ * looks like a missed update, the same failure mode the broker-routed model has
100
+ * when a stream segment is lost.
101
+ *
102
+ * Loopback dedup: the publisher applies its own claim/release to the local
103
+ * mirror synchronously before publishing, and ignores its own envelopes on the
104
+ * inbound side via `ownerInstanceId === instanceId`.
105
+ *
106
+ * Joiner protocol: on connect the new instance publishes a `syncRequest`.
107
+ * Existing peers respond by re-broadcasting each of their owned claims over
108
+ * the same fanout exchange. New instance fills its mirror as they arrive; in
109
+ * the meantime emits routed to subs the joiner has yet to learn about are
110
+ * dropped on the joiner side — by design, since the joiner can't have any of
111
+ * its own subscribers yet either. The window self-closes as the mirror fills.
112
+ */
113
+ export class AmqpDistributedSubscriberRegistry implements DistributedSubscriberRegistry {
114
+ private channel: Channel | undefined
115
+ private connectPromise: Promise<void> | undefined
116
+ private closed = false
117
+ private deliverHandler: ((envelope: DeliverEnvelope) => void) | undefined
118
+ private readonly mirror = new Map<string, SubscriberRecord>()
119
+ private readonly locallyOwnedSubIds = new Set<string>()
120
+
121
+ readonly instanceId: string
122
+
123
+ constructor(
124
+ private readonly config: RabbitMqResolvedConfig,
125
+ private readonly connection: AmqpConnection,
126
+ ) {
127
+ this.instanceId = `${config.identity.serviceName}.${config.identity.instanceId}`
128
+ }
129
+
130
+ async connect(): Promise<void> {
131
+ if (this.connectPromise) return this.connectPromise
132
+ this.connectPromise = this.doConnect()
133
+ return this.connectPromise
134
+ }
135
+
136
+ private async doConnect(): Promise<void> {
137
+ this.channel = await this.connection.channel()
138
+ const ch = this.channel
139
+
140
+ await ch.assertExchange(this.config.topology.subscribersGossipExchange, "fanout", {
141
+ durable: true,
142
+ })
143
+ await ch.assertExchange(this.config.topology.subscribersDirectExchange, "direct", {
144
+ durable: true,
145
+ })
146
+
147
+ const gossipQueue = this.config.topology.subscribersGossipQueue()
148
+ const directQueue = this.config.topology.subscribersDirectQueue()
149
+
150
+ await ch.assertQueue(gossipQueue, { durable: false, exclusive: true, autoDelete: true })
151
+ await ch.assertQueue(directQueue, { durable: false, exclusive: true, autoDelete: true })
152
+
153
+ await ch.bindQueue(gossipQueue, this.config.topology.subscribersGossipExchange, "")
154
+ await ch.bindQueue(directQueue, this.config.topology.subscribersDirectExchange, this.instanceId)
155
+
156
+ await ch.consume(gossipQueue, (msg) => this.handleGossip(msg), { noAck: true })
157
+ await ch.consume(directQueue, (msg) => this.handleDirect(msg), { noAck: true })
158
+
159
+ // Announce ourselves so existing peers re-broadcast their owned claims.
160
+ this.publishGossip({ kind: "syncRequest", requesterId: this.instanceId })
161
+ }
162
+
163
+ async close(): Promise<void> {
164
+ this.closed = true
165
+ await this.channel?.close().catch(() => {})
166
+ }
167
+
168
+ async claim(record: Omit<SubscriberRecord, "ownerInstanceId">): Promise<void> {
169
+ const full: SubscriberRecord = { ...record, ownerInstanceId: this.instanceId }
170
+ this.mirror.set(full.subId, full)
171
+ this.locallyOwnedSubIds.add(full.subId)
172
+ await this.connect()
173
+ if (this.closed) return
174
+ this.publishGossip({
175
+ kind: "claim",
176
+ ownerInstanceId: this.instanceId,
177
+ subId: full.subId,
178
+ queryName: full.queryName,
179
+ payload: full.payload,
180
+ })
181
+ }
182
+
183
+ async release(subId: string): Promise<void> {
184
+ this.mirror.delete(subId)
185
+ this.locallyOwnedSubIds.delete(subId)
186
+ await this.connect()
187
+ if (this.closed) return
188
+ this.publishGossip({ kind: "release", ownerInstanceId: this.instanceId, subId })
189
+ }
190
+
191
+ *records(): IterableIterator<SubscriberRecord> {
192
+ for (const record of this.mirror.values()) yield record
193
+ }
194
+
195
+ async deliver(envelope: DeliverEnvelope): Promise<void> {
196
+ const record = this.mirror.get(envelope.subId)
197
+ if (!record) return
198
+
199
+ if (record.ownerInstanceId === this.instanceId) {
200
+ this.deliverHandler?.(envelope)
201
+ return
202
+ }
203
+
204
+ await this.connect()
205
+ if (this.closed) return
206
+ const ch = this.requireChannel()
207
+ ch.publish(
208
+ this.config.topology.subscribersDirectExchange,
209
+ record.ownerInstanceId,
210
+ Buffer.from(JSON.stringify(envelope)),
211
+ { contentType: "application/json", persistent: false },
212
+ )
213
+ }
214
+
215
+ setDeliverHandler(handler: (envelope: DeliverEnvelope) => void): void {
216
+ this.deliverHandler = handler
217
+ }
218
+
219
+ private publishGossip(envelope: GossipEnvelope): void {
220
+ if (this.closed) return
221
+ const ch = this.channel
222
+ if (!ch) return
223
+ ch.publish(
224
+ this.config.topology.subscribersGossipExchange,
225
+ "",
226
+ Buffer.from(JSON.stringify(envelope)),
227
+ { contentType: "application/json", persistent: false },
228
+ )
229
+ }
230
+
231
+ private handleGossip(msg: ConsumeMessage | null): void {
232
+ if (!msg) return
233
+ let envelope: GossipEnvelope
234
+ try {
235
+ envelope = JSON.parse(msg.content.toString("utf8")) as GossipEnvelope
236
+ } catch {
237
+ return
238
+ }
239
+
240
+ if (envelope.kind === "claim") {
241
+ // Loopback — local mirror already updated synchronously by claim().
242
+ if (envelope.ownerInstanceId === this.instanceId) return
243
+ this.mirror.set(envelope.subId, {
244
+ subId: envelope.subId,
245
+ queryName: envelope.queryName,
246
+ payload: envelope.payload,
247
+ ownerInstanceId: envelope.ownerInstanceId,
248
+ })
249
+ } else if (envelope.kind === "release") {
250
+ if (envelope.ownerInstanceId === this.instanceId) return
251
+ this.mirror.delete(envelope.subId)
252
+ } else if (envelope.kind === "syncRequest") {
253
+ // Skip our own announcement; respond to every other instance by
254
+ // re-broadcasting our owned claims over the same fanout exchange.
255
+ if (envelope.requesterId === this.instanceId) return
256
+ for (const subId of this.locallyOwnedSubIds) {
257
+ const record = this.mirror.get(subId)
258
+ if (!record) continue
259
+ this.publishGossip({
260
+ kind: "claim",
261
+ ownerInstanceId: this.instanceId,
262
+ subId: record.subId,
263
+ queryName: record.queryName,
264
+ payload: record.payload,
265
+ })
266
+ }
267
+ }
268
+ }
269
+
270
+ private handleDirect(msg: ConsumeMessage | null): void {
271
+ if (!msg) return
272
+ if (!this.deliverHandler) return
273
+ let envelope: DeliverEnvelope
274
+ try {
275
+ envelope = JSON.parse(msg.content.toString("utf8")) as DeliverEnvelope
276
+ } catch {
277
+ return
278
+ }
279
+ this.deliverHandler(envelope)
280
+ }
281
+
282
+ private requireChannel(): Channel {
283
+ if (!this.channel) throw new Error("Distributed subscriber registry is not connected")
284
+ return this.channel
285
+ }
286
+ }
package/src/index.ts CHANGED
@@ -30,6 +30,14 @@ export {
30
30
  type RabbitMqQueryBusOptions,
31
31
  } from "./query-bus.js"
32
32
 
33
+ export {
34
+ AmqpDistributedSubscriberRegistry,
35
+ type DistributedSubscriberRegistry,
36
+ type SubscriberRecord,
37
+ type DeliverEnvelope,
38
+ type GossipEnvelope,
39
+ } from "./distributed-subscriber-registry.js"
40
+
33
41
  export { AmqpRabbitMqCommandTransport } from "./amqp-command-transport.js"
34
42
  export { AmqpRabbitMqQueryTransport } from "./amqp-query-transport.js"
35
43
 
package/src/query-bus.ts CHANGED
@@ -1,7 +1,22 @@
1
- import type { QueryBus, QueryMessage, SubscriptionQueryResult } from "@kronos-ts/messaging"
1
+ import type {
2
+ QueryBus,
3
+ QueryMessage,
4
+ SubscriptionFilter,
5
+ SubscriptionQueryResult,
6
+ UpdateHandler,
7
+ } from "@kronos-ts/messaging"
8
+ import {
9
+ applySubscriptionFilter,
10
+ createUpdateHandler,
11
+ runAfterCommitOrImmediately,
12
+ runInNewUoW,
13
+ } from "@kronos-ts/messaging"
2
14
  import { qualifiedNameToString } from "@kronos-ts/common"
3
- import { runInNewUoW } from "@kronos-ts/messaging"
4
15
  import type { RabbitMqResolvedConfig } from "./rabbitmq.js"
16
+ import type {
17
+ DeliverEnvelope,
18
+ DistributedSubscriberRegistry,
19
+ } from "./distributed-subscriber-registry.js"
5
20
 
6
21
  export interface RabbitMqQueryEnvelope {
7
22
  readonly kind: "query"
@@ -32,23 +47,105 @@ export interface RabbitMqQueryTransport {
32
47
  export interface RabbitMqQueryBusOptions {
33
48
  readonly localSegment: QueryBus
34
49
  readonly transport: RabbitMqQueryTransport
50
+ readonly subscriberRegistry?: DistributedSubscriberRegistry
35
51
  readonly config: RabbitMqResolvedConfig
36
52
  }
37
53
 
38
54
  /**
39
55
  * Distributed query bus decorator.
40
56
  *
41
- * Direct request/reply queries (`query` + `subscribe`) route over RabbitMQ.
42
- * Subscription queries (`subscriptionQuery`, `subscribeToUpdates`, `emitUpdate`,
43
- * `completeSubscription*`) remain process-local and delegate to the local
44
- * segment distributing subscription-query update streams over RabbitMQ is
45
- * intentionally out of scope for this version (see rabbitmq-extension-plan.md).
57
+ * Direct request/reply queries (`query` + `subscribe`) route over the
58
+ * request/reply transport.
59
+ *
60
+ * Subscription queries use a distributed-mirror model. Every subscribe
61
+ * publishes a `claim` over the gossip fanout exchange so every instance
62
+ * learns about it; every unsubscribe publishes a `release`. Each instance
63
+ * keeps a cluster-wide `Map<subId, SubscriberRecord>` mirror.
64
+ *
65
+ * `emitUpdate` walks the mirror locally (it holds every cluster-wide
66
+ * subscriber's payload), applies the filter — function predicates work
67
+ * because evaluation happens colocated with the payload, not over the wire —
68
+ * and routes per-subscriber delivery via the registry. Local subs are
69
+ * dispatched in-process; remote subs receive a `DeliverEnvelope` on the
70
+ * owner's direct queue.
71
+ *
72
+ * The same model handles `completeSubscription` and
73
+ * `completeSubscriptionExceptionally`.
74
+ *
75
+ * Falls back to local-only behaviour when no `subscriberRegistry` is supplied.
46
76
  */
47
77
  export function createRabbitMqQueryBus(options: RabbitMqQueryBusOptions): QueryBus {
48
78
  const localHandlers = new Set<string>()
49
- const { localSegment, transport, config } = options
79
+ const { localSegment, transport, subscriberRegistry, config } = options
80
+
81
+ // UpdateHandlers for subs owned BY this instance, keyed by subId.
82
+ const localOwnedHandlers = new Map<string, UpdateHandler>()
83
+
84
+ function applyDelivery(envelope: DeliverEnvelope): void {
85
+ const handler = localOwnedHandlers.get(envelope.subId)
86
+ if (!handler) return
87
+
88
+ if (envelope.kind === "update") {
89
+ if (!handler.active) {
90
+ localOwnedHandlers.delete(envelope.subId)
91
+ return
92
+ }
93
+ const accepted = handler.offer(envelope.update)
94
+ if (!accepted) {
95
+ handler.completeExceptionally(new Error("Subscription query update buffer overflow"))
96
+ localOwnedHandlers.delete(envelope.subId)
97
+ }
98
+ } else if (envelope.kind === "complete") {
99
+ handler.complete()
100
+ localOwnedHandlers.delete(envelope.subId)
101
+ } else if (envelope.kind === "completeExceptionally") {
102
+ const error = Object.assign(new Error(envelope.error.message), {
103
+ name: envelope.error.name ?? "RemoteSubscriptionError",
104
+ })
105
+ handler.completeExceptionally(error)
106
+ localOwnedHandlers.delete(envelope.subId)
107
+ }
108
+ }
109
+
110
+ if (subscriberRegistry) {
111
+ subscriberRegistry.setDeliverHandler(applyDelivery)
112
+ }
113
+
114
+ function registerSubscription(
115
+ message: QueryMessage,
116
+ bufferSize?: number,
117
+ ): UpdateHandler & { iterable: AsyncIterable<unknown> } {
118
+ const subId = message.identifier
119
+ if (localOwnedHandlers.has(subId)) {
120
+ throw new Error(`Subscription query already registered for identifier "${subId}"`)
121
+ }
122
+ const handler = createUpdateHandler(message, bufferSize)
123
+ localOwnedHandlers.set(subId, handler)
124
+
125
+ if (subscriberRegistry) {
126
+ void subscriberRegistry
127
+ .claim({
128
+ subId,
129
+ queryName: qualifiedNameToString(message.name),
130
+ payload: message.payload,
131
+ })
132
+ .catch(() => {})
133
+ }
134
+ return handler
135
+ }
136
+
137
+ function unregisterSubscription(message: QueryMessage): void {
138
+ const subId = message.identifier
139
+ const existing = localOwnedHandlers.get(subId)
140
+ if (!existing) return
141
+ localOwnedHandlers.delete(subId)
142
+ existing.complete()
143
+ if (subscriberRegistry) {
144
+ void subscriberRegistry.release(subId).catch(() => {})
145
+ }
146
+ }
50
147
 
51
- return {
148
+ const bus: QueryBus = {
52
149
  async query(message: QueryMessage): Promise<unknown> {
53
150
  const queryName = qualifiedNameToString(message.name)
54
151
  const preferLocal = config.queries.preferLocalHandlers && !config.queries.alwaysUseDistributedBus
@@ -85,41 +182,125 @@ export function createRabbitMqQueryBus(options: RabbitMqQueryBusOptions): QueryB
85
182
  })
86
183
  },
87
184
 
88
- // Subscription queries stay process-local — see the doc comment above.
89
185
  subscriptionQuery(message: QueryMessage, bufferSize?: number): SubscriptionQueryResult {
90
- return localSegment.subscriptionQuery(message, bufferSize)
186
+ const updateHandler = registerSubscription(message, bufferSize)
187
+ const initialResult = bus.query(message)
188
+ return {
189
+ initialResult,
190
+ updates: updateHandler.iterable,
191
+ close: () => unregisterSubscription(message),
192
+ }
91
193
  },
92
194
 
93
195
  subscribeToUpdates(
94
196
  message: QueryMessage,
95
197
  bufferSize?: number,
96
198
  ): AsyncIterable<unknown> & { close(): void } {
97
- return localSegment.subscribeToUpdates(message, bufferSize)
199
+ const updateHandler = registerSubscription(message, bufferSize)
200
+ return {
201
+ [Symbol.asyncIterator]: () => updateHandler.iterable[Symbol.asyncIterator](),
202
+ close: () => unregisterSubscription(message),
203
+ }
98
204
  },
99
205
 
100
- emitUpdate(
206
+ async emitUpdate(
101
207
  queryName: string,
102
- filter: (queryPayload: unknown) => boolean,
208
+ filter: SubscriptionFilter,
103
209
  update: unknown,
104
210
  ): Promise<void> {
105
- return localSegment.emitUpdate(queryName, filter, update)
211
+ runAfterCommitOrImmediately(() => {
212
+ if (subscriberRegistry) {
213
+ for (const record of subscriberRegistry.records()) {
214
+ if (record.queryName !== queryName) continue
215
+ if (!applySubscriptionFilter(filter, record.payload)) continue
216
+ void subscriberRegistry
217
+ .deliver({ kind: "update", subId: record.subId, update })
218
+ .catch(() => {})
219
+ }
220
+ return
221
+ }
222
+
223
+ // Local-only mode: filter and offer against the local subscriber set.
224
+ for (const [id, handler] of localOwnedHandlers) {
225
+ if (!handler.active) {
226
+ localOwnedHandlers.delete(id)
227
+ continue
228
+ }
229
+ const handlerQueryName = qualifiedNameToString(handler.query.name)
230
+ if (handlerQueryName !== queryName) continue
231
+ if (!applySubscriptionFilter(filter, handler.query.payload)) continue
232
+
233
+ const accepted = handler.offer(update)
234
+ if (!accepted) {
235
+ handler.completeExceptionally(
236
+ new Error("Subscription query update buffer overflow"),
237
+ )
238
+ localOwnedHandlers.delete(id)
239
+ }
240
+ }
241
+ })
106
242
  },
107
243
 
108
- completeSubscription(
244
+ async completeSubscription(
109
245
  queryName: string,
110
- filter?: (queryPayload: unknown) => boolean,
246
+ filter?: SubscriptionFilter,
111
247
  ): Promise<void> {
112
- return localSegment.completeSubscription(queryName, filter)
248
+ runAfterCommitOrImmediately(() => {
249
+ if (subscriberRegistry) {
250
+ for (const record of subscriberRegistry.records()) {
251
+ if (record.queryName !== queryName) continue
252
+ if (filter && !applySubscriptionFilter(filter, record.payload)) continue
253
+ void subscriberRegistry
254
+ .deliver({ kind: "complete", subId: record.subId })
255
+ .catch(() => {})
256
+ }
257
+ return
258
+ }
259
+
260
+ for (const [id, handler] of localOwnedHandlers) {
261
+ const handlerQueryName = qualifiedNameToString(handler.query.name)
262
+ if (handlerQueryName !== queryName) continue
263
+ if (filter && !applySubscriptionFilter(filter, handler.query.payload)) continue
264
+ handler.complete()
265
+ localOwnedHandlers.delete(id)
266
+ }
267
+ })
113
268
  },
114
269
 
115
- completeSubscriptionExceptionally(
270
+ async completeSubscriptionExceptionally(
116
271
  queryName: string,
117
272
  error: Error,
118
- filter?: (queryPayload: unknown) => boolean,
273
+ filter?: SubscriptionFilter,
119
274
  ): Promise<void> {
120
- return localSegment.completeSubscriptionExceptionally(queryName, error, filter)
275
+ runAfterCommitOrImmediately(() => {
276
+ if (subscriberRegistry) {
277
+ const serialized = serializeError(error) ?? { message: String(error) }
278
+ for (const record of subscriberRegistry.records()) {
279
+ if (record.queryName !== queryName) continue
280
+ if (filter && !applySubscriptionFilter(filter, record.payload)) continue
281
+ void subscriberRegistry
282
+ .deliver({
283
+ kind: "completeExceptionally",
284
+ subId: record.subId,
285
+ error: serialized,
286
+ })
287
+ .catch(() => {})
288
+ }
289
+ return
290
+ }
291
+
292
+ for (const [id, handler] of localOwnedHandlers) {
293
+ const handlerQueryName = qualifiedNameToString(handler.query.name)
294
+ if (handlerQueryName !== queryName) continue
295
+ if (filter && !applySubscriptionFilter(filter, handler.query.payload)) continue
296
+ handler.completeExceptionally(error)
297
+ localOwnedHandlers.delete(id)
298
+ }
299
+ })
121
300
  },
122
301
  }
302
+
303
+ return bus
123
304
  }
124
305
 
125
306
  function serializeError(error: unknown): RabbitMqQueryReplyEnvelope["error"] {
package/src/rabbitmq.ts CHANGED
@@ -4,6 +4,7 @@ import { createRabbitMqCommandBus } from "./command-bus.js"
4
4
  import { createRabbitMqQueryBus } from "./query-bus.js"
5
5
  import { AmqpRabbitMqCommandTransport } from "./amqp-command-transport.js"
6
6
  import { AmqpRabbitMqQueryTransport } from "./amqp-query-transport.js"
7
+ import { AmqpDistributedSubscriberRegistry } from "./distributed-subscriber-registry.js"
7
8
  import { createAmqpConnection } from "./connection.js"
8
9
 
9
10
  export interface RabbitMqCommandDispatchConfig {
@@ -73,10 +74,11 @@ export function resolveRabbitMqConfig(app: App, config: RabbitMqExtensionConfig)
73
74
  /**
74
75
  * RabbitMQ distributed messaging extension.
75
76
  *
76
- * Wraps the command and query buses with RabbitMQ-backed request/reply
77
- * transports. Both transports share a single broker connection, each taking
78
- * its own channel. Subscription queries remain process-local distributing
79
- * their update streams is out of scope for this version.
77
+ * Wraps the command and query buses with RabbitMQ-backed transports.
78
+ * Direct request/reply commands and queries share one channel each; a third
79
+ * channel hosts the subscription-query update broadcast (topic exchange plus
80
+ * an exclusive per-instance queue). All three share a single broker
81
+ * connection.
80
82
  */
81
83
  export function rabbitMq(config: RabbitMqExtensionConfig): (app: App) => void {
82
84
  return (app) => {
@@ -84,6 +86,7 @@ export function rabbitMq(config: RabbitMqExtensionConfig): (app: App) => void {
84
86
  const connection = createAmqpConnection(resolved.url)
85
87
  const commandTransport = new AmqpRabbitMqCommandTransport(resolved, connection)
86
88
  const queryTransport = new AmqpRabbitMqQueryTransport(resolved, connection)
89
+ const subscriberRegistry = new AmqpDistributedSubscriberRegistry(resolved, connection)
87
90
 
88
91
  app.decorate("commandBus", (localSegment) =>
89
92
  createRabbitMqCommandBus({
@@ -97,14 +100,17 @@ export function rabbitMq(config: RabbitMqExtensionConfig): (app: App) => void {
97
100
  createRabbitMqQueryBus({
98
101
  localSegment,
99
102
  transport: queryTransport,
103
+ subscriberRegistry,
100
104
  config: resolved,
101
105
  }),
102
106
  )
103
107
 
104
108
  app.onStart("connect", () => commandTransport.connect())
105
109
  app.onStart("connect", () => queryTransport.connect())
110
+ app.onStart("connect", () => subscriberRegistry.connect())
106
111
  app.onStop("connect", () => commandTransport.close())
107
112
  app.onStop("connect", () => queryTransport.close())
113
+ app.onStop("connect", () => subscriberRegistry.close())
108
114
  app.onStop("connect", () => connection.close())
109
115
  }
110
116
  }
package/src/topology.ts CHANGED
@@ -5,16 +5,22 @@ export interface RabbitMqTopologyConfig {
5
5
  readonly prefix?: string
6
6
  readonly commandsExchange?: string
7
7
  readonly queriesExchange?: string
8
+ readonly subscribersGossipExchange?: string
9
+ readonly subscribersDirectExchange?: string
8
10
  readonly durableQueues?: boolean
9
11
  }
10
12
 
11
13
  export interface RabbitMqTopologyNames {
12
14
  readonly commandsExchange: string
13
15
  readonly queriesExchange: string
16
+ readonly subscribersGossipExchange: string
17
+ readonly subscribersDirectExchange: string
14
18
  commandRoutingKey(commandName: QualifiedName | string): string
15
19
  commandQueue(commandName: QualifiedName | string): string
16
20
  queryRoutingKey(queryName: QualifiedName | string): string
17
21
  queryQueue(queryName: QualifiedName | string): string
22
+ subscribersGossipQueue(): string
23
+ subscribersDirectQueue(): string
18
24
  commandReplyQueue(): string
19
25
  queryReplyQueue(): string
20
26
  }
@@ -28,10 +34,16 @@ export function createRabbitMqTopologyNames(
28
34
  const instance = sanitizeSegment(identity.instanceId)
29
35
  const commandsExchange = config.commandsExchange ?? `${prefix}.commands`
30
36
  const queriesExchange = config.queriesExchange ?? `${prefix}.queries`
37
+ const subscribersGossipExchange =
38
+ config.subscribersGossipExchange ?? `${prefix}.subscribers.gossip`
39
+ const subscribersDirectExchange =
40
+ config.subscribersDirectExchange ?? `${prefix}.subscribers.direct`
31
41
 
32
42
  return {
33
43
  commandsExchange,
34
44
  queriesExchange,
45
+ subscribersGossipExchange,
46
+ subscribersDirectExchange,
35
47
  commandRoutingKey(commandName) {
36
48
  return messageName(commandName)
37
49
  },
@@ -44,6 +56,12 @@ export function createRabbitMqTopologyNames(
44
56
  queryQueue(queryName) {
45
57
  return `${prefix}.queries.${service}.${sanitizeMessageName(messageName(queryName))}`
46
58
  },
59
+ subscribersGossipQueue() {
60
+ return `${prefix}.subscribers.gossip.${service}.${instance}`
61
+ },
62
+ subscribersDirectQueue() {
63
+ return `${prefix}.subscribers.direct.${service}.${instance}`
64
+ },
47
65
  commandReplyQueue() {
48
66
  return `${prefix}.replies.${service}.${instance}`
49
67
  },