@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.
- package/dist/amqp-query-updates-transport.d.ts +75 -0
- package/dist/amqp-query-updates-transport.d.ts.map +1 -0
- package/dist/amqp-query-updates-transport.js +105 -0
- package/dist/amqp-query-updates-transport.js.map +1 -0
- package/dist/distributed-subscriber-registry.d.ts +132 -0
- package/dist/distributed-subscriber-registry.d.ts.map +1 -0
- package/dist/distributed-subscriber-registry.js +192 -0
- package/dist/distributed-subscriber-registry.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/query-bus.d.ts +21 -5
- package/dist/query-bus.d.ts.map +1 -1
- package/dist/query-bus.js +181 -17
- package/dist/query-bus.js.map +1 -1
- package/dist/rabbitmq.d.ts +5 -4
- package/dist/rabbitmq.d.ts.map +1 -1
- package/dist/rabbitmq.js +10 -4
- package/dist/rabbitmq.js.map +1 -1
- package/dist/topology.d.ts +6 -0
- package/dist/topology.d.ts.map +1 -1
- package/dist/topology.js +10 -0
- package/dist/topology.js.map +1 -1
- package/package.json +1 -1
- package/src/distributed-subscriber-registry.ts +286 -0
- package/src/index.ts +8 -0
- package/src/query-bus.ts +202 -21
- package/src/rabbitmq.ts +10 -4
- package/src/topology.ts +18 -0
|
@@ -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 {
|
|
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
208
|
+
filter: SubscriptionFilter,
|
|
103
209
|
update: unknown,
|
|
104
210
|
): Promise<void> {
|
|
105
|
-
|
|
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?:
|
|
246
|
+
filter?: SubscriptionFilter,
|
|
111
247
|
): Promise<void> {
|
|
112
|
-
|
|
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?:
|
|
273
|
+
filter?: SubscriptionFilter,
|
|
119
274
|
): Promise<void> {
|
|
120
|
-
|
|
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
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
},
|