@hotmeshio/hotmesh 0.10.2 → 0.11.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 +1 -1
- package/build/modules/errors.d.ts +2 -0
- package/build/modules/errors.js +2 -0
- package/build/modules/key.js +3 -2
- package/build/package.json +2 -2
- package/build/services/activities/worker.js +10 -0
- package/build/services/dba/index.d.ts +2 -1
- package/build/services/dba/index.js +11 -2
- package/build/services/durable/client.js +6 -1
- package/build/services/durable/exporter.d.ts +15 -0
- package/build/services/durable/exporter.js +343 -5
- package/build/services/durable/schemas/factory.d.ts +1 -1
- package/build/services/durable/schemas/factory.js +27 -4
- package/build/services/durable/worker.d.ts +2 -2
- package/build/services/durable/worker.js +15 -9
- package/build/services/durable/workflow/context.js +2 -0
- package/build/services/durable/workflow/execChild.js +5 -2
- package/build/services/durable/workflow/hook.js +6 -0
- package/build/services/durable/workflow/proxyActivities.js +3 -4
- package/build/services/engine/index.js +5 -3
- package/build/services/store/index.d.ts +40 -0
- package/build/services/store/providers/postgres/exporter-sql.d.ts +23 -0
- package/build/services/store/providers/postgres/exporter-sql.js +52 -0
- package/build/services/store/providers/postgres/kvtables.js +6 -0
- package/build/services/store/providers/postgres/postgres.d.ts +34 -0
- package/build/services/store/providers/postgres/postgres.js +99 -0
- package/build/services/stream/providers/postgres/kvtables.d.ts +1 -1
- package/build/services/stream/providers/postgres/kvtables.js +175 -82
- package/build/services/stream/providers/postgres/lifecycle.d.ts +4 -3
- package/build/services/stream/providers/postgres/lifecycle.js +6 -5
- package/build/services/stream/providers/postgres/messages.d.ts +9 -6
- package/build/services/stream/providers/postgres/messages.js +121 -75
- package/build/services/stream/providers/postgres/notifications.d.ts +5 -2
- package/build/services/stream/providers/postgres/notifications.js +39 -35
- package/build/services/stream/providers/postgres/postgres.d.ts +20 -118
- package/build/services/stream/providers/postgres/postgres.js +83 -140
- package/build/services/stream/registry.d.ts +62 -0
- package/build/services/stream/registry.js +198 -0
- package/build/services/worker/index.js +20 -6
- package/build/types/durable.d.ts +6 -1
- package/build/types/error.d.ts +2 -0
- package/build/types/exporter.d.ts +39 -0
- package/build/types/hotmesh.d.ts +7 -1
- package/build/types/stream.d.ts +2 -0
- package/package.json +2 -2
|
@@ -8,11 +8,10 @@ const utils_1 = require("../../../../modules/utils");
|
|
|
8
8
|
* When a transaction is provided, the SQL is added to the transaction
|
|
9
9
|
* and executed atomically with other operations.
|
|
10
10
|
*/
|
|
11
|
-
async function publishMessages(client, tableName, streamName, messages, options, logger) {
|
|
12
|
-
const { sql, params } = buildPublishSQL(tableName, streamName, messages, options);
|
|
11
|
+
async function publishMessages(client, tableName, streamName, isEngine, messages, options, logger) {
|
|
12
|
+
const { sql, params } = buildPublishSQL(tableName, streamName, isEngine, messages, options);
|
|
13
13
|
if (options?.transaction &&
|
|
14
14
|
typeof options.transaction.addCommand === 'function') {
|
|
15
|
-
// Add to transaction and return the transaction object
|
|
16
15
|
options.transaction.addCommand(sql, params, 'array', (rows) => rows.map((row) => row.id.toString()));
|
|
17
16
|
return options.transaction;
|
|
18
17
|
}
|
|
@@ -36,11 +35,11 @@ async function publishMessages(client, tableName, streamName, messages, options,
|
|
|
36
35
|
exports.publishMessages = publishMessages;
|
|
37
36
|
/**
|
|
38
37
|
* Build SQL for publishing messages with retry policies and visibility delays.
|
|
39
|
-
*
|
|
38
|
+
* Routes to engine_streams or worker_streams based on isEngine flag.
|
|
39
|
+
* Worker messages include a workflow_name column extracted from metadata.wfn.
|
|
40
40
|
*/
|
|
41
|
-
function buildPublishSQL(tableName, streamName, messages, options) {
|
|
42
|
-
|
|
43
|
-
// Parse messages to extract retry config and visibility options
|
|
41
|
+
function buildPublishSQL(tableName, streamName, isEngine, messages, options) {
|
|
42
|
+
// Parse messages to extract retry config, visibility options, and workflow name
|
|
44
43
|
const parsedMessages = messages.map(msg => {
|
|
45
44
|
const data = JSON.parse(msg);
|
|
46
45
|
const retryConfig = data._streamRetryConfig;
|
|
@@ -50,6 +49,8 @@ function buildPublishSQL(tableName, streamName, messages, options) {
|
|
|
50
49
|
delete data._streamRetryConfig;
|
|
51
50
|
delete data._visibilityDelayMs;
|
|
52
51
|
delete data._retryAttempt;
|
|
52
|
+
// Extract workflow name for worker streams
|
|
53
|
+
const workflowName = data.metadata?.wfn || '';
|
|
53
54
|
// Determine if this message has explicit retry config
|
|
54
55
|
const hasExplicitConfig = (retryConfig && 'max_retry_attempts' in retryConfig) || options?.retryPolicy;
|
|
55
56
|
let normalizedPolicy = null;
|
|
@@ -69,63 +70,106 @@ function buildPublishSQL(tableName, streamName, messages, options) {
|
|
|
69
70
|
retryPolicy: normalizedPolicy,
|
|
70
71
|
visibilityDelayMs: visibilityDelayMs || 0,
|
|
71
72
|
retryAttempt: retryAttempt || 0,
|
|
73
|
+
workflowName,
|
|
72
74
|
};
|
|
73
75
|
});
|
|
74
|
-
const params = [streamName
|
|
76
|
+
const params = [streamName];
|
|
75
77
|
let valuesClauses = [];
|
|
76
78
|
let insertColumns;
|
|
77
|
-
// Check if ALL messages have explicit config or ALL don't
|
|
78
79
|
const allHaveConfig = parsedMessages.every(pm => pm.hasExplicitConfig);
|
|
79
80
|
const noneHaveConfig = parsedMessages.every(pm => !pm.hasExplicitConfig);
|
|
80
81
|
const hasVisibilityDelays = parsedMessages.some(pm => pm.visibilityDelayMs > 0);
|
|
81
|
-
if (
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
82
|
+
if (isEngine) {
|
|
83
|
+
// Engine table: no group_name, no workflow_name
|
|
84
|
+
if (noneHaveConfig && !hasVisibilityDelays) {
|
|
85
|
+
insertColumns = '(stream_name, message)';
|
|
86
|
+
parsedMessages.forEach((pm, idx) => {
|
|
87
|
+
const base = idx * 1;
|
|
88
|
+
valuesClauses.push(`($1, $${base + 2})`);
|
|
89
|
+
params.push(pm.message);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
else if (noneHaveConfig && hasVisibilityDelays) {
|
|
93
|
+
insertColumns = '(stream_name, message, visible_at, retry_attempt)';
|
|
94
|
+
parsedMessages.forEach((pm, idx) => {
|
|
95
|
+
const base = idx * 2;
|
|
96
|
+
if (pm.visibilityDelayMs > 0) {
|
|
97
|
+
const visibleAtSQL = `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`;
|
|
98
|
+
valuesClauses.push(`($1, $${base + 2}, ${visibleAtSQL}, $${base + 3})`);
|
|
99
|
+
params.push(pm.message, pm.retryAttempt);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
valuesClauses.push(`($1, $${base + 2}, DEFAULT, $${base + 3})`);
|
|
103
|
+
params.push(pm.message, pm.retryAttempt);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
insertColumns = '(stream_name, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)';
|
|
109
|
+
parsedMessages.forEach((pm) => {
|
|
110
|
+
const visibleAtClause = pm.visibilityDelayMs > 0
|
|
111
|
+
? `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`
|
|
112
|
+
: 'DEFAULT';
|
|
113
|
+
if (pm.hasExplicitConfig) {
|
|
114
|
+
const paramOffset = params.length + 1;
|
|
115
|
+
valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, ${visibleAtClause}, $${paramOffset + 4})`);
|
|
116
|
+
params.push(pm.message, pm.retryPolicy.max_retry_attempts, pm.retryPolicy.backoff_coefficient, pm.retryPolicy.maximum_interval_seconds, pm.retryAttempt);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const paramOffset = params.length + 1;
|
|
120
|
+
valuesClauses.push(`($1, $${paramOffset}, DEFAULT, DEFAULT, DEFAULT, ${visibleAtClause}, $${paramOffset + 1})`);
|
|
121
|
+
params.push(pm.message, pm.retryAttempt);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
105
125
|
}
|
|
106
126
|
else {
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
? `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`
|
|
112
|
-
: 'DEFAULT';
|
|
113
|
-
if (pm.hasExplicitConfig) {
|
|
114
|
-
const paramOffset = params.length + 1; // Current param count + 1 for next param
|
|
115
|
-
valuesClauses.push(`($1, $2, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, ${visibleAtClause}, $${paramOffset + 4})`);
|
|
116
|
-
params.push(pm.message, pm.retryPolicy.max_retry_attempts, pm.retryPolicy.backoff_coefficient, pm.retryPolicy.maximum_interval_seconds, pm.retryAttempt);
|
|
117
|
-
}
|
|
118
|
-
else {
|
|
119
|
-
// This message doesn't have config but others do - use DEFAULT keyword
|
|
127
|
+
// Worker table: includes workflow_name, no group_name
|
|
128
|
+
if (noneHaveConfig && !hasVisibilityDelays) {
|
|
129
|
+
insertColumns = '(stream_name, workflow_name, message)';
|
|
130
|
+
parsedMessages.forEach((pm) => {
|
|
120
131
|
const paramOffset = params.length + 1;
|
|
121
|
-
valuesClauses.push(`($1,
|
|
122
|
-
params.push(pm.
|
|
123
|
-
}
|
|
124
|
-
}
|
|
132
|
+
valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1})`);
|
|
133
|
+
params.push(pm.workflowName, pm.message);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
else if (noneHaveConfig && hasVisibilityDelays) {
|
|
137
|
+
insertColumns = '(stream_name, workflow_name, message, visible_at, retry_attempt)';
|
|
138
|
+
parsedMessages.forEach((pm) => {
|
|
139
|
+
const paramOffset = params.length + 1;
|
|
140
|
+
if (pm.visibilityDelayMs > 0) {
|
|
141
|
+
const visibleAtSQL = `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`;
|
|
142
|
+
valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, ${visibleAtSQL}, $${paramOffset + 2})`);
|
|
143
|
+
params.push(pm.workflowName, pm.message, pm.retryAttempt);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, DEFAULT, $${paramOffset + 2})`);
|
|
147
|
+
params.push(pm.workflowName, pm.message, pm.retryAttempt);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
insertColumns = '(stream_name, workflow_name, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)';
|
|
153
|
+
parsedMessages.forEach((pm) => {
|
|
154
|
+
const visibleAtClause = pm.visibilityDelayMs > 0
|
|
155
|
+
? `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`
|
|
156
|
+
: 'DEFAULT';
|
|
157
|
+
if (pm.hasExplicitConfig) {
|
|
158
|
+
const paramOffset = params.length + 1;
|
|
159
|
+
valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, $${paramOffset + 4}, ${visibleAtClause}, $${paramOffset + 5})`);
|
|
160
|
+
params.push(pm.workflowName, pm.message, pm.retryPolicy.max_retry_attempts, pm.retryPolicy.backoff_coefficient, pm.retryPolicy.maximum_interval_seconds, pm.retryAttempt);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const paramOffset = params.length + 1;
|
|
164
|
+
valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, DEFAULT, DEFAULT, DEFAULT, ${visibleAtClause}, $${paramOffset + 2})`);
|
|
165
|
+
params.push(pm.workflowName, pm.message, pm.retryAttempt);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
125
169
|
}
|
|
126
170
|
return {
|
|
127
171
|
sql: `INSERT INTO ${tableName} ${insertColumns}
|
|
128
|
-
VALUES ${valuesClauses.join(', ')}
|
|
172
|
+
VALUES ${valuesClauses.join(', ')}
|
|
129
173
|
RETURNING id`,
|
|
130
174
|
params,
|
|
131
175
|
};
|
|
@@ -134,38 +178,39 @@ exports.buildPublishSQL = buildPublishSQL;
|
|
|
134
178
|
/**
|
|
135
179
|
* Fetch messages from the stream with optional exponential backoff.
|
|
136
180
|
* Uses SKIP LOCKED for high-concurrency consumption.
|
|
181
|
+
* No group_name filter needed - the table itself determines engine vs worker.
|
|
137
182
|
*/
|
|
138
|
-
async function fetchMessages(client, tableName, streamName,
|
|
183
|
+
async function fetchMessages(client, tableName, streamName, isEngine, consumerName, options = {}, logger) {
|
|
139
184
|
const enableBackoff = options?.enableBackoff ?? false;
|
|
140
|
-
const initialBackoff = options?.initialBackoff ?? 100;
|
|
141
|
-
const maxBackoff = options?.maxBackoff ?? 3000;
|
|
142
|
-
const maxRetries = options?.maxRetries ?? 3;
|
|
185
|
+
const initialBackoff = options?.initialBackoff ?? 100;
|
|
186
|
+
const maxBackoff = options?.maxBackoff ?? 3000;
|
|
187
|
+
const maxRetries = options?.maxRetries ?? 3;
|
|
143
188
|
let backoff = initialBackoff;
|
|
144
189
|
let retries = 0;
|
|
190
|
+
// Include workflow_name in RETURNING for worker streams
|
|
191
|
+
const returningClause = isEngine
|
|
192
|
+
? 'id, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, retry_attempt'
|
|
193
|
+
: 'id, message, workflow_name, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, retry_attempt';
|
|
145
194
|
try {
|
|
146
195
|
while (retries < maxRetries) {
|
|
147
196
|
retries++;
|
|
148
197
|
const batchSize = options?.batchSize || 1;
|
|
149
198
|
const reservationTimeout = options?.reservationTimeout || 30;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
SET reserved_at = NOW(), reserved_by = $4
|
|
199
|
+
const res = await client.query(`UPDATE ${tableName}
|
|
200
|
+
SET reserved_at = NOW(), reserved_by = $3
|
|
153
201
|
WHERE id IN (
|
|
154
202
|
SELECT id FROM ${tableName}
|
|
155
|
-
WHERE stream_name = $1
|
|
156
|
-
AND group_name = $2
|
|
203
|
+
WHERE stream_name = $1
|
|
157
204
|
AND (reserved_at IS NULL OR reserved_at < NOW() - INTERVAL '${reservationTimeout} seconds')
|
|
158
205
|
AND expired_at IS NULL
|
|
159
206
|
AND visible_at <= NOW()
|
|
160
207
|
ORDER BY id
|
|
161
|
-
LIMIT $
|
|
208
|
+
LIMIT $2
|
|
162
209
|
FOR UPDATE SKIP LOCKED
|
|
163
210
|
)
|
|
164
|
-
RETURNING
|
|
211
|
+
RETURNING ${returningClause}`, [streamName, batchSize, consumerName]);
|
|
165
212
|
const messages = res.rows.map((row) => {
|
|
166
213
|
const data = (0, utils_1.parseStreamMessage)(row.message);
|
|
167
|
-
// Inject retry policy only if not using default values
|
|
168
|
-
// Default values indicate old retry mechanism should be used (policies.retry)
|
|
169
214
|
const hasDefaultRetryPolicy = (row.max_retry_attempts === 3 || row.max_retry_attempts === 5) &&
|
|
170
215
|
parseFloat(row.backoff_coefficient) === 10 &&
|
|
171
216
|
row.maximum_interval_seconds === 120;
|
|
@@ -176,10 +221,15 @@ async function fetchMessages(client, tableName, streamName, groupName, consumerN
|
|
|
176
221
|
maximum_interval_seconds: row.maximum_interval_seconds,
|
|
177
222
|
};
|
|
178
223
|
}
|
|
179
|
-
// Inject retry_attempt from database
|
|
180
224
|
if (row.retry_attempt !== undefined && row.retry_attempt !== null) {
|
|
181
225
|
data._retryAttempt = row.retry_attempt;
|
|
182
226
|
}
|
|
227
|
+
// Inject workflow_name from DB column into metadata for dispatch routing
|
|
228
|
+
if (!isEngine && row.workflow_name) {
|
|
229
|
+
if (data.metadata) {
|
|
230
|
+
data.metadata.wfn = row.workflow_name;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
183
233
|
return {
|
|
184
234
|
id: row.id.toString(),
|
|
185
235
|
data,
|
|
@@ -193,11 +243,9 @@ async function fetchMessages(client, tableName, streamName, groupName, consumerN
|
|
|
193
243
|
if (messages.length > 0 || !enableBackoff) {
|
|
194
244
|
return messages;
|
|
195
245
|
}
|
|
196
|
-
// Apply backoff if enabled and no messages found
|
|
197
246
|
await (0, utils_1.sleepFor)(backoff);
|
|
198
|
-
backoff = Math.min(backoff * 2, maxBackoff);
|
|
247
|
+
backoff = Math.min(backoff * 2, maxBackoff);
|
|
199
248
|
}
|
|
200
|
-
// Return empty array if maxRetries is reached and still no messages
|
|
201
249
|
return [];
|
|
202
250
|
}
|
|
203
251
|
catch (error) {
|
|
@@ -212,20 +260,19 @@ exports.fetchMessages = fetchMessages;
|
|
|
212
260
|
* Acknowledge messages (no-op for PostgreSQL - uses soft delete pattern).
|
|
213
261
|
*/
|
|
214
262
|
async function acknowledgeMessages(messageIds) {
|
|
215
|
-
// No-op for this implementation
|
|
216
263
|
return messageIds.length;
|
|
217
264
|
}
|
|
218
265
|
exports.acknowledgeMessages = acknowledgeMessages;
|
|
219
266
|
/**
|
|
220
267
|
* Delete messages by soft-deleting them (setting expired_at).
|
|
268
|
+
* No group_name needed - stream_name + table is sufficient.
|
|
221
269
|
*/
|
|
222
|
-
async function deleteMessages(client, tableName, streamName,
|
|
270
|
+
async function deleteMessages(client, tableName, streamName, messageIds, logger) {
|
|
223
271
|
try {
|
|
224
272
|
const ids = messageIds.map((id) => parseInt(id));
|
|
225
|
-
// Perform a soft delete by setting `expired_at` to the current timestamp
|
|
226
273
|
await client.query(`UPDATE ${tableName}
|
|
227
274
|
SET expired_at = NOW()
|
|
228
|
-
WHERE stream_name = $1 AND id = ANY($2::bigint[])
|
|
275
|
+
WHERE stream_name = $1 AND id = ANY($2::bigint[])`, [streamName, ids]);
|
|
229
276
|
return messageIds.length;
|
|
230
277
|
}
|
|
231
278
|
catch (error) {
|
|
@@ -239,15 +286,14 @@ exports.deleteMessages = deleteMessages;
|
|
|
239
286
|
/**
|
|
240
287
|
* Acknowledge and delete messages in one operation.
|
|
241
288
|
*/
|
|
242
|
-
async function ackAndDelete(client, tableName, streamName,
|
|
243
|
-
return await deleteMessages(client, tableName, streamName,
|
|
289
|
+
async function ackAndDelete(client, tableName, streamName, messageIds, logger) {
|
|
290
|
+
return await deleteMessages(client, tableName, streamName, messageIds, logger);
|
|
244
291
|
}
|
|
245
292
|
exports.ackAndDelete = ackAndDelete;
|
|
246
293
|
/**
|
|
247
294
|
* Retry messages (placeholder for future implementation).
|
|
248
295
|
*/
|
|
249
296
|
async function retryMessages(streamName, groupName, options) {
|
|
250
|
-
// Implement retry logic if needed
|
|
251
297
|
return [];
|
|
252
298
|
}
|
|
253
299
|
exports.retryMessages = retryMessages;
|
|
@@ -5,6 +5,9 @@ import { ProviderClient } from '../../../../types/provider';
|
|
|
5
5
|
/**
|
|
6
6
|
* Manages PostgreSQL LISTEN/NOTIFY for stream message notifications.
|
|
7
7
|
* Handles static state shared across all service instances using the same client.
|
|
8
|
+
*
|
|
9
|
+
* Channel naming uses table-type prefixes (eng_ / wrk_) instead of group_name,
|
|
10
|
+
* since engine_streams and worker_streams are separate tables.
|
|
8
11
|
*/
|
|
9
12
|
export declare class NotificationManager<TService> {
|
|
10
13
|
private client;
|
|
@@ -27,15 +30,16 @@ export declare class NotificationManager<TService> {
|
|
|
27
30
|
startClientFallbackPoller(checkForMissedMessages: () => Promise<void>): void;
|
|
28
31
|
/**
|
|
29
32
|
* Check for missed messages (fallback polling).
|
|
30
|
-
* Handles errors gracefully to avoid noise during shutdown.
|
|
31
33
|
*/
|
|
32
34
|
checkForMissedMessages(fetchMessages: (instance: TService, consumer: NotificationConsumer) => Promise<StreamMessage[]>): Promise<void>;
|
|
33
35
|
/**
|
|
34
36
|
* Handle incoming PostgreSQL notification.
|
|
37
|
+
* Channels use table-type prefixes: eng_ for engine, wrk_ for worker.
|
|
35
38
|
*/
|
|
36
39
|
private handleNotification;
|
|
37
40
|
/**
|
|
38
41
|
* Set up notification consumer for a stream/group.
|
|
42
|
+
* Uses table-type channel naming (eng_ / wrk_).
|
|
39
43
|
*/
|
|
40
44
|
setupNotificationConsumer(serviceInstance: TService, streamName: string, groupName: string, consumerName: string, callback: (messages: StreamMessage[]) => void): Promise<void>;
|
|
41
45
|
/**
|
|
@@ -44,7 +48,6 @@ export declare class NotificationManager<TService> {
|
|
|
44
48
|
stopNotificationConsumer(serviceInstance: TService, streamName: string, groupName: string): Promise<void>;
|
|
45
49
|
/**
|
|
46
50
|
* Clean up notification consumers for this instance.
|
|
47
|
-
* Stops fallback poller FIRST to prevent race conditions during shutdown.
|
|
48
51
|
*/
|
|
49
52
|
cleanup(serviceInstance: TService): Promise<void>;
|
|
50
53
|
/**
|
|
@@ -6,6 +6,9 @@ const kvtables_1 = require("./kvtables");
|
|
|
6
6
|
/**
|
|
7
7
|
* Manages PostgreSQL LISTEN/NOTIFY for stream message notifications.
|
|
8
8
|
* Handles static state shared across all service instances using the same client.
|
|
9
|
+
*
|
|
10
|
+
* Channel naming uses table-type prefixes (eng_ / wrk_) instead of group_name,
|
|
11
|
+
* since engine_streams and worker_streams are separate tables.
|
|
9
12
|
*/
|
|
10
13
|
class NotificationManager {
|
|
11
14
|
constructor(client, getTableName, getFallbackInterval, logger) {
|
|
@@ -24,13 +27,10 @@ class NotificationManager {
|
|
|
24
27
|
if (NotificationManager.clientNotificationHandlers.get(this.client)) {
|
|
25
28
|
return;
|
|
26
29
|
}
|
|
27
|
-
// Initialize notification consumer map for this client
|
|
28
30
|
if (!NotificationManager.clientNotificationConsumers.has(this.client)) {
|
|
29
31
|
NotificationManager.clientNotificationConsumers.set(this.client, new Map());
|
|
30
32
|
}
|
|
31
|
-
// Set up the notification handler
|
|
32
33
|
this.client.on('notification', this.notificationHandlerBound);
|
|
33
|
-
// Mark this client as having a notification handler
|
|
34
34
|
NotificationManager.clientNotificationHandlers.set(this.client, true);
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
@@ -50,7 +50,6 @@ class NotificationManager {
|
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
52
|
* Check for missed messages (fallback polling).
|
|
53
|
-
* Handles errors gracefully to avoid noise during shutdown.
|
|
54
53
|
*/
|
|
55
54
|
async checkForMissedMessages(fetchMessages) {
|
|
56
55
|
const now = Date.now();
|
|
@@ -67,10 +66,7 @@ class NotificationManager {
|
|
|
67
66
|
}
|
|
68
67
|
}
|
|
69
68
|
catch (error) {
|
|
70
|
-
// Silently ignore errors during shutdown (client closed, etc.)
|
|
71
|
-
// Function might not exist in older schemas
|
|
72
69
|
if (error.message?.includes('Client was closed')) {
|
|
73
|
-
// Client is shutting down, silently return
|
|
74
70
|
return;
|
|
75
71
|
}
|
|
76
72
|
this.logger.debug('postgres-stream-visibility-function-unavailable', {
|
|
@@ -82,7 +78,6 @@ class NotificationManager {
|
|
|
82
78
|
if (!clientNotificationConsumers) {
|
|
83
79
|
return;
|
|
84
80
|
}
|
|
85
|
-
// Check consumers that haven't been checked recently
|
|
86
81
|
for (const [consumerKey, instanceMap,] of clientNotificationConsumers.entries()) {
|
|
87
82
|
for (const [instance, consumer] of instanceMap.entries()) {
|
|
88
83
|
if (consumer.isListening &&
|
|
@@ -100,9 +95,7 @@ class NotificationManager {
|
|
|
100
95
|
consumer.lastFallbackCheck = now;
|
|
101
96
|
}
|
|
102
97
|
catch (error) {
|
|
103
|
-
// Silently ignore errors during shutdown
|
|
104
98
|
if (error.message?.includes('Client was closed')) {
|
|
105
|
-
// Client is shutting down, stop checking this consumer
|
|
106
99
|
consumer.isListening = false;
|
|
107
100
|
return;
|
|
108
101
|
}
|
|
@@ -118,11 +111,12 @@ class NotificationManager {
|
|
|
118
111
|
}
|
|
119
112
|
/**
|
|
120
113
|
* Handle incoming PostgreSQL notification.
|
|
114
|
+
* Channels use table-type prefixes: eng_ for engine, wrk_ for worker.
|
|
121
115
|
*/
|
|
122
116
|
handleNotification(notification) {
|
|
123
117
|
try {
|
|
124
|
-
// Only handle stream notifications
|
|
125
|
-
if (!notification.channel.startsWith('
|
|
118
|
+
// Only handle stream notifications (eng_ or wrk_ prefixed)
|
|
119
|
+
if (!notification.channel.startsWith('eng_') && !notification.channel.startsWith('wrk_')) {
|
|
126
120
|
this.logger.debug('postgres-stream-ignoring-sub-notification', {
|
|
127
121
|
channel: notification.channel,
|
|
128
122
|
payloadPreview: notification.payload.substring(0, 100),
|
|
@@ -133,14 +127,16 @@ class NotificationManager {
|
|
|
133
127
|
channel: notification.channel,
|
|
134
128
|
});
|
|
135
129
|
const payload = JSON.parse(notification.payload);
|
|
136
|
-
const { stream_name,
|
|
137
|
-
if (!stream_name || !
|
|
130
|
+
const { stream_name, table_type } = payload;
|
|
131
|
+
if (!stream_name || !table_type) {
|
|
138
132
|
this.logger.warn('postgres-stream-invalid-notification', {
|
|
139
133
|
notification,
|
|
140
134
|
});
|
|
141
135
|
return;
|
|
142
136
|
}
|
|
143
|
-
|
|
137
|
+
// Derive groupName from table_type for consumer key lookup
|
|
138
|
+
const groupName = table_type === 'engine' ? 'ENGINE' : 'WORKER';
|
|
139
|
+
const consumerKey = this.getConsumerKey(stream_name, groupName);
|
|
144
140
|
const clientNotificationConsumers = NotificationManager.clientNotificationConsumers.get(this.client);
|
|
145
141
|
if (!clientNotificationConsumers) {
|
|
146
142
|
return;
|
|
@@ -149,7 +145,6 @@ class NotificationManager {
|
|
|
149
145
|
if (!instanceMap) {
|
|
150
146
|
return;
|
|
151
147
|
}
|
|
152
|
-
// Trigger immediate message fetch for all instances with this consumer
|
|
153
148
|
for (const [instance, consumer] of instanceMap.entries()) {
|
|
154
149
|
if (consumer.isListening) {
|
|
155
150
|
const serviceInstance = instance;
|
|
@@ -168,11 +163,21 @@ class NotificationManager {
|
|
|
168
163
|
}
|
|
169
164
|
/**
|
|
170
165
|
* Set up notification consumer for a stream/group.
|
|
166
|
+
* Uses table-type channel naming (eng_ / wrk_).
|
|
171
167
|
*/
|
|
172
168
|
async setupNotificationConsumer(serviceInstance, streamName, groupName, consumerName, callback) {
|
|
173
169
|
const startTime = Date.now();
|
|
174
|
-
const
|
|
175
|
-
|
|
170
|
+
const isEngine = groupName === 'ENGINE';
|
|
171
|
+
// Resolve the stream name to get the simplified form for channel naming and consumer key
|
|
172
|
+
const serviceAny = serviceInstance;
|
|
173
|
+
let resolvedStreamName = streamName;
|
|
174
|
+
if (serviceAny.resolveStreamTarget) {
|
|
175
|
+
const target = serviceAny.resolveStreamTarget(streamName);
|
|
176
|
+
resolvedStreamName = target.streamName;
|
|
177
|
+
}
|
|
178
|
+
// Use resolved stream name for consumer key so it matches notification payloads
|
|
179
|
+
const consumerKey = this.getConsumerKey(resolvedStreamName, groupName);
|
|
180
|
+
const channelName = (0, kvtables_1.getNotificationChannelName)(resolvedStreamName, isEngine);
|
|
176
181
|
// Get or create notification consumer map for this client
|
|
177
182
|
let clientNotificationConsumers = NotificationManager.clientNotificationConsumers.get(this.client);
|
|
178
183
|
if (!clientNotificationConsumers) {
|
|
@@ -202,7 +207,7 @@ class NotificationManager {
|
|
|
202
207
|
channelName,
|
|
203
208
|
error,
|
|
204
209
|
});
|
|
205
|
-
throw error;
|
|
210
|
+
throw error;
|
|
206
211
|
}
|
|
207
212
|
}
|
|
208
213
|
// Register consumer for this instance
|
|
@@ -215,7 +220,6 @@ class NotificationManager {
|
|
|
215
220
|
lastFallbackCheck: Date.now(),
|
|
216
221
|
};
|
|
217
222
|
instanceMap.set(serviceInstance, consumer);
|
|
218
|
-
// Track this consumer for cleanup
|
|
219
223
|
this.instanceNotificationConsumers.add(consumerKey);
|
|
220
224
|
this.logger.debug('postgres-stream-notification-setup-complete', {
|
|
221
225
|
streamName,
|
|
@@ -228,7 +232,14 @@ class NotificationManager {
|
|
|
228
232
|
* Stop notification consumer for a stream/group.
|
|
229
233
|
*/
|
|
230
234
|
async stopNotificationConsumer(serviceInstance, streamName, groupName) {
|
|
231
|
-
const
|
|
235
|
+
const isEngine = groupName === 'ENGINE';
|
|
236
|
+
const serviceAny = serviceInstance;
|
|
237
|
+
let resolvedStreamName = streamName;
|
|
238
|
+
if (serviceAny.resolveStreamTarget) {
|
|
239
|
+
const target = serviceAny.resolveStreamTarget(streamName);
|
|
240
|
+
resolvedStreamName = target.streamName;
|
|
241
|
+
}
|
|
242
|
+
const consumerKey = this.getConsumerKey(resolvedStreamName, groupName);
|
|
232
243
|
const clientNotificationConsumers = NotificationManager.clientNotificationConsumers.get(this.client);
|
|
233
244
|
if (!clientNotificationConsumers) {
|
|
234
245
|
return;
|
|
@@ -241,12 +252,10 @@ class NotificationManager {
|
|
|
241
252
|
if (consumer) {
|
|
242
253
|
consumer.isListening = false;
|
|
243
254
|
instanceMap.delete(serviceInstance);
|
|
244
|
-
// Remove from instance tracking
|
|
245
255
|
this.instanceNotificationConsumers.delete(consumerKey);
|
|
246
|
-
// If no more instances for this consumer key, stop listening
|
|
247
256
|
if (instanceMap.size === 0) {
|
|
248
257
|
clientNotificationConsumers.delete(consumerKey);
|
|
249
|
-
const channelName = (0, kvtables_1.getNotificationChannelName)(
|
|
258
|
+
const channelName = (0, kvtables_1.getNotificationChannelName)(resolvedStreamName, isEngine);
|
|
250
259
|
try {
|
|
251
260
|
await this.client.query(`UNLISTEN "${channelName}"`);
|
|
252
261
|
this.logger.debug('postgres-stream-unlisten', {
|
|
@@ -268,7 +277,6 @@ class NotificationManager {
|
|
|
268
277
|
}
|
|
269
278
|
/**
|
|
270
279
|
* Clean up notification consumers for this instance.
|
|
271
|
-
* Stops fallback poller FIRST to prevent race conditions during shutdown.
|
|
272
280
|
*/
|
|
273
281
|
async cleanup(serviceInstance) {
|
|
274
282
|
const clientNotificationConsumers = NotificationManager.clientNotificationConsumers.get(this.client);
|
|
@@ -279,7 +287,6 @@ class NotificationManager {
|
|
|
279
287
|
NotificationManager.clientFallbackPollers.delete(this.client);
|
|
280
288
|
}
|
|
281
289
|
if (clientNotificationConsumers) {
|
|
282
|
-
// Remove this instance from all consumer maps
|
|
283
290
|
for (const consumerKey of this.instanceNotificationConsumers) {
|
|
284
291
|
const instanceMap = clientNotificationConsumers.get(consumerKey);
|
|
285
292
|
if (instanceMap) {
|
|
@@ -287,10 +294,13 @@ class NotificationManager {
|
|
|
287
294
|
if (consumer) {
|
|
288
295
|
consumer.isListening = false;
|
|
289
296
|
instanceMap.delete(serviceInstance);
|
|
290
|
-
// If no more instances for this consumer, stop listening
|
|
291
297
|
if (instanceMap.size === 0) {
|
|
292
298
|
clientNotificationConsumers.delete(consumerKey);
|
|
293
|
-
|
|
299
|
+
// Extract resolved stream name and isEngine from the consumer key
|
|
300
|
+
// Consumer key format: resolvedStreamName:groupName
|
|
301
|
+
const isEngine = consumer.groupName === 'ENGINE';
|
|
302
|
+
const resolvedStreamName = consumerKey.substring(0, consumerKey.lastIndexOf(':'));
|
|
303
|
+
const channelName = (0, kvtables_1.getNotificationChannelName)(resolvedStreamName, isEngine);
|
|
294
304
|
try {
|
|
295
305
|
await this.client.query(`UNLISTEN "${channelName}"`);
|
|
296
306
|
this.logger.debug('postgres-stream-cleanup-unlisten', {
|
|
@@ -300,7 +310,6 @@ class NotificationManager {
|
|
|
300
310
|
});
|
|
301
311
|
}
|
|
302
312
|
catch (error) {
|
|
303
|
-
// Silently ignore errors during shutdown
|
|
304
313
|
if (!error.message?.includes('Client was closed')) {
|
|
305
314
|
this.logger.error('postgres-stream-cleanup-unlisten-error', {
|
|
306
315
|
streamName: consumer.streamName,
|
|
@@ -315,15 +324,10 @@ class NotificationManager {
|
|
|
315
324
|
}
|
|
316
325
|
}
|
|
317
326
|
}
|
|
318
|
-
// Clear instance tracking
|
|
319
327
|
this.instanceNotificationConsumers.clear();
|
|
320
|
-
// If no more consumers exist for this client, clean up static resources
|
|
321
328
|
if (clientNotificationConsumers && clientNotificationConsumers.size === 0) {
|
|
322
|
-
// Remove client from static maps
|
|
323
329
|
NotificationManager.clientNotificationConsumers.delete(this.client);
|
|
324
330
|
NotificationManager.clientNotificationHandlers.delete(this.client);
|
|
325
|
-
// Fallback poller already stopped above
|
|
326
|
-
// Remove notification handler
|
|
327
331
|
if (this.client.removeAllListeners) {
|
|
328
332
|
this.client.removeAllListeners('notification');
|
|
329
333
|
}
|
|
@@ -352,6 +356,6 @@ function getFallbackInterval(config) {
|
|
|
352
356
|
}
|
|
353
357
|
exports.getFallbackInterval = getFallbackInterval;
|
|
354
358
|
function getNotificationTimeout(config) {
|
|
355
|
-
return config?.postgres?.notificationTimeout || 5000;
|
|
359
|
+
return config?.postgres?.notificationTimeout || 5000;
|
|
356
360
|
}
|
|
357
361
|
exports.getNotificationTimeout = getNotificationTimeout;
|