@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.
- package/README.md +179 -142
- package/build/modules/enums.d.ts +7 -0
- package/build/modules/enums.js +16 -1
- package/build/modules/utils.d.ts +27 -0
- package/build/modules/utils.js +52 -1
- package/build/package.json +10 -8
- package/build/services/connector/providers/postgres.js +3 -0
- package/build/services/hotmesh/index.d.ts +66 -15
- package/build/services/hotmesh/index.js +84 -15
- package/build/services/memflow/index.d.ts +100 -14
- package/build/services/memflow/index.js +100 -14
- package/build/services/memflow/worker.d.ts +97 -0
- package/build/services/memflow/worker.js +217 -0
- package/build/services/memflow/workflow/proxyActivities.d.ts +74 -3
- package/build/services/memflow/workflow/proxyActivities.js +81 -4
- package/build/services/router/consumption/index.d.ts +2 -1
- package/build/services/router/consumption/index.js +38 -2
- package/build/services/router/error-handling/index.d.ts +3 -3
- package/build/services/router/error-handling/index.js +48 -13
- package/build/services/router/index.d.ts +1 -0
- package/build/services/router/index.js +2 -1
- package/build/services/store/index.d.ts +3 -2
- package/build/services/store/providers/postgres/kvtypes/hash/basic.js +36 -6
- package/build/services/store/providers/postgres/kvtypes/hash/expire.js +12 -2
- package/build/services/store/providers/postgres/kvtypes/hash/scan.js +30 -10
- package/build/services/store/providers/postgres/kvtypes/list.js +68 -10
- package/build/services/store/providers/postgres/kvtypes/string.js +60 -10
- package/build/services/store/providers/postgres/kvtypes/zset.js +92 -22
- package/build/services/store/providers/postgres/postgres.d.ts +3 -3
- package/build/services/store/providers/redis/_base.d.ts +3 -3
- package/build/services/store/providers/redis/ioredis.js +17 -7
- package/build/services/stream/providers/postgres/kvtables.js +76 -23
- package/build/services/stream/providers/postgres/lifecycle.d.ts +19 -0
- package/build/services/stream/providers/postgres/lifecycle.js +54 -0
- package/build/services/stream/providers/postgres/messages.d.ts +56 -0
- package/build/services/stream/providers/postgres/messages.js +253 -0
- package/build/services/stream/providers/postgres/notifications.d.ts +59 -0
- package/build/services/stream/providers/postgres/notifications.js +357 -0
- package/build/services/stream/providers/postgres/postgres.d.ts +110 -11
- package/build/services/stream/providers/postgres/postgres.js +196 -488
- package/build/services/stream/providers/postgres/scout.d.ts +68 -0
- package/build/services/stream/providers/postgres/scout.js +233 -0
- package/build/services/stream/providers/postgres/stats.d.ts +49 -0
- package/build/services/stream/providers/postgres/stats.js +113 -0
- package/build/services/sub/providers/postgres/postgres.js +37 -5
- package/build/services/sub/providers/redis/ioredis.js +13 -2
- package/build/services/sub/providers/redis/redis.js +13 -2
- package/build/services/worker/index.d.ts +1 -0
- package/build/services/worker/index.js +2 -0
- package/build/types/hotmesh.d.ts +42 -2
- package/build/types/index.d.ts +3 -3
- package/build/types/memflow.d.ts +32 -0
- package/build/types/provider.d.ts +16 -0
- package/build/types/stream.d.ts +92 -1
- 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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
206
|
+
return Lifecycle.createStream(streamName);
|
|
175
207
|
}
|
|
176
208
|
async deleteStream(streamName) {
|
|
177
|
-
|
|
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
|
|
212
|
+
return Lifecycle.createConsumerGroup(streamName, groupName);
|
|
199
213
|
}
|
|
200
214
|
async deleteConsumerGroup(streamName, groupName) {
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
390
|
-
this.
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
451
|
-
|
|
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
|
|
302
|
+
return Messages.ackAndDelete(this.streamClient, this.getTableName(), streamName, groupName, messageIds, this.logger);
|
|
458
303
|
}
|
|
459
304
|
async acknowledgeMessages(streamName, groupName, messageIds, options) {
|
|
460
|
-
|
|
461
|
-
return messageIds.length;
|
|
305
|
+
return Messages.acknowledgeMessages(messageIds);
|
|
462
306
|
}
|
|
463
307
|
async deleteMessages(streamName, groupName, messageIds, options) {
|
|
464
|
-
|
|
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
|
-
|
|
483
|
-
return [];
|
|
311
|
+
return Messages.retryMessages(streamName, groupName, options);
|
|
484
312
|
}
|
|
485
313
|
async getStreamStats(streamName) {
|
|
486
|
-
|
|
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
|
-
|
|
503
|
-
return stats.messageCount;
|
|
317
|
+
return Stats.getStreamDepth(this.streamClient, this.getTableName(), streamName, this.logger);
|
|
504
318
|
}
|
|
505
319
|
async getStreamDepths(streamNames) {
|
|
506
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
//
|
|
606
|
-
this.
|
|
607
|
-
|
|
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();
|