@hotmeshio/hotmesh 0.6.1 → 0.7.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 (55) hide show
  1. package/README.md +179 -142
  2. package/build/modules/enums.d.ts +7 -0
  3. package/build/modules/enums.js +16 -1
  4. package/build/modules/utils.d.ts +27 -0
  5. package/build/modules/utils.js +52 -1
  6. package/build/package.json +10 -8
  7. package/build/services/connector/providers/postgres.js +3 -0
  8. package/build/services/hotmesh/index.d.ts +66 -15
  9. package/build/services/hotmesh/index.js +84 -15
  10. package/build/services/memflow/index.d.ts +100 -14
  11. package/build/services/memflow/index.js +100 -14
  12. package/build/services/memflow/worker.d.ts +97 -0
  13. package/build/services/memflow/worker.js +217 -0
  14. package/build/services/memflow/workflow/proxyActivities.d.ts +74 -3
  15. package/build/services/memflow/workflow/proxyActivities.js +81 -4
  16. package/build/services/router/consumption/index.d.ts +2 -1
  17. package/build/services/router/consumption/index.js +38 -2
  18. package/build/services/router/error-handling/index.d.ts +3 -3
  19. package/build/services/router/error-handling/index.js +48 -13
  20. package/build/services/router/index.d.ts +1 -0
  21. package/build/services/router/index.js +2 -1
  22. package/build/services/store/index.d.ts +3 -2
  23. package/build/services/store/providers/postgres/kvtypes/hash/basic.js +36 -6
  24. package/build/services/store/providers/postgres/kvtypes/hash/expire.js +12 -2
  25. package/build/services/store/providers/postgres/kvtypes/hash/scan.js +30 -10
  26. package/build/services/store/providers/postgres/kvtypes/list.js +68 -10
  27. package/build/services/store/providers/postgres/kvtypes/string.js +60 -10
  28. package/build/services/store/providers/postgres/kvtypes/zset.js +92 -22
  29. package/build/services/store/providers/postgres/postgres.d.ts +3 -3
  30. package/build/services/store/providers/redis/_base.d.ts +3 -3
  31. package/build/services/store/providers/redis/ioredis.js +17 -7
  32. package/build/services/stream/providers/postgres/kvtables.js +76 -23
  33. package/build/services/stream/providers/postgres/lifecycle.d.ts +19 -0
  34. package/build/services/stream/providers/postgres/lifecycle.js +54 -0
  35. package/build/services/stream/providers/postgres/messages.d.ts +56 -0
  36. package/build/services/stream/providers/postgres/messages.js +253 -0
  37. package/build/services/stream/providers/postgres/notifications.d.ts +59 -0
  38. package/build/services/stream/providers/postgres/notifications.js +357 -0
  39. package/build/services/stream/providers/postgres/postgres.d.ts +110 -11
  40. package/build/services/stream/providers/postgres/postgres.js +196 -488
  41. package/build/services/stream/providers/postgres/scout.d.ts +68 -0
  42. package/build/services/stream/providers/postgres/scout.js +233 -0
  43. package/build/services/stream/providers/postgres/stats.d.ts +49 -0
  44. package/build/services/stream/providers/postgres/stats.js +113 -0
  45. package/build/services/sub/providers/postgres/postgres.js +37 -5
  46. package/build/services/sub/providers/redis/ioredis.js +13 -2
  47. package/build/services/sub/providers/redis/redis.js +13 -2
  48. package/build/services/worker/index.d.ts +1 -0
  49. package/build/services/worker/index.js +2 -0
  50. package/build/types/hotmesh.d.ts +42 -2
  51. package/build/types/index.d.ts +3 -3
  52. package/build/types/memflow.d.ts +32 -0
  53. package/build/types/provider.d.ts +16 -0
  54. package/build/types/stream.d.ts +92 -1
  55. package/package.json +10 -8
@@ -1,139 +1,171 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
2
25
  Object.defineProperty(exports, "__esModule", { value: true });
3
26
  exports.PostgresStreamService = void 0;
4
27
  const key_1 = require("../../../../modules/key");
5
- const utils_1 = require("../../../../modules/utils");
6
28
  const index_1 = require("../../index");
7
29
  const kvtables_1 = require("./kvtables");
