bunsane 0.3.2 → 0.5.0

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 (220) hide show
  1. package/CHANGELOG.md +471 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +93 -1020
  4. package/core/EntityHookManager.ts +52 -754
  5. package/core/Logger.ts +10 -0
  6. package/core/RequestContext.ts +94 -85
  7. package/core/RequestLoaders.ts +98 -5
  8. package/core/SchedulerManager.ts +28 -600
  9. package/core/app/cors.ts +2 -11
  10. package/core/app/preparedStatementWarmup.ts +9 -49
  11. package/core/app/requestRouter.ts +9 -8
  12. package/core/app/restRegistry.ts +8 -0
  13. package/core/archetype/fieldResolvers.ts +85 -40
  14. package/core/archetype/relationLoader.ts +135 -92
  15. package/core/cache/CacheManager.ts +91 -302
  16. package/core/cache/CompressionUtils.ts +34 -3
  17. package/core/cache/MemoryCache.ts +40 -37
  18. package/core/cache/RedisCache.ts +8 -7
  19. package/core/cache/health.ts +30 -0
  20. package/core/cache/invalidation.ts +96 -0
  21. package/core/cache/strategies/writeInvalidate.ts +111 -0
  22. package/core/cache/strategies/writeThrough.ts +233 -0
  23. package/core/components/BaseComponent.ts +25 -10
  24. package/core/components/ComponentRegistry.ts +28 -0
  25. package/core/decorators/IndexedField.ts +1 -1
  26. package/core/entity/cacheStrategies.ts +97 -0
  27. package/core/entity/componentAccess.ts +383 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/getCacheManager.ts +10 -0
  30. package/core/entity/pendingOps.ts +72 -0
  31. package/core/entity/saveEntity.ts +375 -0
  32. package/core/health.ts +93 -4
  33. package/core/hooks/dispatcher.ts +439 -0
  34. package/core/hooks/guards.ts +155 -0
  35. package/core/hooks/registry.ts +247 -0
  36. package/core/metadata/definitions/Component.ts +1 -1
  37. package/core/metadata/index.ts +15 -4
  38. package/core/middleware/RateLimit.ts +102 -105
  39. package/core/middleware/RequestId.ts +2 -9
  40. package/core/middleware/SecurityHeaders.ts +2 -11
  41. package/core/middleware/headers.ts +28 -0
  42. package/core/remote/OutboxWorker.ts +213 -183
  43. package/core/remote/RemoteManager.ts +401 -400
  44. package/core/remote/StreamConsumer.ts +535 -535
  45. package/core/remote/types.ts +153 -151
  46. package/core/requestScope.ts +34 -0
  47. package/core/scheduler/cronEvaluator.ts +174 -0
  48. package/core/scheduler/lifecycleHooks.ts +21 -0
  49. package/core/scheduler/lockCoordinator.ts +27 -0
  50. package/core/scheduler/metrics.ts +14 -0
  51. package/core/scheduler/taskRunner.ts +420 -0
  52. package/core/validateEnv.ts +10 -0
  53. package/database/DatabaseHelper.ts +128 -101
  54. package/database/IndexingStrategy.ts +72 -2
  55. package/database/PreparedStatementCache.ts +8 -2
  56. package/database/cancellable.ts +35 -22
  57. package/database/index.ts +29 -3
  58. package/database/instrumentedDb.ts +141 -141
  59. package/database/sqlHelpers.ts +3 -1
  60. package/endpoints/archetypes.ts +2 -8
  61. package/endpoints/tables.ts +6 -1
  62. package/gql/index.ts +1 -1
  63. package/gql/schema/index.ts +15 -4
  64. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  65. package/package.json +22 -1
  66. package/query/CTENode.ts +5 -3
  67. package/query/ComponentInclusionNode.ts +245 -14
  68. package/query/OrNode.ts +8 -19
  69. package/query/Query.ts +208 -79
  70. package/query/QueryContext.ts +6 -0
  71. package/query/QueryDAG.ts +7 -2
  72. package/query/membershipSource.ts +66 -0
  73. package/storage/LocalStorageProvider.ts +8 -3
  74. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  75. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  76. package/studio/{index.html → dist/index.html} +3 -2
  77. package/swagger/generator.ts +11 -1
  78. package/upload/UploadManager.ts +8 -6
  79. package/utils/uuid.ts +40 -10
  80. package/.claude/scheduled_tasks.lock +0 -1
  81. package/.claude/settings.local.json +0 -47
  82. package/.prettierrc +0 -4
  83. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  84. package/.serena/memories/architecture.md +0 -154
  85. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  86. package/.serena/memories/code_style_and_conventions.md +0 -76
  87. package/.serena/memories/project_overview.md +0 -43
  88. package/.serena/memories/schema-dsl-plan.md +0 -107
  89. package/.serena/memories/suggested_commands.md +0 -80
  90. package/.serena/memories/typescript-compilation-status.md +0 -54
  91. package/.serena/project.yml +0 -114
  92. package/BunSane.jpg +0 -0
  93. package/CLAUDE.md +0 -198
  94. package/TODO.md +0 -2
  95. package/bun.lock +0 -302
  96. package/bunfig.toml +0 -10
  97. package/docs/RFC_APP_REFACTOR.md +0 -248
  98. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  99. package/docs/SCALABILITY_PLAN.md +0 -175
  100. package/studio/bun.lock +0 -482
  101. package/studio/package.json +0 -39
  102. package/studio/postcss.config.js +0 -6
  103. package/studio/src/components/DataTable.tsx +0 -211
  104. package/studio/src/components/Layout.tsx +0 -13
  105. package/studio/src/components/PageContainer.tsx +0 -9
  106. package/studio/src/components/PageHeader.tsx +0 -13
  107. package/studio/src/components/SearchBar.tsx +0 -57
  108. package/studio/src/components/Sidebar.tsx +0 -294
  109. package/studio/src/components/ui/button.tsx +0 -56
  110. package/studio/src/components/ui/checkbox.tsx +0 -26
  111. package/studio/src/components/ui/input.tsx +0 -25
  112. package/studio/src/hooks/useDataTable.ts +0 -131
  113. package/studio/src/index.css +0 -36
  114. package/studio/src/lib/api.ts +0 -186
  115. package/studio/src/lib/utils.ts +0 -13
  116. package/studio/src/main.tsx +0 -17
  117. package/studio/src/pages/ArcheType.tsx +0 -239
  118. package/studio/src/pages/Components.tsx +0 -124
  119. package/studio/src/pages/EntityInspector.tsx +0 -302
  120. package/studio/src/pages/QueryRunner.tsx +0 -246
  121. package/studio/src/pages/Table.tsx +0 -94
  122. package/studio/src/pages/Welcome.tsx +0 -241
  123. package/studio/src/routes.tsx +0 -45
  124. package/studio/src/store/archeTypeSettings.ts +0 -30
  125. package/studio/src/store/studio.ts +0 -65
  126. package/studio/src/utils/columnHelpers.tsx +0 -114
  127. package/studio/studio-instructions.md +0 -81
  128. package/studio/tailwind.config.js +0 -77
  129. package/studio/utils.ts +0 -54
  130. package/studio/vite.config.js +0 -19
  131. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  132. package/tests/benchmark/bunfig.toml +0 -9
  133. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  134. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  135. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  136. package/tests/benchmark/fixtures/index.ts +0 -6
  137. package/tests/benchmark/index.ts +0 -22
  138. package/tests/benchmark/noop-preload.ts +0 -3
  139. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  140. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  141. package/tests/benchmark/runners/index.ts +0 -4
  142. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  143. package/tests/benchmark/scripts/generate-db.ts +0 -344
  144. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  145. package/tests/e2e/http.test.ts +0 -130
  146. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  147. package/tests/fixtures/components/TestOrder.ts +0 -23
  148. package/tests/fixtures/components/TestProduct.ts +0 -23
  149. package/tests/fixtures/components/TestUser.ts +0 -20
  150. package/tests/fixtures/components/index.ts +0 -6
  151. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  152. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  153. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  154. package/tests/helpers/MockRedisClient.ts +0 -113
  155. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  156. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  157. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  158. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  159. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  160. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  161. package/tests/integration/query/Query.abort.test.ts +0 -66
  162. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  163. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  164. package/tests/integration/query/Query.exec.test.ts +0 -576
  165. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  166. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  167. package/tests/integration/remote/dlq.test.ts +0 -175
  168. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  169. package/tests/integration/remote/outbox.test.ts +0 -130
  170. package/tests/integration/remote/rpc.test.ts +0 -177
  171. package/tests/pglite-setup.ts +0 -62
  172. package/tests/setup.ts +0 -164
  173. package/tests/stress/BenchmarkRunner.ts +0 -203
  174. package/tests/stress/DataSeeder.ts +0 -190
  175. package/tests/stress/StressTestReporter.ts +0 -229
  176. package/tests/stress/cursor-perf-test.ts +0 -171
  177. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  178. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  179. package/tests/stress/index.ts +0 -7
  180. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  181. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  182. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  183. package/tests/unit/BatchLoader.test.ts +0 -196
  184. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  185. package/tests/unit/cache/CacheManager.test.ts +0 -498
  186. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  187. package/tests/unit/cache/RedisCache.test.ts +0 -411
  188. package/tests/unit/database/cancellable.test.ts +0 -81
  189. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  190. package/tests/unit/entity/Entity.components.test.ts +0 -317
  191. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  192. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  193. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  194. package/tests/unit/entity/Entity.test.ts +0 -345
  195. package/tests/unit/gql/depthLimit.test.ts +0 -203
  196. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  197. package/tests/unit/health/Health.test.ts +0 -129
  198. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  199. package/tests/unit/middleware/Middleware.test.ts +0 -98
  200. package/tests/unit/middleware/RequestId.test.ts +0 -54
  201. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  202. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  203. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  204. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  205. package/tests/unit/query/Query.test.ts +0 -310
  206. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  207. package/tests/unit/remote/RemoteError.test.ts +0 -55
  208. package/tests/unit/remote/decorators.test.ts +0 -195
  209. package/tests/unit/remote/metrics.test.ts +0 -115
  210. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  211. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  212. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  213. package/tests/unit/schema/schema-integration.test.ts +0 -426
  214. package/tests/unit/schema/schema.test.ts +0 -580
  215. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  216. package/tests/unit/upload/RestUpload.test.ts +0 -267
  217. package/tests/unit/validateEnv.test.ts +0 -82
  218. package/tests/utils/entity-tracker.ts +0 -57
  219. package/tests/utils/index.ts +0 -13
  220. package/tests/utils/test-context.ts +0 -149
@@ -1,535 +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
- }
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 (reclaimed && 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
+ }