bunsane 0.2.8 → 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.
Files changed (35) hide show
  1. package/CLAUDE.md +26 -0
  2. package/core/App.ts +97 -0
  3. package/core/remote/CircuitBreaker.ts +115 -0
  4. package/core/remote/OutboxWorker.ts +176 -0
  5. package/core/remote/RemoteManager.ts +400 -0
  6. package/core/remote/RpcCaller.ts +310 -0
  7. package/core/remote/StreamConsumer.ts +535 -0
  8. package/core/remote/decorators.ts +121 -0
  9. package/core/remote/health.ts +139 -0
  10. package/core/remote/index.ts +37 -0
  11. package/core/remote/metrics.ts +99 -0
  12. package/core/remote/outboxSchema.ts +41 -0
  13. package/core/remote/types.ts +151 -0
  14. package/core/scheduler/DistributedLock.ts +309 -266
  15. package/docs/SCALABILITY_PLAN.md +3 -3
  16. package/package.json +1 -1
  17. package/query/FilterBuilder.ts +25 -0
  18. package/query/Query.ts +5 -1
  19. package/query/builders/JsonbArrayBuilder.ts +116 -0
  20. package/query/index.ts +28 -2
  21. package/tests/helpers/MockRedisClient.ts +113 -0
  22. package/tests/helpers/MockRedisStreamServer.ts +448 -0
  23. package/tests/integration/query/Query.exec.test.ts +67 -14
  24. package/tests/integration/query/Query.jsonbArray.test.ts +214 -0
  25. package/tests/integration/remote/dlq.test.ts +175 -0
  26. package/tests/integration/remote/event-dispatch.test.ts +114 -0
  27. package/tests/integration/remote/outbox.test.ts +130 -0
  28. package/tests/integration/remote/rpc.test.ts +177 -0
  29. package/tests/pglite-setup.ts +1 -0
  30. package/tests/unit/query/JsonbArrayBuilder.test.ts +178 -0
  31. package/tests/unit/remote/CircuitBreaker.test.ts +159 -0
  32. package/tests/unit/remote/RemoteError.test.ts +55 -0
  33. package/tests/unit/remote/decorators.test.ts +195 -0
  34. package/tests/unit/remote/metrics.test.ts +115 -0
  35. package/tests/unit/remote/mockRedisStreamServer.test.ts +104 -0
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Remote Communication: RemoteManager
3
+ *
4
+ * Phase 1: events + RPC over Redis Streams.
5
+ * - emit(target, event, data): fire-and-forget XADD to `remote:<target>`
6
+ * - call(target, method, data, options): RPC with correlationId + deadline
7
+ * - Dedicated Redis connections:
8
+ * publisher — XADD only (non-blocking, retries enabled)
9
+ * consumer — XREADGROUP BLOCK (retries=null, required for blocking)
10
+ * rpcListener — XREAD BLOCK on per-instance response stream
11
+ * - Graceful shutdown drains pending RPC calls within `shutdownDrainMs`
12
+ */
13
+
14
+ import Redis, { type RedisOptions } from "ioredis";
15
+ import { logger } from "../Logger";
16
+ import { StreamConsumer } from "./StreamConsumer";
17
+ import { RpcCaller } from "./RpcCaller";
18
+ import { OutboxWorker } from "./OutboxWorker";
19
+ import { ensureOutboxSchema } from "./outboxSchema";
20
+ import { CircuitBreaker } from "./CircuitBreaker";
21
+ import { RemoteMetrics, type RemoteMetricsSnapshot } from "./metrics";
22
+ import { collectRemoteHealth, type RemoteHealthCheck } from "./health";
23
+ import db from "../../database";
24
+ import type {
25
+ CallOptions,
26
+ EmitOptions,
27
+ RemoteHandler,
28
+ RemoteManagerConfig,
29
+ RpcHandler,
30
+ } from "./types";
31
+
32
+ const loggerInstance = logger.child({ scope: "RemoteManager" });
33
+
34
+ function buildRedisOptions(blocking: boolean): RedisOptions {
35
+ return {
36
+ host: process.env.REDIS_HOST || "localhost",
37
+ port: parseInt(process.env.REDIS_PORT || "6379", 10),
38
+ password: process.env.REDIS_PASSWORD,
39
+ db: parseInt(process.env.REDIS_DB || "0", 10),
40
+ maxRetriesPerRequest: blocking ? null : 3,
41
+ enableReadyCheck: false,
42
+ retryStrategy: (times: number) => Math.min(times * 50, 2000),
43
+ };
44
+ }
45
+
46
+ export class RemoteManager {
47
+ private publisher: Redis | null = null;
48
+ private consumerRedis: Redis | null = null;
49
+ private rpcListenerRedis: Redis | null = null;
50
+ private consumer: StreamConsumer | null = null;
51
+ private caller: RpcCaller | null = null;
52
+ private outboxWorker: OutboxWorker | null = null;
53
+ private breaker: CircuitBreaker;
54
+ private metrics = new RemoteMetrics();
55
+ private config: RemoteManagerConfig;
56
+ private _instanceId: string;
57
+ private started = false;
58
+
59
+ constructor(config: RemoteManagerConfig) {
60
+ this.config = config;
61
+ this._instanceId = crypto.randomUUID();
62
+ this.breaker = new CircuitBreaker({
63
+ threshold: config.circuitBreakerThreshold,
64
+ resetTimeoutMs: config.circuitBreakerResetMs,
65
+ });
66
+ this.breaker.onTrip = () => this.metrics.cbTripped();
67
+ this.breaker.onReject = () => this.metrics.cbRejected();
68
+ }
69
+
70
+ getMetrics(): RemoteMetricsSnapshot & {
71
+ circuitBreaker: RemoteMetricsSnapshot["circuitBreaker"] & {
72
+ state: string;
73
+ };
74
+ } {
75
+ const snap = this.metrics.getSnapshot();
76
+ return {
77
+ ...snap,
78
+ circuitBreaker: {
79
+ ...snap.circuitBreaker,
80
+ state: this.breaker.getState(),
81
+ },
82
+ };
83
+ }
84
+
85
+ getCircuitBreaker(): CircuitBreaker {
86
+ return this.breaker;
87
+ }
88
+
89
+ async health(): Promise<RemoteHealthCheck> {
90
+ return collectRemoteHealth({
91
+ publisher: this.publisher,
92
+ consumerRedis: this.consumerRedis,
93
+ streamKey: `${this.streamPrefix}${this.config.appName}`,
94
+ consumerGroup:
95
+ this.config.consumerGroup ?? this.config.appName,
96
+ dlqStream: `${this.streamPrefix}${this.config.appName}:dlq`,
97
+ outboxEnabled: this.config.enableOutbox ?? false,
98
+ db,
99
+ breaker: this.breaker,
100
+ });
101
+ }
102
+
103
+ get appName(): string {
104
+ return this.config.appName;
105
+ }
106
+
107
+ get instanceId(): string {
108
+ return this._instanceId;
109
+ }
110
+
111
+ get streamPrefix(): string {
112
+ return this.config.streamPrefix ?? "remote:";
113
+ }
114
+
115
+ get responseStream(): string {
116
+ return `rpc:responses:${this._instanceId}`;
117
+ }
118
+
119
+ /**
120
+ * Emit an event.
121
+ *
122
+ * Without `{ trx }`: direct XADD to Redis (fire-and-forget, no DB write).
123
+ * With `{ trx }`: insert a row into `remote_outbox` within the caller's
124
+ * transaction. The OutboxWorker publishes the row to Redis after commit.
125
+ *
126
+ * Returns the Redis message id (direct path) or the outbox row id
127
+ * (transactional path).
128
+ */
129
+ async emit(
130
+ target: string,
131
+ event: string,
132
+ data: unknown,
133
+ options: EmitOptions = {}
134
+ ): Promise<string | null> {
135
+ if (!this.publisher) {
136
+ throw new Error(
137
+ "RemoteManager not started — call start() before emit()"
138
+ );
139
+ }
140
+
141
+ if (options.trx) {
142
+ const trx = options.trx as any;
143
+ const rows = await trx`
144
+ INSERT INTO remote_outbox (target, event, data)
145
+ VALUES (${target}, ${event}, ${data})
146
+ RETURNING id
147
+ `;
148
+ const id = rows?.[0]?.id ?? null;
149
+ this.metrics.emitOutbox();
150
+ if (this.config.enableLogging) {
151
+ loggerInstance.debug(
152
+ `emit outbox → target=${target} event=${event} id=${id}`
153
+ );
154
+ }
155
+ return id;
156
+ }
157
+
158
+ const stream = `${this.streamPrefix}${target}`;
159
+ const envelope = JSON.stringify({
160
+ kind: "event",
161
+ sourceApp: this.config.appName,
162
+ event,
163
+ data,
164
+ emittedAt: Date.now(),
165
+ });
166
+ try {
167
+ const publisher = this.publisher;
168
+ const id = await this.breaker.exec(() =>
169
+ publisher.xadd(stream, "*", "data", envelope)
170
+ );
171
+ this.metrics.emitDirect();
172
+ if (this.config.enableLogging) {
173
+ loggerInstance.debug(
174
+ `emit → ${stream} event=${event} id=${id}`
175
+ );
176
+ }
177
+ return id;
178
+ } catch (error) {
179
+ this.metrics.emitFailed();
180
+ throw error;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * RPC call — awaits a response or rejects on timeout/error.
186
+ * Throws `RemoteError { code: "INVALID_TARGET" }` for broadcast target "*".
187
+ */
188
+ async call<T = unknown>(
189
+ target: string,
190
+ method: string,
191
+ data: unknown,
192
+ options: CallOptions = {}
193
+ ): Promise<T> {
194
+ if (!this.caller) {
195
+ throw new Error(
196
+ "RemoteManager not started — call start() before call()"
197
+ );
198
+ }
199
+ return this.caller.call<T>(
200
+ target,
201
+ method,
202
+ data,
203
+ this.streamPrefix,
204
+ this.config.appName,
205
+ options
206
+ );
207
+ }
208
+
209
+ on(event: string, fn: RemoteHandler, handlerId: string): void {
210
+ if (!this.consumer) {
211
+ throw new Error(
212
+ "RemoteManager consumer not initialized — call start() first"
213
+ );
214
+ }
215
+ this.consumer.addHandler(event, fn, handlerId);
216
+ }
217
+
218
+ onRpc(event: string, fn: RpcHandler, handlerId: string): void {
219
+ if (!this.consumer) {
220
+ throw new Error(
221
+ "RemoteManager consumer not initialized — call start() first"
222
+ );
223
+ }
224
+ this.consumer.addRpcHandler(event, fn, handlerId);
225
+ }
226
+
227
+ async start(): Promise<void> {
228
+ if (this.started) return;
229
+
230
+ const factory =
231
+ this.config.redisFactory ??
232
+ ((blocking: boolean) => new Redis(buildRedisOptions(blocking)));
233
+
234
+ this.publisher = factory(false) as Redis;
235
+ this.consumerRedis = factory(true) as Redis;
236
+ this.rpcListenerRedis = factory(true) as Redis;
237
+
238
+ for (const [name, client] of [
239
+ ["publisher", this.publisher],
240
+ ["consumer", this.consumerRedis],
241
+ ["rpcListener", this.rpcListenerRedis],
242
+ ] as const) {
243
+ client.on("error", (err) => {
244
+ loggerInstance.warn(
245
+ { err, name, msg: `${name} Redis error` }
246
+ );
247
+ });
248
+ }
249
+
250
+ this.consumer = new StreamConsumer(
251
+ this.consumerRedis,
252
+ this.publisher,
253
+ this.config,
254
+ this.metrics
255
+ );
256
+ await this.consumer.start();
257
+
258
+ this.caller = new RpcCaller(
259
+ this.rpcListenerRedis,
260
+ this.publisher,
261
+ {
262
+ instanceId: this._instanceId,
263
+ responseStream: this.responseStream,
264
+ defaultTimeout: this.config.defaultCallTimeout ?? 5000,
265
+ responseStreamMaxLen:
266
+ this.config.responseStreamMaxLen ?? 1000,
267
+ enableLogging: this.config.enableLogging ?? false,
268
+ },
269
+ this.breaker,
270
+ this.metrics
271
+ );
272
+ await this.caller.start();
273
+
274
+ if (this.config.enableOutbox) {
275
+ try {
276
+ await ensureOutboxSchema(db);
277
+ this.outboxWorker = new OutboxWorker(
278
+ db,
279
+ this.publisher,
280
+ {
281
+ sourceApp: this.config.appName,
282
+ streamPrefix: this.streamPrefix,
283
+ pollIntervalMs:
284
+ this.config.outboxPollIntervalMs ?? 1000,
285
+ batchSize: this.config.outboxBatchSize ?? 100,
286
+ enableLogging: this.config.enableLogging ?? false,
287
+ },
288
+ this.metrics
289
+ );
290
+ await this.outboxWorker.start();
291
+ } catch (error) {
292
+ loggerInstance.error(
293
+ { err: error, msg: "Failed to start OutboxWorker" }
294
+ );
295
+ this.outboxWorker = null;
296
+ }
297
+ }
298
+
299
+ this.started = true;
300
+ loggerInstance.info(
301
+ `RemoteManager started app="${this.config.appName}" instance=${this._instanceId} outbox=${this.config.enableOutbox ?? false}`
302
+ );
303
+ }
304
+
305
+ async shutdown(): Promise<void> {
306
+ if (!this.started) return;
307
+ this.started = false;
308
+
309
+ // 1. Stop outbox worker first — best-effort flush so committed rows
310
+ // emitted right before shutdown still reach Redis.
311
+ if (this.outboxWorker) {
312
+ try {
313
+ await this.outboxWorker.flush();
314
+ } catch (error) {
315
+ loggerInstance.warn(
316
+ { err: error, msg: "OutboxWorker flush error" }
317
+ );
318
+ }
319
+ try {
320
+ await this.outboxWorker.stop();
321
+ } catch (error) {
322
+ loggerInstance.warn(
323
+ { err: error, msg: "OutboxWorker stop error" }
324
+ );
325
+ }
326
+ this.outboxWorker = null;
327
+ }
328
+
329
+ // 2. Drain pending RPC calls first (caller rejects new)
330
+ const drainMs = this.config.shutdownDrainMs ?? 2000;
331
+ if (this.caller) {
332
+ try {
333
+ await this.caller.stop(drainMs);
334
+ } catch (error) {
335
+ loggerInstance.warn(
336
+ { err: error, msg: "RpcCaller stop error" }
337
+ );
338
+ }
339
+ this.caller = null;
340
+ }
341
+
342
+ // 3. Stop consumer — waits for in-flight handler
343
+ if (this.consumer) {
344
+ try {
345
+ await this.consumer.stop();
346
+ } catch (error) {
347
+ loggerInstance.warn(
348
+ { err: error, msg: "Consumer stop error" }
349
+ );
350
+ }
351
+ this.consumer = null;
352
+ }
353
+
354
+ // 4. Disconnect Redis conns
355
+ if (this.rpcListenerRedis) {
356
+ try {
357
+ this.rpcListenerRedis.disconnect();
358
+ } catch (error) {
359
+ loggerInstance.warn(
360
+ { err: error, msg: "RPC listener disconnect error" }
361
+ );
362
+ }
363
+ this.rpcListenerRedis = null;
364
+ }
365
+
366
+ if (this.consumerRedis) {
367
+ try {
368
+ this.consumerRedis.disconnect();
369
+ } catch (error) {
370
+ loggerInstance.warn(
371
+ { err: error, msg: "Consumer Redis disconnect error" }
372
+ );
373
+ }
374
+ this.consumerRedis = null;
375
+ }
376
+
377
+ if (this.publisher) {
378
+ try {
379
+ await this.publisher.quit();
380
+ } catch (error) {
381
+ loggerInstance.warn(
382
+ { err: error, msg: "Publisher quit error" }
383
+ );
384
+ }
385
+ this.publisher = null;
386
+ }
387
+
388
+ loggerInstance.info("RemoteManager shutdown completed");
389
+ }
390
+ }
391
+
392
+ let remoteManagerInstance: RemoteManager | null = null;
393
+
394
+ export function getRemoteManager(): RemoteManager | null {
395
+ return remoteManagerInstance;
396
+ }
397
+
398
+ export function setRemoteManager(instance: RemoteManager | null): void {
399
+ remoteManagerInstance = instance;
400
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Remote Communication: RpcCaller
3
+ *
4
+ * Phase 1: Request/response over Redis Streams.
5
+ * - Per-instance response stream `rpc:responses:<instanceId>` — no consumer group
6
+ * - Dedicated blocking Redis connection for XREAD $
7
+ * - Pending map keyed by correlationId, timer per call
8
+ * - Response writes capped via MAXLEN ~ to prevent unbounded growth
9
+ */
10
+
11
+ import Redis from "ioredis";
12
+ import { logger } from "../Logger";
13
+ import { RemoteError } from "./types";
14
+ import type {
15
+ CallOptions,
16
+ RpcResponse,
17
+ } from "./types";
18
+ import type { CircuitBreaker } from "./CircuitBreaker";
19
+ import type { RemoteMetrics } from "./metrics";
20
+
21
+ const loggerInstance = logger.child({ scope: "RpcCaller" });
22
+
23
+ interface PendingEntry {
24
+ resolve: (value: unknown) => void;
25
+ reject: (err: unknown) => void;
26
+ timer: ReturnType<typeof setTimeout>;
27
+ method: string;
28
+ target: string;
29
+ }
30
+
31
+ export interface RpcCallerConfig {
32
+ instanceId: string;
33
+ responseStream: string;
34
+ defaultTimeout: number;
35
+ responseStreamMaxLen: number;
36
+ enableLogging: boolean;
37
+ }
38
+
39
+ export class RpcCaller {
40
+ private listenerRedis: Redis;
41
+ private publisher: Redis;
42
+ private config: RpcCallerConfig;
43
+ private pending = new Map<string, PendingEntry>();
44
+ private running = false;
45
+ private draining = false;
46
+ private loopPromise: Promise<void> | null = null;
47
+ private lastId = "$";
48
+ private breaker?: CircuitBreaker;
49
+ private metrics?: RemoteMetrics;
50
+
51
+ constructor(
52
+ listenerRedis: Redis,
53
+ publisher: Redis,
54
+ config: RpcCallerConfig,
55
+ breaker?: CircuitBreaker,
56
+ metrics?: RemoteMetrics
57
+ ) {
58
+ this.listenerRedis = listenerRedis;
59
+ this.publisher = publisher;
60
+ this.config = config;
61
+ this.breaker = breaker;
62
+ this.metrics = metrics;
63
+ }
64
+
65
+ get responseStream(): string {
66
+ return this.config.responseStream;
67
+ }
68
+
69
+ get instanceId(): string {
70
+ return this.config.instanceId;
71
+ }
72
+
73
+ async start(): Promise<void> {
74
+ if (this.running) return;
75
+ this.running = true;
76
+ this.loopPromise = this.listenLoop();
77
+ loggerInstance.info(
78
+ `RpcCaller started: responseStream=${this.config.responseStream}`
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Stop accepting new calls and drain pending within `drainMs`.
84
+ * Pending calls remaining after drain are rejected with code="SHUTDOWN".
85
+ */
86
+ async stop(drainMs: number): Promise<void> {
87
+ if (!this.running) return;
88
+ this.draining = true;
89
+
90
+ // Wait for pending to settle — bounded by drainMs
91
+ const deadline = Date.now() + drainMs;
92
+ while (this.pending.size > 0 && Date.now() < deadline) {
93
+ await this.sleep(50);
94
+ }
95
+
96
+ // Force-reject remaining
97
+ for (const [id, entry] of this.pending) {
98
+ clearTimeout(entry.timer);
99
+ entry.reject(
100
+ new RemoteError(
101
+ `RPC ${entry.method} cancelled by shutdown`,
102
+ { code: "SHUTDOWN", sourceApp: entry.target }
103
+ )
104
+ );
105
+ this.pending.delete(id);
106
+ }
107
+
108
+ this.running = false;
109
+ if (this.loopPromise) {
110
+ await this.loopPromise.catch(() => {});
111
+ this.loopPromise = null;
112
+ }
113
+ loggerInstance.info("RpcCaller stopped");
114
+ }
115
+
116
+ async call<T = unknown>(
117
+ target: string,
118
+ method: string,
119
+ data: unknown,
120
+ requestStreamPrefix: string,
121
+ sourceApp: string,
122
+ options: CallOptions = {}
123
+ ): Promise<T> {
124
+ if (target === "*") {
125
+ throw new RemoteError(
126
+ "call() does not support broadcast target '*' — use emit() for fan-out",
127
+ { code: "INVALID_TARGET" }
128
+ );
129
+ }
130
+ if (this.draining || !this.running) {
131
+ throw new RemoteError(
132
+ `RPC ${method} rejected — RemoteManager draining/stopped`,
133
+ { code: "SHUTDOWN" }
134
+ );
135
+ }
136
+
137
+ this.metrics?.rpcCalled();
138
+
139
+ const timeout = options.timeout ?? this.config.defaultTimeout;
140
+ const correlationId = crypto.randomUUID();
141
+ const deadline = Date.now() + timeout;
142
+ const requestStream = `${requestStreamPrefix}${target}`;
143
+
144
+ return new Promise<T>((resolve, reject) => {
145
+ const timer = setTimeout(() => {
146
+ if (this.pending.delete(correlationId)) {
147
+ this.metrics?.rpcTimedOut();
148
+ reject(
149
+ new RemoteError(
150
+ `RPC ${method} to "${target}" timed out after ${timeout}ms`,
151
+ { code: "TIMEOUT", sourceApp: target }
152
+ )
153
+ );
154
+ }
155
+ }, timeout);
156
+
157
+ this.pending.set(correlationId, {
158
+ resolve: (v) => {
159
+ this.metrics?.rpcSucceeded();
160
+ (resolve as (x: unknown) => void)(v);
161
+ },
162
+ reject: (err) => {
163
+ this.metrics?.rpcFailed();
164
+ reject(err);
165
+ },
166
+ timer,
167
+ method,
168
+ target,
169
+ });
170
+
171
+ const envelope = JSON.stringify({
172
+ kind: "rpc_request",
173
+ sourceApp,
174
+ event: method,
175
+ data,
176
+ emittedAt: Date.now(),
177
+ correlationId,
178
+ replyTo: this.config.responseStream,
179
+ deadline,
180
+ });
181
+
182
+ const publish = this.breaker
183
+ ? this.breaker.exec(() =>
184
+ this.publisher.xadd(requestStream, "*", "data", envelope)
185
+ )
186
+ : this.publisher.xadd(
187
+ requestStream,
188
+ "*",
189
+ "data",
190
+ envelope
191
+ );
192
+
193
+ Promise.resolve(publish)
194
+ .then((id) => {
195
+ if (this.config.enableLogging) {
196
+ loggerInstance.debug(
197
+ `call → ${requestStream} method=${method} id=${id} cid=${correlationId}`
198
+ );
199
+ }
200
+ })
201
+ .catch((err) => {
202
+ if (this.pending.delete(correlationId)) {
203
+ clearTimeout(timer);
204
+ const code =
205
+ (err && (err as any).code) === "CIRCUIT_OPEN"
206
+ ? "CIRCUIT_OPEN"
207
+ : "PUBLISH_FAILED";
208
+ reject(
209
+ new RemoteError(
210
+ `Failed to publish RPC request: ${err?.message ?? err}`,
211
+ { code, sourceApp: target }
212
+ )
213
+ );
214
+ }
215
+ });
216
+ });
217
+ }
218
+
219
+ private async listenLoop(): Promise<void> {
220
+ while (this.running) {
221
+ try {
222
+ const result: any = await (this.listenerRedis as any).xread(
223
+ "COUNT",
224
+ 50,
225
+ "BLOCK",
226
+ 2000,
227
+ "STREAMS",
228
+ this.config.responseStream,
229
+ this.lastId
230
+ );
231
+
232
+ if (!result || !this.running) continue;
233
+
234
+ for (const [, entries] of result) {
235
+ for (const [msgId, fields] of entries) {
236
+ this.lastId = msgId;
237
+ const response = this.parseResponse(fields);
238
+ if (response) {
239
+ this.dispatchResponse(response);
240
+ }
241
+ }
242
+ }
243
+ } catch (error: any) {
244
+ if (!this.running) break;
245
+ if (String(error?.message).includes("Connection is closed")) {
246
+ break;
247
+ }
248
+ loggerInstance.error(
249
+ { err: error, msg: "Response listen error" }
250
+ );
251
+ await this.sleep(500);
252
+ }
253
+ }
254
+ }
255
+
256
+ private dispatchResponse(response: RpcResponse): void {
257
+ const entry = this.pending.get(response.correlationId);
258
+ if (!entry) {
259
+ // Orphan response — caller already timed out or never existed
260
+ if (this.config.enableLogging) {
261
+ loggerInstance.debug(
262
+ `Orphan response cid=${response.correlationId}`
263
+ );
264
+ }
265
+ return;
266
+ }
267
+ clearTimeout(entry.timer);
268
+ this.pending.delete(response.correlationId);
269
+
270
+ if (response.success) {
271
+ entry.resolve(response.result);
272
+ } else {
273
+ entry.reject(
274
+ new RemoteError(response.error.message, {
275
+ code: response.error.code,
276
+ sourceApp: response.sourceApp,
277
+ extensions: response.error.extensions,
278
+ })
279
+ );
280
+ }
281
+ }
282
+
283
+ private parseResponse(fields: string[]): RpcResponse | null {
284
+ let payload: string | undefined;
285
+ for (let i = 0; i < fields.length - 1; i += 2) {
286
+ if (fields[i] === "data") {
287
+ payload = fields[i + 1];
288
+ break;
289
+ }
290
+ }
291
+ if (!payload) return null;
292
+ try {
293
+ const parsed = JSON.parse(payload);
294
+ if (
295
+ !parsed ||
296
+ typeof parsed.correlationId !== "string" ||
297
+ typeof parsed.success !== "boolean"
298
+ ) {
299
+ return null;
300
+ }
301
+ return parsed as RpcResponse;
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
307
+ private sleep(ms: number): Promise<void> {
308
+ return new Promise((r) => setTimeout(r, ms));
309
+ }
310
+ }