30
+ const Stats = __importStar(require("./stats"));
31
+ const Messages = __importStar(require("./messages"));
32
+ const scout_1 = require("./scout");
33
+ const notifications_1 = require("./notifications");
34
+ const Lifecycle = __importStar(require("./lifecycle"));
35
+ /**
36
+ * PostgreSQL Stream Service
37
+ *
38
+ * High-performance stream message provider using PostgreSQL with LISTEN/NOTIFY.
39
+ *
40
+ * ## Module Organization
41
+ *
42
+ * This service is organized into focused modules following KISS principles:
43
+ * - `postgres.ts` (this file) - Main orchestrator and service interface
44
+ * - `kvtables.ts` - Schema deployment and table management
45
+ * - `messages.ts` - Message CRUD operations (publish, fetch, ack, delete)
46
+ * - `stats.ts` - Statistics and query operations
47
+ * - `scout.ts` - Scout role coordination for polling visible messages
48
+ * - `notifications.ts` - LISTEN/NOTIFY notification system with static state management
49
+ * - `lifecycle.ts` - Stream and consumer group lifecycle operations
50
+ *
51
+ * ## Lifecycle
52
+ *
53
+ * ### Initialization (`init`)
54
+ * 1. Deploy PostgreSQL schema (tables, indexes, triggers, functions)
55
+ * 2. Create ScoutManager for coordinating visibility timeout polling
56
+ * 3. Create NotificationManager for LISTEN/NOTIFY event handling
57
+ * 4. Set up notification handler (once per client, shared across instances)
58
+ * 5. Start fallback poller (backup for missed notifications)
59
+ * 6. Start router scout poller (for visibility timeout processing)
60
+ *
61
+ * ### Shutdown (`cleanup`)
62
+ * 1. Stop router scout polling loop
63
+ * 2. Release scout role if held
64
+ * 3. Stop notification consumers for this instance
65
+ * 4. UNLISTEN from channels when last instance disconnects
66
+ * 5. Clean up fallback poller when last instance disconnects
67
+ * 6. Remove notification handlers when last instance disconnects
68
+ *
69
+ * ## Notification System (LISTEN/NOTIFY)
70
+ *
71
+ * ### Real-time Message Delivery
72
+ * - PostgreSQL trigger on INSERT sends NOTIFY when messages are immediately visible
73
+ * - Messages with visibility timeout are NOT notified on INSERT
74
+ * - Multiple service instances share the same client and notification handlers
75
+ * - Static state ensures only ONE LISTEN per channel across all instances
76
+ *
77
+ * ### Components
78
+ * - **Notification Handler**: Listens for PostgreSQL NOTIFY events
79
+ * - **Fallback Poller**: Polls every 30s (default) for missed messages
80
+ * - **Router Scout**: Active role-holder polls visible messages frequently (~100ms)
81
+ * - **Visibility Function**: `notify_visible_messages()` checks for expired timeouts
82
+ *
83
+ * ## Scout Role (Visibility Timeout Processing)
84
+ *
85
+ * When messages are published with visibility timeouts (delays), they need to be
86
+ * processed when they become visible. The scout role ensures this happens efficiently:
87
+ *
88
+ * 1. **Role Acquisition**: One instance per app acquires "router" scout role
89
+ * 2. **Fast Polling**: Scout polls `notify_visible_messages()` every ~100ms
90
+ * 3. **Notification**: Function triggers NOTIFY for streams with visible messages
91
+ * 4. **Role Rotation**: Role expires after interval, another instance can claim it
92
+ * 5. **Fallback**: Non-scouts sleep longer, try to acquire role periodically
93
+ *
94
+ * ## Message Flow
95
+ *
96
+ * ### Publishing
97
+ * 1. Messages inserted into partitioned table
98
+ * 2. If immediately visible → INSERT trigger sends NOTIFY
99
+ * 3. If visibility timeout → no NOTIFY (scout will handle when visible)
100
+ *
101
+ * ### Consuming (Event-Driven)
102
+ * 1. Consumer calls `consumeMessages` with notification callback
103
+ * 2. Service executes LISTEN on channel `stream_{name}_{group}`
104
+ * 3. On NOTIFY → fetch messages → invoke callback
105
+ * 4. Initial fetch done immediately (catch any queued messages)
106
+ *
107
+ * ### Consuming (Polling)
108
+ * 1. Consumer calls `consumeMessages` without callback
109
+ * 2. Service directly queries and reserves messages
110
+ * 3. Returns messages synchronously
111
+ *
112
+ * ## Reliability Guarantees
113
+ *
114
+ * - **Notification Fallback**: Poller catches missed notifications every 30s
115
+ * - **Visibility Scout**: Ensures delayed messages are processed when visible
116
+ * - **Graceful Degradation**: Falls back to polling if LISTEN fails
117
+ * - **Shared State**: Multiple instances coordinate via static maps
118
+ * - **Race Condition Safe**: SKIP LOCKED prevents message duplication
119
+ *
120
+ * @example
121
+ * ```typescript
122
+ * // Initialize service
123
+ * const service = new PostgresStreamService(client, storeClient, config);
124
+ * await service.init('namespace', 'appId', logger);
125
+ *
126
+ * // Event-driven consumption (recommended)
127
+ * await service.consumeMessages('stream', 'group', 'consumer', {
128
+ * notificationCallback: (messages) => {
129
+ * // Process messages in real-time
130
+ * }
131
+ * });
132
+ *
133
+ * // Polling consumption
134
+ * const messages = await service.consumeMessages('stream', 'group', 'consumer', {
135
+ * batchSize: 10
136
+ * });
137
+ *
138
+ * // Cleanup on shutdown
139
+ * await service.cleanup();
140
+ * ```
141
+ */
8
142
  class PostgresStreamService extends index_1.StreamService {
9
143
  constructor(streamClient, storeClient, config = {}) {
10
144
  super(streamClient, storeClient, config);
11
- // Instance-level tracking for cleanup
12
- this.instanceNotificationConsumers = new Set();
13
- this.notificationHandlerBound = this.handleNotification.bind(this);
14
145
  }
15
146
  async init(namespace, appId, logger) {
16
147
  this.namespace = namespace;
17
148
  this.appId = appId;
18
149
  this.logger = logger;
19
150
  await (0, kvtables_1.deploySchema)(this.streamClient, this.appId, this.logger);
151
+ // Initialize scout manager
152
+ this.scoutManager = new scout_1.ScoutManager(this.streamClient, this.appId, this.getTableName.bind(this), this.mintKey.bind(this), this.logger);
153
+ // Initialize notification manager
154
+ this.notificationManager = new notifications_1.NotificationManager(this.streamClient, this.getTableName.bind(this), () => (0, notifications_1.getFallbackInterval)(this.config), this.logger);
20
155
  // Set up notification handler if supported
21
156
  if (this.streamClient.on && this.isNotificationsEnabled()) {
22
- this.setupClientNotificationHandler();
23
- this.startClientFallbackPoller();
157
+ this.notificationManager.setupClientNotificationHandler(this);
158
+ this.notificationManager.startClientFallbackPoller(this.checkForMissedMessages.bind(this));
159
+ this.scoutManager.startRouterScoutPoller();
24
160
  }
25
161
  }
26
- setupClientNotificationHandler() {
27
- // Check if notification handler is already set up for this client
28
- if (PostgresStreamService.clientNotificationHandlers.get(this.streamClient)) {
29
- return;
30
- }
31
- // Initialize notification consumer map for this client if it doesn't exist
32
- if (!PostgresStreamService.clientNotificationConsumers.has(this.streamClient)) {
33
- PostgresStreamService.clientNotificationConsumers.set(this.streamClient, new Map());
34
- }
35
- // Set up the notification handler for this client
36
- this.streamClient.on('notification', this.handleNotification.bind(this));
37
- // Mark this client as having a notification handler
38
- PostgresStreamService.clientNotificationHandlers.set(this.streamClient, true);
39
- }
40
- startClientFallbackPoller() {
41
- // Check if fallback poller already exists for this client
42
- if (PostgresStreamService.clientFallbackPollers.has(this.streamClient)) {
43
- return;
44
- }
45
- const fallbackIntervalId = setInterval(() => {
46
- this.checkForMissedMessages();
47
- }, this.getFallbackInterval());
48
- PostgresStreamService.clientFallbackPollers.set(this.streamClient, fallbackIntervalId);
49
- }
50
162
  isNotificationsEnabled() {
51
- return this.config?.postgres?.enableNotifications !== false; // Default: true
52
- }
53
- getFallbackInterval() {
54
- return this.config?.postgres?.notificationFallbackInterval || 30000; // Default: 30 seconds
55
- }
56
- getNotificationTimeout() {
57
- return this.config?.postgres?.notificationTimeout || 5000; // Default: 5 seconds
163
+ return Stats.isNotificationsEnabled(this.config);
58
164
  }
59
165
  async checkForMissedMessages() {
60
- const now = Date.now();
61
- const clientNotificationConsumers = PostgresStreamService.clientNotificationConsumers.get(this.streamClient);
62
- if (!clientNotificationConsumers) {
63
- return;
64
- }
65
- for (const [consumerKey, instanceMap,] of clientNotificationConsumers.entries()) {
66
- for (const [instance, consumer] of instanceMap.entries()) {
67
- if (consumer.isListening &&
68
- now - consumer.lastFallbackCheck > this.getFallbackInterval()) {
69
- try {
70
- const messages = await instance.fetchMessages(consumer.streamName, consumer.groupName, consumer.consumerName, { batchSize: 10, enableBackoff: false, maxRetries: 1 });
71
- if (messages.length > 0) {
72
- instance.logger.debug('postgres-stream-fallback-messages', {
73
- streamName: consumer.streamName,
74
- groupName: consumer.groupName,
75
- messageCount: messages.length,
76
- });
77
- consumer.callback(messages);
78
- }
79
- consumer.lastFallbackCheck = now;
80
- }
81
- catch (error) {
82
- instance.logger.error('postgres-stream-fallback-error', {
83
- streamName: consumer.streamName,
84
- groupName: consumer.groupName,
85
- error,
86
- });
87
- }
88
- }
89
- }
90
- }
91
- }
92
- handleNotification(notification) {
93
- try {
94
- // Only handle stream notifications (channels starting with "stream_")
95
- // Ignore pub/sub notifications from sub provider which use different channel names
96
- if (!notification.channel.startsWith('stream_')) {
97
- // This is likely a pub/sub notification from the sub provider, ignore it
98
- this.logger.debug('postgres-stream-ignoring-sub-notification', {
99
- channel: notification.channel,
100
- payloadPreview: notification.payload.substring(0, 100),
101
- });
102
- return;
103
- }
104
- this.logger.debug('postgres-stream-processing-notification', {
105
- channel: notification.channel,
106
- });
107
- const payload = JSON.parse(notification.payload);
108
- const { stream_name, group_name } = payload;
109
- if (!stream_name || !group_name) {
110
- this.logger.warn('postgres-stream-invalid-notification', {
111
- notification,
112
- });
113
- return;
114
- }
115
- const consumerKey = this.getConsumerKey(stream_name, group_name);
116
- const clientNotificationConsumers = PostgresStreamService.clientNotificationConsumers.get(this.streamClient);
117
- if (!clientNotificationConsumers) {
118
- return;
119
- }
120
- const instanceMap = clientNotificationConsumers.get(consumerKey);
121
- if (!instanceMap) {
122
- return;
123
- }
124
- // Trigger immediate message fetch for all instances with this consumer
125
- for (const [instance, consumer] of instanceMap.entries()) {
126
- if (consumer.isListening) {
127
- instance.fetchAndDeliverMessages(consumer);
128
- }
129
- }
130
- }
131
- catch (error) {
132
- this.logger.error('postgres-stream-notification-parse-error', {
133
- notification,
134
- error,
135
- });
136
- }
166
+ await this.notificationManager.checkForMissedMessages(async (instance, consumer) => {
167
+ return await instance.fetchMessages(consumer.streamName, consumer.groupName, consumer.consumerName, { batchSize: 10, enableBackoff: false, maxRetries: 1 });
168
+ });
137
169
  }
138
170
  async fetchAndDeliverMessages(consumer) {
139
171
  try {
@@ -171,43 +203,16 @@ class PostgresStreamService extends index_1.StreamService {
171
203
  return appId.replace(/[^a-zA-Z0-9_]/g, '_');
172
204
  }
173
205
  async createStream(streamName) {
174
- return true;
206
+ return Lifecycle.createStream(streamName);
175
207
  }
176
208
  async deleteStream(streamName) {
177
- const client = this.streamClient;
178
- const tableName = this.getTableName();
179
- try {
180
- if (streamName === '*') {
181
- await client.query(`DELETE FROM ${tableName}`);
182
- }
183
- else {
184
- await client.query(`DELETE FROM ${tableName} WHERE stream_name = $1`, [
185
- streamName,
186
- ]);
187
- }
188
- return true;
189
- }
190
- catch (error) {
191
- this.logger.error(`postgres-stream-delete-error-${streamName}`, {
192
- error,
193
- });
194
- throw error;
195
- }
209
+ return Lifecycle.deleteStream(this.streamClient, this.getTableName(), streamName, this.logger);
196
210
  }
197
211
  async createConsumerGroup(streamName, groupName) {
198
- return true;
212
+ return Lifecycle.createConsumerGroup(streamName, groupName);
199
213
  }
200
214
  async deleteConsumerGroup(streamName, groupName) {
201
- const client = this.streamClient;
202
- const tableName = this.getTableName();
203
- try {
204
- await client.query(`DELETE FROM ${tableName} WHERE stream_name = $1 AND group_name = $2`, [streamName, groupName]);
205
- return true;
206
- }
207
- catch (error) {
208
- this.logger.error(`postgres-stream-delete-group-error-${streamName}.${groupName}`, { error });
209
- throw error;
210
- }
215
+ return Lifecycle.deleteConsumerGroup(this.streamClient, this.getTableName(), streamName, groupName, this.logger);
211
216
  }
212
217
  /**
213
218
  * `publishMessages` can be roped into a transaction by the `store`
@@ -227,40 +232,10 @@ class PostgresStreamService extends index_1.StreamService {
227
232
  * allows calls to the stream to be roped into a single SQL transaction.
228
233
  */
229
234
  async publishMessages(streamName, messages, options) {
230
- const { sql, params } = this._publishMessages(streamName, messages);
231
- if (options?.transaction &&
232
- typeof options.transaction.addCommand === 'function') {
233
- //call addCommand and return the transaction object
234
- options.transaction.addCommand(sql, params, 'array', (rows) => rows.map((row) => row.id.toString()));
235
- return options.transaction;
236
- }
237
- else {
238
- try {
239
- const ids = [];
240
- const res = await this.streamClient.query(sql, params);
241
- for (const row of res.rows) {
242
- ids.push(row.id.toString());
243
- }
244
- return ids;
245
- }
246
- catch (error) {
247
- this.logger.error(`postgres-stream-publish-error-${streamName}`, {
248
- error,
249
- });
250
- throw error;
251
- }
252
- }
235
+ return Messages.publishMessages(this.streamClient, this.getTableName(), streamName, messages, options, this.logger);
253
236
  }
254
- _publishMessages(streamName, messages) {
255
- const tableName = this.getTableName();
256
- const groupName = streamName.endsWith(':') ? 'ENGINE' : 'WORKER';
257
- const insertValues = messages
258
- .map((_, idx) => `($1, $2, $${idx + 3})`)
259
- .join(', ');
260
- return {
261
- sql: `INSERT INTO ${tableName} (stream_name, group_name, message) VALUES ${insertValues} RETURNING id`,
262
- params: [streamName, groupName, ...messages],
263
- };
237
+ _publishMessages(streamName, messages, options) {
238
+ return Messages.buildPublishSQL(this.getTableName(), streamName, messages, options);
264
239
  }
265
240
  async consumeMessages(streamName, groupName, consumerName, options) {
266
241
  // If notification callback is provided and notifications are enabled, set up listener
@@ -279,354 +254,87 @@ class PostgresStreamService extends index_1.StreamService {
279
254
  return enabled && this.streamClient.on !== undefined;
280
255
  }
281
256
  async setupNotificationConsumer(streamName, groupName, consumerName, callback, options) {
282
- const startTime = Date.now();
283
- const consumerKey = this.getConsumerKey(streamName, groupName);
284
- const channelName = (0, kvtables_1.getNotificationChannelName)(streamName, groupName);
285
- // Get or create notification consumer map for this client
286
- let clientNotificationConsumers = PostgresStreamService.clientNotificationConsumers.get(this.streamClient);
287
- if (!clientNotificationConsumers) {
288
- clientNotificationConsumers = new Map();
289
- PostgresStreamService.clientNotificationConsumers.set(this.streamClient, clientNotificationConsumers);
290
- }
291
- // Get or create instance map for this consumer key
292
- let instanceMap = clientNotificationConsumers.get(consumerKey);
293
- if (!instanceMap) {
294
- instanceMap = new Map();
295
- clientNotificationConsumers.set(consumerKey, instanceMap);
296
- // Set up LISTEN for this channel (only once per channel across all instances)
297
- try {
298
- const listenStart = Date.now();
299
- await this.streamClient.query(`LISTEN "${channelName}"`);
300
- this.logger.debug('postgres-stream-listen-start', {
301
- streamName,
302
- groupName,
303
- channelName,
304
- listenDuration: Date.now() - listenStart,
305
- });
306
- }
307
- catch (error) {
308
- this.logger.error('postgres-stream-listen-error', {
309
- streamName,
310
- groupName,
311
- channelName,
312
- error,
313
- });
314
- // Fall back to polling if LISTEN fails
315
- return this.fetchMessages(streamName, groupName, consumerName, options);
316
- }
317
- }
318
- // Register or update consumer for this instance
319
- const consumer = {
320
- streamName,
321
- groupName,
322
- consumerName,
323
- callback,
324
- isListening: true,
325
- lastFallbackCheck: Date.now(),
326
- };
327
- instanceMap.set(this, consumer);
328
- // Track this consumer for cleanup
329
- this.instanceNotificationConsumers.add(consumerKey);
330
- this.logger.debug('postgres-stream-notification-setup-complete', {
331
- streamName,
332
- groupName,
333
- instanceCount: instanceMap.size,
334
- setupDuration: Date.now() - startTime,
335
- });
336
- // Do an initial fetch asynchronously to avoid blocking setup
337
- // This ensures we don't miss any messages that were already in the queue
338
- setImmediate(async () => {
339
- try {
340
- const fetchStart = Date.now();
341
- const initialMessages = await this.fetchMessages(streamName, groupName, consumerName, {
342
- ...options,
343
- enableBackoff: false,
344
- maxRetries: 1,
345
- });
346
- this.logger.debug('postgres-stream-initial-fetch-complete', {
347
- streamName,
348
- groupName,
349
- messageCount: initialMessages.length,
350
- fetchDuration: Date.now() - fetchStart,
351
- });
352
- // If we got messages, call the callback
353
- if (initialMessages.length > 0) {
354
- callback(initialMessages);
355
- }
356
- }
357
- catch (error) {
358
- this.logger.error('postgres-stream-initial-fetch-error', {
359
- streamName,
360
- groupName,
361
- error,
362
- });
363
- }
364
- });
365
- // Return empty array immediately to avoid blocking
366
- return [];
367
- }
368
- async stopNotificationConsumer(streamName, groupName) {
369
- const consumerKey = this.getConsumerKey(streamName, groupName);
370
- const clientNotificationConsumers = PostgresStreamService.clientNotificationConsumers.get(this.streamClient);
371
- if (!clientNotificationConsumers) {
372
- return;
373
- }
374
- const instanceMap = clientNotificationConsumers.get(consumerKey);
375
- if (!instanceMap) {
376
- return;
377
- }
378
- const consumer = instanceMap.get(this);
379
- if (consumer) {
380
- consumer.isListening = false;
381
- instanceMap.delete(this);
382
- // Remove from instance tracking
383
- this.instanceNotificationConsumers.delete(consumerKey);
384
- // If no more instances for this consumer key, stop listening and clean up
385
- if (instanceMap.size === 0) {
386
- clientNotificationConsumers.delete(consumerKey);
387
- const channelName = (0, kvtables_1.getNotificationChannelName)(streamName, groupName);
257
+ try {
258
+ await this.notificationManager.setupNotificationConsumer(this, streamName, groupName, consumerName, callback);
259
+ // Do an initial fetch asynchronously to avoid blocking setup
260
+ setImmediate(async () => {
388
261
  try {
389
- await this.streamClient.query(`UNLISTEN "${channelName}"`);
390
- this.logger.debug('postgres-stream-unlisten', {
262
+ const fetchStart = Date.now();
263
+ const initialMessages = await this.fetchMessages(streamName, groupName, consumerName, {
264
+ ...options,
265
+ enableBackoff: false,
266
+ maxRetries: 1,
267
+ });
268
+ this.logger.debug('postgres-stream-initial-fetch-complete', {
391
269
  streamName,
392
270
  groupName,
393
- channelName,
271
+ messageCount: initialMessages.length,
272
+ fetchDuration: Date.now() - fetchStart,
394
273
  });
274
+ // If we got messages, call the callback
275
+ if (initialMessages.length > 0) {
276
+ callback(initialMessages);
277
+ }
395
278
  }
396
279
  catch (error) {
397
- this.logger.error('postgres-stream-unlisten-error', {
280
+ this.logger.error('postgres-stream-initial-fetch-error', {
398
281
  streamName,
399
282
  groupName,
400
- channelName,
401
283
  error,
402
284
  });
403
285
  }
404
- }
405
- }
406
- }
407
- async fetchMessages(streamName, groupName, consumerName, options) {
408
- const client = this.streamClient;
409
- const tableName = this.getTableName();
410
- const enableBackoff = options?.enableBackoff ?? false;
411
- const initialBackoff = options?.initialBackoff ?? 100; // Default initial backoff: 100ms
412
- const maxBackoff = options?.maxBackoff ?? 3000; // Default max backoff: 3 seconds
413
- const maxRetries = options?.maxRetries ?? 3; // Set a finite default, e.g., 3 retries
414
- let backoff = initialBackoff;
415
- let retries = 0;
416
- try {
417
- while (retries < maxRetries) {
418
- retries++;
419
- const batchSize = options?.batchSize || 1;
420
- const reservationTimeout = options?.reservationTimeout || 30;
421
- // Simplified query for better performance - especially for notification-triggered fetches
422
- const res = await client.query(`UPDATE ${tableName}
423
- SET reserved_at = NOW(), reserved_by = $4
424
- WHERE id IN (
425
- SELECT id FROM ${tableName}
426
- WHERE stream_name = $1
427
- AND group_name = $2
428
- AND (reserved_at IS NULL OR reserved_at < NOW() - INTERVAL '${reservationTimeout} seconds')
429
- AND expired_at IS NULL
430
- ORDER BY id
431
- LIMIT $3
432
- FOR UPDATE SKIP LOCKED
433
- )
434
- RETURNING id, message`, [streamName, groupName, batchSize, consumerName]);
435
- const messages = res.rows.map((row) => ({
436
- id: row.id.toString(),
437
- data: (0, utils_1.parseStreamMessage)(row.message),
438
- }));
439
- if (messages.length > 0 || !enableBackoff) {
440
- return messages;
441
- }
442
- // Apply backoff if enabled and no messages found
443
- await (0, utils_1.sleepFor)(backoff);
444
- backoff = Math.min(backoff * 2, maxBackoff); // Exponential backoff
445
- }
446
- // Return empty array if maxRetries is reached and still no messages
286
+ });
287
+ // Return empty array immediately to avoid blocking
447
288
  return [];
448
289
  }
449
290
  catch (error) {
450
- this.logger.error(`postgres-stream-consumer-error-${streamName}`, {
451
- error,
452
- });
453
- throw error;
291
+ // Fall back to polling if setup fails
292
+ return this.fetchMessages(streamName, groupName, consumerName, options);
454
293
  }
455
294
  }
295
+ async stopNotificationConsumer(streamName, groupName) {
296
+ await this.notificationManager.stopNotificationConsumer(this, streamName, groupName);
297
+ }
298
+ async fetchMessages(streamName, groupName, consumerName, options) {
299
+ return Messages.fetchMessages(this.streamClient, this.getTableName(), streamName, groupName, consumerName, options || {}, this.logger);
300
+ }
456
301
  async ackAndDelete(streamName, groupName, messageIds) {
457
- return await this.deleteMessages(streamName, groupName, messageIds);
302
+ return Messages.ackAndDelete(this.streamClient, this.getTableName(), streamName, groupName, messageIds, this.logger);
458
303
  }
459
304
  async acknowledgeMessages(streamName, groupName, messageIds, options) {
460
- // No-op for this implementation
461
- return messageIds.length;
305
+ return Messages.acknowledgeMessages(messageIds);
462
306
  }
463
307
  async deleteMessages(streamName, groupName, messageIds, options) {
464
- const client = this.streamClient;
465
- const tableName = this.getTableName();
466
- try {
467
- const ids = messageIds.map((id) => parseInt(id));
468
- // Perform a soft delete by setting `expired_at` to the current timestamp
469
- await client.query(`UPDATE ${tableName}
470
- SET expired_at = NOW()
471
- WHERE stream_name = $1 AND id = ANY($2::bigint[]) AND group_name = $3`, [streamName, ids, groupName]);
472
- return messageIds.length;
473
- }
474
- catch (error) {
475
- this.logger.error(`postgres-stream-delete-error-${streamName}`, {
476
- error,
477
- });
478
- throw error;
479
- }
308
+ return Messages.deleteMessages(this.streamClient, this.getTableName(), streamName, groupName, messageIds, this.logger);
480
309
  }
481
310
  async retryMessages(streamName, groupName, options) {
482
- // Implement retry logic if needed
483
- return [];
311
+ return Messages.retryMessages(streamName, groupName, options);
484
312
  }
485
313
  async getStreamStats(streamName) {
486
- const client = this.streamClient;
487
- const tableName = this.getTableName();
488
- try {
489
- const res = await client.query(`SELECT COUNT(*) AS available_count
490
- FROM ${tableName}
491
- WHERE stream_name = $1 AND expired_at IS NULL`, [streamName]);
492
- return {
493
- messageCount: parseInt(res.rows[0].available_count, 10),
494
- };
495
- }
496
- catch (error) {
497
- this.logger.error(`postgres-stream-stats-error-${streamName}`, { error });
498
- throw error;
499
- }
314
+ return Stats.getStreamStats(this.streamClient, this.getTableName(), streamName, this.logger);
500
315
  }
501
316
  async getStreamDepth(streamName) {
502
- const stats = await this.getStreamStats(streamName);
503
- return stats.messageCount;
317
+ return Stats.getStreamDepth(this.streamClient, this.getTableName(), streamName, this.logger);
504
318
  }
505
319
  async getStreamDepths(streamNames) {
506
- const client = this.streamClient;
507
- const tableName = this.getTableName();
508
- try {
509
- const streams = streamNames.map((s) => s.stream);
510
- const res = await client.query(`SELECT stream_name, COUNT(*) AS count
511
- FROM ${tableName}
512
- WHERE stream_name = ANY($1::text[])
513
- GROUP BY stream_name`, [streams]);
514
- const result = res.rows.map((row) => ({
515
- stream: row.stream_name,
516
- depth: parseInt(row.count, 10),
517
- }));
518
- return result;
519
- }
520
- catch (error) {
521
- this.logger.error('postgres-stream-depth-error', { error });
522
- throw error;
523
- }
320
+ return Stats.getStreamDepths(this.streamClient, this.getTableName(), streamNames, this.logger);
524
321
  }
525
322
  async trimStream(streamName, options) {
526
- const client = this.streamClient;
527
- const tableName = this.getTableName();
528
- try {
529
- let expiredCount = 0;
530
- if (options.maxLen !== undefined) {
531
- const res = await client.query(`WITH to_expire AS (
532
- SELECT id FROM ${tableName}
533
- WHERE stream_name = $1
534
- ORDER BY id ASC
535
- OFFSET $2
536
- )
537
- UPDATE ${tableName}
538
- SET expired_at = NOW()
539
- WHERE id IN (SELECT id FROM to_expire)`, [streamName, options.maxLen]);
540
- expiredCount += res.rowCount;
541
- }
542
- if (options.maxAge !== undefined) {
543
- const res = await client.query(`UPDATE ${tableName}
544
- SET expired_at = NOW()
545
- WHERE stream_name = $1 AND created_at < NOW() - INTERVAL '${options.maxAge} milliseconds'`, [streamName]);
546
- expiredCount += res.rowCount;
547
- }
548
- return expiredCount;
549
- }
550
- catch (error) {
551
- this.logger.error(`postgres-stream-trim-error-${streamName}`, { error });
552
- throw error;
553
- }
323
+ return Stats.trimStream(this.streamClient, this.getTableName(), streamName, options, this.logger);
554
324
  }
555
325
  getProviderSpecificFeatures() {
556
- return {
557
- supportsBatching: true,
558
- supportsDeadLetterQueue: false,
559
- supportsOrdering: true,
560
- supportsTrimming: true,
561
- supportsRetry: false,
562
- supportsNotifications: this.isNotificationsEnabled(),
563
- maxMessageSize: 1024 * 1024,
564
- maxBatchSize: 256,
565
- };
326
+ return Stats.getProviderSpecificFeatures(this.config);
566
327
  }
567
328
  // Cleanup method to be called when shutting down
568
329
  async cleanup() {
569
- // Clean up this instance's notification consumers
570
- const clientNotificationConsumers = PostgresStreamService.clientNotificationConsumers.get(this.streamClient);
571
- if (clientNotificationConsumers) {
572
- // Remove this instance from all consumer maps
573
- for (const consumerKey of this.instanceNotificationConsumers) {
574
- const instanceMap = clientNotificationConsumers.get(consumerKey);
575
- if (instanceMap) {
576
- const consumer = instanceMap.get(this);
577
- if (consumer) {
578
- consumer.isListening = false;
579
- instanceMap.delete(this);
580
- // If no more instances for this consumer, stop listening
581
- if (instanceMap.size === 0) {
582
- clientNotificationConsumers.delete(consumerKey);
583
- const channelName = (0, kvtables_1.getNotificationChannelName)(consumer.streamName, consumer.groupName);
584
- try {
585
- await this.streamClient.query(`UNLISTEN "${channelName}"`);
586
- this.logger.debug('postgres-stream-cleanup-unlisten', {
587
- streamName: consumer.streamName,
588
- groupName: consumer.groupName,
589
- channelName,
590
- });
591
- }
592
- catch (error) {
593
- this.logger.error('postgres-stream-cleanup-unlisten-error', {
594
- streamName: consumer.streamName,
595
- groupName: consumer.groupName,
596
- channelName,
597
- error,
598
- });
599
- }
600
- }
601
- }
602
- }
603
- }
330
+ // Stop router scout polling loop
331
+ if (this.scoutManager) {
332
+ await this.scoutManager.stopRouterScoutPoller();
604
333
  }
605
- // Clear instance tracking
606
- this.instanceNotificationConsumers.clear();
607
- // If no more consumers exist for this client, clean up static resources
608
- if (clientNotificationConsumers && clientNotificationConsumers.size === 0) {
609
- // Remove client from static maps
610
- PostgresStreamService.clientNotificationConsumers.delete(this.streamClient);
611
- PostgresStreamService.clientNotificationHandlers.delete(this.streamClient);
612
- // Stop fallback poller for this client
613
- const fallbackIntervalId = PostgresStreamService.clientFallbackPollers.get(this.streamClient);
614
- if (fallbackIntervalId) {
615
- clearInterval(fallbackIntervalId);
616
- PostgresStreamService.clientFallbackPollers.delete(this.streamClient);
617
- }
618
- // Remove notification handler
619
- if (this.streamClient.removeAllListeners) {
620
- this.streamClient.removeAllListeners('notification');
621
- }
622
- else if (this.streamClient.off && this.notificationHandlerBound) {
623
- this.streamClient.off('notification', this.notificationHandlerBound);
624
- }
334
+ // Clean up notification consumers
335
+ if (this.notificationManager) {
336
+ await this.notificationManager.cleanup(this);
625
337
  }
626
338
  }
627
339
  }
628
340
  exports.PostgresStreamService = PostgresStreamService;
629
- // Static maps to manage notifications across all instances sharing the same client
630
- PostgresStreamService.clientNotificationConsumers = new Map();
631
- PostgresStreamService.clientNotificationHandlers = new Map();
632
- PostgresStreamService.clientFallbackPollers = new Map();