bunsane 0.2.9 → 0.2.10

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,535 @@
1
+ /**
2
+ * Remote Communication: StreamConsumer
3
+ *
4
+ * Blocking XREADGROUP loop on a dedicated Redis connection.
5
+ * - Consumer group auto-created on start (MKSTREAM, BUSYGROUP-safe)
6
+ * - BLOCK 2000 so `running` flag is polled at most every 2s
7
+ * - XACK on success; failures skip ACK to allow PEL redelivery
8
+ * - XAUTOCLAIM on startup reclaims PEL entries idle > autoClaimIdleMs
9
+ * - RPC dispatch via `kind: "rpc_request"` envelope — sends response to `replyTo`
10
+ */
11
+
12
+ import Redis from "ioredis";
13
+ import { logger } from "../Logger";
14
+ import type {
15
+ RemoteContext,
16
+ RemoteEnvelope,
17
+ RemoteHandler,
18
+ RemoteManagerConfig,
19
+ RpcHandler,
20
+ RpcResponse,
21
+ } from "./types";
22
+ import type { RemoteMetrics } from "./metrics";
23
+
24
+ const loggerInstance = logger.child({ scope: "StreamConsumer" });
25
+
26
+ type InternalEventHandler = { id: string; fn: RemoteHandler };
27
+ type InternalRpcHandler = { id: string; fn: RpcHandler };
28
+
29
+ export class StreamConsumer {
30
+ private redis: Redis;
31
+ private publisher: Redis;
32
+ private config: Required<
33
+ Pick<
34
+ RemoteManagerConfig,
35
+ | "appName"
36
+ | "batchSize"
37
+ | "blockMs"
38
+ | "streamPrefix"
39
+ | "consumerGroup"
40
+ | "consumerId"
41
+ | "enableLogging"
42
+ | "autoClaimIdleMs"
43
+ | "responseStreamMaxLen"
44
+ | "dlqMaxDeliveries"
45
+ >
46
+ >;
47
+ private eventHandlers = new Map<string, InternalEventHandler[]>();
48
+ private rpcHandlers = new Map<string, InternalRpcHandler>();
49
+ private running = false;
50
+ private loopPromise: Promise<void> | null = null;
51
+ private currentHandlerPromise: Promise<void> | null = null;
52
+ private metrics?: RemoteMetrics;
53
+
54
+ constructor(
55
+ redis: Redis,
56
+ publisher: Redis,
57
+ config: RemoteManagerConfig,
58
+ metrics?: RemoteMetrics
59
+ ) {
60
+ this.redis = redis;
61
+ this.publisher = publisher;
62
+ this.metrics = metrics;
63
+ this.config = {
64
+ appName: config.appName,
65
+ batchSize: config.batchSize ?? 10,
66
+ blockMs: config.blockMs ?? 2000,
67
+ streamPrefix: config.streamPrefix ?? "remote:",
68
+ consumerGroup: config.consumerGroup ?? config.appName,
69
+ consumerId:
70
+ config.consumerId ?? `consumer-${process.pid}-${Date.now()}`,
71
+ enableLogging: config.enableLogging ?? false,
72
+ autoClaimIdleMs: config.autoClaimIdleMs ?? 60_000,
73
+ responseStreamMaxLen: config.responseStreamMaxLen ?? 1000,
74
+ dlqMaxDeliveries: config.dlqMaxDeliveries ?? 3,
75
+ };
76
+ }
77
+
78
+ get dlqStream(): string {
79
+ return `${this.streamKey}:dlq`;
80
+ }
81
+
82
+ get streamKey(): string {
83
+ return `${this.config.streamPrefix}${this.config.appName}`;
84
+ }
85
+
86
+ addHandler(event: string, fn: RemoteHandler, handlerId: string): void {
87
+ const existing = this.eventHandlers.get(event) ?? [];
88
+ if (existing.some((h) => h.id === handlerId)) return;
89
+ existing.push({ id: handlerId, fn });
90
+ this.eventHandlers.set(event, existing);
91
+ }
92
+
93
+ addRpcHandler(event: string, fn: RpcHandler, handlerId: string): void {
94
+ const existing = this.rpcHandlers.get(event);
95
+ if (existing) {
96
+ if (existing.id !== handlerId) {
97
+ loggerInstance.warn(
98
+ `RPC handler for "${event}" already bound to ${existing.id}; overwriting with ${handlerId}`
99
+ );
100
+ }
101
+ }
102
+ this.rpcHandlers.set(event, { id: handlerId, fn });
103
+ }
104
+
105
+ async start(): Promise<void> {
106
+ if (this.running) return;
107
+
108
+ try {
109
+ await this.redis.xgroup(
110
+ "CREATE",
111
+ this.streamKey,
112
+ this.config.consumerGroup,
113
+ "$",
114
+ "MKSTREAM"
115
+ );
116
+ loggerInstance.info(
117
+ `Created consumer group ${this.config.consumerGroup} on ${this.streamKey}`
118
+ );
119
+ } catch (error: any) {
120
+ if (!String(error?.message).includes("BUSYGROUP")) {
121
+ throw error;
122
+ }
123
+ if (this.config.enableLogging) {
124
+ loggerInstance.debug(
125
+ `Consumer group ${this.config.consumerGroup} already exists`
126
+ );
127
+ }
128
+ }
129
+
130
+ if (this.config.autoClaimIdleMs > 0) {
131
+ await this.reclaimOrphaned();
132
+ }
133
+
134
+ this.running = true;
135
+ this.loopPromise = this.consumeLoop();
136
+ loggerInstance.info(
137
+ `Stream consumer started: stream=${this.streamKey} group=${this.config.consumerGroup} consumer=${this.config.consumerId}`
138
+ );
139
+ }
140
+
141
+ async stop(): Promise<void> {
142
+ if (!this.running) return;
143
+ this.running = false;
144
+ if (this.loopPromise) {
145
+ await this.loopPromise.catch(() => {});
146
+ this.loopPromise = null;
147
+ }
148
+ if (this.currentHandlerPromise) {
149
+ await this.currentHandlerPromise.catch(() => {});
150
+ }
151
+ loggerInstance.info("Stream consumer stopped");
152
+ }
153
+
154
+ /**
155
+ * XAUTOCLAIM orphaned PEL entries — any consumer in the group idle
156
+ * beyond autoClaimIdleMs has its pending messages reassigned to us.
157
+ */
158
+ private async reclaimOrphaned(): Promise<void> {
159
+ let cursor = "0-0";
160
+ let totalClaimed = 0;
161
+ try {
162
+ while (true) {
163
+ const result: any = await (this.redis as any).xautoclaim(
164
+ this.streamKey,
165
+ this.config.consumerGroup,
166
+ this.config.consumerId,
167
+ this.config.autoClaimIdleMs,
168
+ cursor,
169
+ "COUNT",
170
+ this.config.batchSize
171
+ );
172
+ if (!result) break;
173
+ const [nextCursor, entries] = result;
174
+ if (Array.isArray(entries)) {
175
+ for (const [msgId, fields] of entries) {
176
+ await this.processMessage(msgId, fields, true);
177
+ totalClaimed++;
178
+ }
179
+ }
180
+ if (!nextCursor || nextCursor === "0-0") break;
181
+ cursor = nextCursor;
182
+ }
183
+ if (totalClaimed > 0) {
184
+ loggerInstance.info(
185
+ `XAUTOCLAIM recovered ${totalClaimed} orphaned messages`
186
+ );
187
+ }
188
+ } catch (error: any) {
189
+ // XAUTOCLAIM requires Redis 6.2+. Log and continue.
190
+ loggerInstance.warn(
191
+ { err: error, msg: "XAUTOCLAIM failed — Redis < 6.2?" }
192
+ );
193
+ }
194
+ }
195
+
196
+ private async consumeLoop(): Promise<void> {
197
+ while (this.running) {
198
+ try {
199
+ const result: any = await (this.redis as any).xreadgroup(
200
+ "GROUP",
201
+ this.config.consumerGroup,
202
+ this.config.consumerId,
203
+ "COUNT",
204
+ this.config.batchSize,
205
+ "BLOCK",
206
+ this.config.blockMs,
207
+ "STREAMS",
208
+ this.streamKey,
209
+ ">"
210
+ );
211
+
212
+ if (!result || !this.running) continue;
213
+
214
+ for (const [, entries] of result) {
215
+ for (const [msgId, fields] of entries) {
216
+ if (!this.running) break;
217
+ this.currentHandlerPromise = this.processMessage(
218
+ msgId,
219
+ fields,
220
+ false
221
+ );
222
+ await this.currentHandlerPromise;
223
+ this.currentHandlerPromise = null;
224
+ }
225
+ }
226
+ } catch (error: any) {
227
+ if (!this.running) break;
228
+ if (String(error?.message).includes("Connection is closed")) {
229
+ break;
230
+ }
231
+ loggerInstance.error(
232
+ { err: error, msg: "Stream consume error" }
233
+ );
234
+ await this.sleep(1000);
235
+ }
236
+ }
237
+ }
238
+
239
+ private async processMessage(
240
+ msgId: string,
241
+ fields: string[],
242
+ reclaimed: boolean
243
+ ): Promise<void> {
244
+ const envelope = this.parseEnvelope(fields);
245
+ if (!envelope) {
246
+ await this.ack(msgId);
247
+ loggerInstance.warn(`Malformed envelope at ${msgId}, ACK'd`);
248
+ return;
249
+ }
250
+
251
+ // DLQ check: if this message has been redelivered too many times,
252
+ // move it to the DLQ and ACK the original so the consumer group can
253
+ // progress past it. Disabled when dlqMaxDeliveries is 0.
254
+ if (this.config.dlqMaxDeliveries > 0) {
255
+ const deliveryCount = await this.getDeliveryCount(msgId);
256
+ if (deliveryCount >= this.config.dlqMaxDeliveries) {
257
+ await this.sendToDlq(msgId, fields, deliveryCount);
258
+ await this.ack(msgId);
259
+ this.metrics?.eventDlq();
260
+ loggerInstance.warn(
261
+ {
262
+ msgId,
263
+ deliveryCount,
264
+ event: envelope.event,
265
+ msg: "Message routed to DLQ — max deliveries exceeded",
266
+ }
267
+ );
268
+ return;
269
+ }
270
+ }
271
+
272
+ const kind = envelope.kind ?? "event";
273
+ if (kind === "rpc_request") {
274
+ await this.handleRpcRequest(msgId, envelope, reclaimed);
275
+ } else {
276
+ await this.handleEvent(msgId, envelope, reclaimed);
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Query PEL for this message id; returns the delivery count, or 1 if
282
+ * the message isn't in PEL (first delivery before ACK).
283
+ */
284
+ private async getDeliveryCount(msgId: string): Promise<number> {
285
+ try {
286
+ const result: any = await (this.redis as any).xpending(
287
+ this.streamKey,
288
+ this.config.consumerGroup,
289
+ msgId,
290
+ msgId,
291
+ 1
292
+ );
293
+ // XPENDING with id range returns: [[msgId, consumer, idleMs, deliveryCount], ...]
294
+ const entry = Array.isArray(result) ? result[0] : null;
295
+ if (!entry || !Array.isArray(entry)) return 1;
296
+ const count = entry[3];
297
+ return typeof count === "number" ? count : parseInt(count ?? "1", 10);
298
+ } catch (error) {
299
+ // On error, fall through to process normally — avoid false DLQ routing.
300
+ return 1;
301
+ }
302
+ }
303
+
304
+ private async sendToDlq(
305
+ msgId: string,
306
+ fields: string[],
307
+ deliveryCount: number
308
+ ): Promise<void> {
309
+ // Forward original envelope + metadata to DLQ stream.
310
+ const flatFields: string[] = [];
311
+ flatFields.push("original_id", msgId);
312
+ flatFields.push("delivery_count", String(deliveryCount));
313
+ flatFields.push("moved_at", String(Date.now()));
314
+ for (let i = 0; i < fields.length; i++) {
315
+ flatFields.push(fields[i]!);
316
+ }
317
+ try {
318
+ await (this.publisher as any).xadd(
319
+ this.dlqStream,
320
+ "MAXLEN",
321
+ "~",
322
+ 10_000,
323
+ "*",
324
+ ...flatFields
325
+ );
326
+ } catch (error: any) {
327
+ loggerInstance.error(
328
+ {
329
+ err: error,
330
+ dlqStream: this.dlqStream,
331
+ originalId: msgId,
332
+ msg: "Failed to write to DLQ",
333
+ }
334
+ );
335
+ }
336
+ }
337
+
338
+ private async handleEvent(
339
+ msgId: string,
340
+ envelope: RemoteEnvelope,
341
+ reclaimed: boolean
342
+ ): Promise<void> {
343
+ this.metrics?.eventReceived();
344
+ const handlers = this.eventHandlers.get(envelope.event) ?? [];
345
+ if (handlers.length === 0) {
346
+ await this.ack(msgId);
347
+ this.metrics?.eventNoHandler();
348
+ if (this.config.enableLogging) {
349
+ loggerInstance.debug(
350
+ `No handler for event "${envelope.event}", ACK'd ${msgId}`
351
+ );
352
+ }
353
+ return;
354
+ }
355
+
356
+ const ctx: RemoteContext = {
357
+ sourceApp: envelope.sourceApp,
358
+ messageId: msgId,
359
+ timestamp: new Date(envelope.emittedAt),
360
+ attempt: reclaimed ? 2 : 1,
361
+ };
362
+
363
+ let allOk = true;
364
+ for (const h of handlers) {
365
+ try {
366
+ await h.fn(envelope.data, ctx);
367
+ } catch (error: any) {
368
+ allOk = false;
369
+ this.metrics?.eventHandlerFailed();
370
+ loggerInstance.error(
371
+ {
372
+ err: error,
373
+ event: envelope.event,
374
+ handlerId: h.id,
375
+ msgId,
376
+ msg: "Remote handler failed",
377
+ }
378
+ );
379
+ }
380
+ }
381
+
382
+ if (allOk) {
383
+ this.metrics?.eventHandled();
384
+ await this.ack(msgId);
385
+ }
386
+ }
387
+
388
+ private async handleRpcRequest(
389
+ msgId: string,
390
+ envelope: RemoteEnvelope,
391
+ reclaimed: boolean
392
+ ): Promise<void> {
393
+ const { correlationId, replyTo, deadline, event } = envelope;
394
+ if (!correlationId || !replyTo) {
395
+ await this.ack(msgId);
396
+ loggerInstance.warn(
397
+ `RPC request missing correlationId/replyTo at ${msgId}, ACK'd`
398
+ );
399
+ return;
400
+ }
401
+
402
+ // Deadline check — caller may already have timed out
403
+ if (typeof deadline === "number" && Date.now() > deadline) {
404
+ await this.ack(msgId);
405
+ this.metrics?.rpcPastDeadline();
406
+ if (this.config.enableLogging) {
407
+ loggerInstance.debug(
408
+ `RPC ${event} past deadline, skipping (cid=${correlationId})`
409
+ );
410
+ }
411
+ return;
412
+ }
413
+
414
+ const handler = this.rpcHandlers.get(event);
415
+ if (!handler) {
416
+ await this.sendRpcResponse(replyTo, {
417
+ correlationId,
418
+ sourceApp: this.config.appName,
419
+ success: false,
420
+ error: {
421
+ code: "NOT_FOUND",
422
+ message: `No RPC handler for "${event}" on ${this.config.appName}`,
423
+ },
424
+ respondedAt: Date.now(),
425
+ });
426
+ await this.ack(msgId);
427
+ return;
428
+ }
429
+
430
+ const ctx: RemoteContext = {
431
+ sourceApp: envelope.sourceApp,
432
+ messageId: msgId,
433
+ timestamp: new Date(envelope.emittedAt),
434
+ attempt: reclaimed ? 2 : 1,
435
+ correlationId,
436
+ deadline: typeof deadline === "number" ? new Date(deadline) : undefined,
437
+ };
438
+
439
+ try {
440
+ const result = await handler.fn(envelope.data, ctx);
441
+ await this.sendRpcResponse(replyTo, {
442
+ correlationId,
443
+ sourceApp: this.config.appName,
444
+ success: true,
445
+ result,
446
+ respondedAt: Date.now(),
447
+ });
448
+ await this.ack(msgId);
449
+ this.metrics?.rpcHandlerExecuted();
450
+ } catch (error: any) {
451
+ const code = error?.code ?? "HANDLER_ERROR";
452
+ const message = error?.message ?? String(error);
453
+ const extensions = error?.extensions;
454
+ await this.sendRpcResponse(replyTo, {
455
+ correlationId,
456
+ sourceApp: this.config.appName,
457
+ success: false,
458
+ error: { code, message, extensions },
459
+ respondedAt: Date.now(),
460
+ });
461
+ await this.ack(msgId);
462
+ this.metrics?.rpcHandlerFailed();
463
+ loggerInstance.error(
464
+ {
465
+ err: error,
466
+ event,
467
+ msgId,
468
+ msg: "RPC handler failed",
469
+ }
470
+ );
471
+ }
472
+ }
473
+
474
+ private async sendRpcResponse(
475
+ replyTo: string,
476
+ response: RpcResponse
477
+ ): Promise<void> {
478
+ try {
479
+ await this.publisher.xadd(
480
+ replyTo,
481
+ "MAXLEN",
482
+ "~",
483
+ this.config.responseStreamMaxLen,
484
+ "*",
485
+ "data",
486
+ JSON.stringify(response)
487
+ );
488
+ } catch (error: any) {
489
+ loggerInstance.error(
490
+ {
491
+ err: error,
492
+ replyTo,
493
+ correlationId: response.correlationId,
494
+ msg: "Failed to send RPC response",
495
+ }
496
+ );
497
+ }
498
+ }
499
+
500
+ private async ack(msgId: string): Promise<void> {
501
+ try {
502
+ await this.redis.xack(
503
+ this.streamKey,
504
+ this.config.consumerGroup,
505
+ msgId
506
+ );
507
+ } catch (error: any) {
508
+ loggerInstance.warn(
509
+ { err: error, msgId, msg: "XACK failed" }
510
+ );
511
+ }
512
+ }
513
+
514
+ private parseEnvelope(fields: string[]): RemoteEnvelope | null {
515
+ let payload: string | undefined;
516
+ for (let i = 0; i < fields.length - 1; i += 2) {
517
+ if (fields[i] === "data") {
518
+ payload = fields[i + 1];
519
+ break;
520
+ }
521
+ }
522
+ if (!payload) return null;
523
+ try {
524
+ const parsed = JSON.parse(payload) as RemoteEnvelope;
525
+ if (!parsed || typeof parsed.event !== "string") return null;
526
+ return parsed;
527
+ } catch {
528
+ return null;
529
+ }
530
+ }
531
+
532
+ private sleep(ms: number): Promise<void> {
533
+ return new Promise((r) => setTimeout(r, ms));
534
+ }
535
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Remote Communication: @RemoteEvent + @RemoteRpc decorators
3
+ *
4
+ * Mirrors @ScheduledTask pattern:
5
+ * - Metadata stored on `constructor.__remoteHandlers`
6
+ * - Deduplication by handler id
7
+ * - Service-scoped registration at SYSTEM_READY
8
+ */
9
+
10
+ import { logger } from "../Logger";
11
+ import type { RemoteHandlerInfo } from "./types";
12
+ import { getRemoteManager } from "./RemoteManager";
13
+
14
+ const loggerInstance = logger.child({ scope: "RemoteDecorators" });
15
+
16
+ export interface RemoteEventOptions {
17
+ event: string;
18
+ id?: string;
19
+ }
20
+
21
+ export interface RemoteRpcOptions {
22
+ event: string;
23
+ id?: string;
24
+ }
25
+
26
+ function pushHandler(
27
+ target: any,
28
+ propertyKey: string,
29
+ options: RemoteEventOptions,
30
+ kind: "event" | "rpc_request"
31
+ ): void {
32
+ const handlerId = options.id || `${target.constructor.name}.${propertyKey}`;
33
+
34
+ if (!target.constructor.__remoteHandlers) {
35
+ target.constructor.__remoteHandlers = [];
36
+ }
37
+
38
+ const info: RemoteHandlerInfo = {
39
+ event: options.event,
40
+ methodName: propertyKey,
41
+ handlerId,
42
+ kind,
43
+ };
44
+
45
+ const handlers: RemoteHandlerInfo[] = target.constructor.__remoteHandlers;
46
+ const existing = handlers.findIndex((h) => h.handlerId === handlerId);
47
+ if (existing === -1) {
48
+ handlers.push(info);
49
+ } else {
50
+ loggerInstance.warn(
51
+ `Remote handler ${handlerId} already registered on ${target.constructor.name}. Skipping duplicate.`
52
+ );
53
+ }
54
+ }
55
+
56
+ export function RemoteEvent(options: RemoteEventOptions) {
57
+ return function (
58
+ target: any,
59
+ propertyKey: string,
60
+ descriptor: PropertyDescriptor
61
+ ) {
62
+ pushHandler(target, propertyKey, options, "event");
63
+ return descriptor;
64
+ };
65
+ }
66
+
67
+ export function RemoteRpc(options: RemoteRpcOptions) {
68
+ return function (
69
+ target: any,
70
+ propertyKey: string,
71
+ descriptor: PropertyDescriptor
72
+ ) {
73
+ pushHandler(target, propertyKey, options, "rpc_request");
74
+ return descriptor;
75
+ };
76
+ }
77
+
78
+ export function registerRemoteHandlers(service: any): void {
79
+ const ctor = service.constructor;
80
+ const handlers: RemoteHandlerInfo[] | undefined = ctor.__remoteHandlers;
81
+ if (!handlers || handlers.length === 0) return;
82
+
83
+ const manager = getRemoteManager();
84
+ if (!manager) {
85
+ loggerInstance.warn(
86
+ `Remote manager not initialized — skipping remote handler registration for ${ctor.name}`
87
+ );
88
+ return;
89
+ }
90
+
91
+ const seen = new Set<string>();
92
+ for (const h of handlers) {
93
+ if (seen.has(h.handlerId)) {
94
+ loggerInstance.warn(
95
+ `Duplicate remote handler id ${h.handlerId}, using first occurrence only`
96
+ );
97
+ continue;
98
+ }
99
+ seen.add(h.handlerId);
100
+
101
+ const method = (service as any)[h.methodName];
102
+ if (typeof method !== "function") {
103
+ loggerInstance.warn(
104
+ `Remote handler method ${h.methodName} not found on ${ctor.name}`
105
+ );
106
+ continue;
107
+ }
108
+
109
+ if (h.kind === "rpc_request") {
110
+ manager.onRpc(h.event, method.bind(service), h.handlerId);
111
+ loggerInstance.info(
112
+ `Registered RPC handler ${h.handlerId} for event "${h.event}"`
113
+ );
114
+ } else {
115
+ manager.on(h.event, method.bind(service), h.handlerId);
116
+ loggerInstance.info(
117
+ `Registered event handler ${h.handlerId} for event "${h.event}"`
118
+ );
119
+ }
120
+ }
121
+ }