@hotmeshio/hotmesh 0.10.2 → 0.12.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/enums.d.ts +1 -0
- package/build/modules/enums.js +3 -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 +384 -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.d.ts +2 -2
- package/build/services/engine/index.js +10 -5
- package/build/services/exporter/index.d.ts +16 -2
- package/build/services/exporter/index.js +76 -0
- package/build/services/hotmesh/index.d.ts +2 -2
- package/build/services/hotmesh/index.js +2 -2
- package/build/services/router/config/index.d.ts +2 -2
- package/build/services/router/config/index.js +2 -1
- package/build/services/router/consumption/index.js +80 -5
- package/build/services/store/index.d.ts +52 -0
- package/build/services/store/providers/postgres/exporter-sql.d.ts +40 -0
- package/build/services/store/providers/postgres/exporter-sql.js +92 -0
- package/build/services/store/providers/postgres/kvtables.js +6 -0
- package/build/services/store/providers/postgres/postgres.d.ts +42 -0
- package/build/services/store/providers/postgres/postgres.js +151 -0
- package/build/services/stream/index.d.ts +1 -0
- package/build/services/stream/providers/postgres/kvtables.d.ts +1 -1
- package/build/services/stream/providers/postgres/kvtables.js +235 -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 +14 -6
- package/build/services/stream/providers/postgres/messages.js +153 -76
- 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 +21 -118
- package/build/services/stream/providers/postgres/postgres.js +87 -140
- package/build/services/stream/providers/postgres/scout.js +2 -2
- package/build/services/stream/providers/postgres/stats.js +3 -2
- 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 +84 -0
- package/build/types/hotmesh.d.ts +7 -1
- package/build/types/index.d.ts +1 -1
- package/build/types/stream.d.ts +2 -0
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.retryMessages = exports.ackAndDelete = exports.deleteMessages = exports.acknowledgeMessages = exports.fetchMessages = exports.buildPublishSQL = exports.publishMessages = void 0;
|
|
3
|
+
exports.retryMessages = exports.deadLetterMessages = exports.ackAndDelete = exports.deleteMessages = exports.acknowledgeMessages = exports.fetchMessages = exports.buildPublishSQL = exports.publishMessages = void 0;
|
|
4
4
|
const utils_1 = require("../../../../modules/utils");
|
|
5
5
|
/**
|
|
6
6
|
* Publish messages to a stream. Can be used within a transaction.
|
|
@@ -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,13 @@ function buildPublishSQL(tableName, streamName, messages, options) {
|
|
|
50
49
|
delete data._streamRetryConfig;
|
|
51
50
|
delete data._visibilityDelayMs;
|
|
52
51
|
delete data._retryAttempt;
|
|
52
|
+
// Extract metadata for worker stream columns
|
|
53
|
+
const workflowName = data.metadata?.wfn || '';
|
|
54
|
+
const jid = data.metadata?.jid || '';
|
|
55
|
+
const aid = data.metadata?.aid || '';
|
|
56
|
+
const dad = data.metadata?.dad || '';
|
|
57
|
+
const msgType = data.type || '';
|
|
58
|
+
const topic = data.metadata?.topic || '';
|
|
53
59
|
// Determine if this message has explicit retry config
|
|
54
60
|
const hasExplicitConfig = (retryConfig && 'max_retry_attempts' in retryConfig) || options?.retryPolicy;
|
|
55
61
|
let normalizedPolicy = null;
|
|
@@ -69,63 +75,111 @@ function buildPublishSQL(tableName, streamName, messages, options) {
|
|
|
69
75
|
retryPolicy: normalizedPolicy,
|
|
70
76
|
visibilityDelayMs: visibilityDelayMs || 0,
|
|
71
77
|
retryAttempt: retryAttempt || 0,
|
|
78
|
+
workflowName,
|
|
79
|
+
jid,
|
|
80
|
+
aid,
|
|
81
|
+
dad,
|
|
82
|
+
msgType,
|
|
83
|
+
topic,
|
|
72
84
|
};
|
|
73
85
|
});
|
|
74
|
-
const params = [streamName
|
|
86
|
+
const params = [streamName];
|
|
75
87
|
let valuesClauses = [];
|
|
76
88
|
let insertColumns;
|
|
77
|
-
// Check if ALL messages have explicit config or ALL don't
|
|
78
89
|
const allHaveConfig = parsedMessages.every(pm => pm.hasExplicitConfig);
|
|
79
90
|
const noneHaveConfig = parsedMessages.every(pm => !pm.hasExplicitConfig);
|
|
80
91
|
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
|
-
|
|
92
|
+
if (isEngine) {
|
|
93
|
+
// Engine table: no group_name, no workflow_name
|
|
94
|
+
if (noneHaveConfig && !hasVisibilityDelays) {
|
|
95
|
+
insertColumns = '(stream_name, message)';
|
|
96
|
+
parsedMessages.forEach((pm, idx) => {
|
|
97
|
+
const base = idx * 1;
|
|
98
|
+
valuesClauses.push(`($1, $${base + 2})`);
|
|
99
|
+
params.push(pm.message);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
else if (noneHaveConfig && hasVisibilityDelays) {
|
|
103
|
+
insertColumns = '(stream_name, message, visible_at, retry_attempt)';
|
|
104
|
+
parsedMessages.forEach((pm, idx) => {
|
|
105
|
+
const base = idx * 2;
|
|
106
|
+
if (pm.visibilityDelayMs > 0) {
|
|
107
|
+
const visibleAtSQL = `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`;
|
|
108
|
+
valuesClauses.push(`($1, $${base + 2}, ${visibleAtSQL}, $${base + 3})`);
|
|
109
|
+
params.push(pm.message, pm.retryAttempt);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
valuesClauses.push(`($1, $${base + 2}, DEFAULT, $${base + 3})`);
|
|
113
|
+
params.push(pm.message, pm.retryAttempt);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
insertColumns = '(stream_name, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)';
|
|
119
|
+
parsedMessages.forEach((pm) => {
|
|
120
|
+
const visibleAtClause = pm.visibilityDelayMs > 0
|
|
121
|
+
? `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`
|
|
122
|
+
: 'DEFAULT';
|
|
123
|
+
if (pm.hasExplicitConfig) {
|
|
124
|
+
const paramOffset = params.length + 1;
|
|
125
|
+
valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, ${visibleAtClause}, $${paramOffset + 4})`);
|
|
126
|
+
params.push(pm.message, pm.retryPolicy.max_retry_attempts, pm.retryPolicy.backoff_coefficient, pm.retryPolicy.maximum_interval_seconds, pm.retryAttempt);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const paramOffset = params.length + 1;
|
|
130
|
+
valuesClauses.push(`($1, $${paramOffset}, DEFAULT, DEFAULT, DEFAULT, ${visibleAtClause}, $${paramOffset + 1})`);
|
|
131
|
+
params.push(pm.message, pm.retryAttempt);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
105
135
|
}
|
|
106
136
|
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
|
|
137
|
+
// Worker table: includes workflow_name + export fidelity columns, no group_name
|
|
138
|
+
if (noneHaveConfig && !hasVisibilityDelays) {
|
|
139
|
+
insertColumns = '(stream_name, workflow_name, jid, aid, dad, msg_type, topic, message)';
|
|
140
|
+
parsedMessages.forEach((pm) => {
|
|
120
141
|
const paramOffset = params.length + 1;
|
|
121
|
-
valuesClauses.push(`($1,
|
|
122
|
-
params.push(pm.
|
|
123
|
-
}
|
|
124
|
-
}
|
|
142
|
+
valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, $${paramOffset + 4}, $${paramOffset + 5}, $${paramOffset + 6})`);
|
|
143
|
+
params.push(pm.workflowName, pm.jid, pm.aid, pm.dad, pm.msgType, pm.topic, pm.message);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
else if (noneHaveConfig && hasVisibilityDelays) {
|
|
147
|
+
insertColumns = '(stream_name, workflow_name, jid, aid, dad, msg_type, topic, message, visible_at, retry_attempt)';
|
|
148
|
+
parsedMessages.forEach((pm) => {
|
|
149
|
+
const paramOffset = params.length + 1;
|
|
150
|
+
if (pm.visibilityDelayMs > 0) {
|
|
151
|
+
const visibleAtSQL = `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`;
|
|
152
|
+
valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, $${paramOffset + 4}, $${paramOffset + 5}, $${paramOffset + 6}, ${visibleAtSQL}, $${paramOffset + 7})`);
|
|
153
|
+
params.push(pm.workflowName, pm.jid, pm.aid, pm.dad, pm.msgType, pm.topic, pm.message, pm.retryAttempt);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, $${paramOffset + 4}, $${paramOffset + 5}, $${paramOffset + 6}, DEFAULT, $${paramOffset + 7})`);
|
|
157
|
+
params.push(pm.workflowName, pm.jid, pm.aid, pm.dad, pm.msgType, pm.topic, pm.message, pm.retryAttempt);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
insertColumns = '(stream_name, workflow_name, jid, aid, dad, msg_type, topic, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)';
|
|
163
|
+
parsedMessages.forEach((pm) => {
|
|
164
|
+
const visibleAtClause = pm.visibilityDelayMs > 0
|
|
165
|
+
? `NOW() + INTERVAL '${pm.visibilityDelayMs} milliseconds'`
|
|
166
|
+
: 'DEFAULT';
|
|
167
|
+
if (pm.hasExplicitConfig) {
|
|
168
|
+
const paramOffset = params.length + 1;
|
|
169
|
+
valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, $${paramOffset + 4}, $${paramOffset + 5}, $${paramOffset + 6}, $${paramOffset + 7}, $${paramOffset + 8}, $${paramOffset + 9}, ${visibleAtClause}, $${paramOffset + 10})`);
|
|
170
|
+
params.push(pm.workflowName, pm.jid, pm.aid, pm.dad, pm.msgType, pm.topic, pm.message, pm.retryPolicy.max_retry_attempts, pm.retryPolicy.backoff_coefficient, pm.retryPolicy.maximum_interval_seconds, pm.retryAttempt);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const paramOffset = params.length + 1;
|
|
174
|
+
valuesClauses.push(`($1, $${paramOffset}, $${paramOffset + 1}, $${paramOffset + 2}, $${paramOffset + 3}, $${paramOffset + 4}, $${paramOffset + 5}, $${paramOffset + 6}, DEFAULT, DEFAULT, DEFAULT, ${visibleAtClause}, $${paramOffset + 7})`);
|
|
175
|
+
params.push(pm.workflowName, pm.jid, pm.aid, pm.dad, pm.msgType, pm.topic, pm.message, pm.retryAttempt);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
125
179
|
}
|
|
126
180
|
return {
|
|
127
181
|
sql: `INSERT INTO ${tableName} ${insertColumns}
|
|
128
|
-
VALUES ${valuesClauses.join(', ')}
|
|
182
|
+
VALUES ${valuesClauses.join(', ')}
|
|
129
183
|
RETURNING id`,
|
|
130
184
|
params,
|
|
131
185
|
};
|
|
@@ -134,38 +188,39 @@ exports.buildPublishSQL = buildPublishSQL;
|
|
|
134
188
|
/**
|
|
135
189
|
* Fetch messages from the stream with optional exponential backoff.
|
|
136
190
|
* Uses SKIP LOCKED for high-concurrency consumption.
|
|
191
|
+
* No group_name filter needed - the table itself determines engine vs worker.
|
|
137
192
|
*/
|
|
138
|
-
async function fetchMessages(client, tableName, streamName,
|
|
193
|
+
async function fetchMessages(client, tableName, streamName, isEngine, consumerName, options = {}, logger) {
|
|
139
194
|
const enableBackoff = options?.enableBackoff ?? false;
|
|
140
|
-
const initialBackoff = options?.initialBackoff ?? 100;
|
|
141
|
-
const maxBackoff = options?.maxBackoff ?? 3000;
|
|
142
|
-
const maxRetries = options?.maxRetries ?? 3;
|
|
195
|
+
const initialBackoff = options?.initialBackoff ?? 100;
|
|
196
|
+
const maxBackoff = options?.maxBackoff ?? 3000;
|
|
197
|
+
const maxRetries = options?.maxRetries ?? 3;
|
|
143
198
|
let backoff = initialBackoff;
|
|
144
199
|
let retries = 0;
|
|
200
|
+
// Include workflow_name in RETURNING for worker streams
|
|
201
|
+
const returningClause = isEngine
|
|
202
|
+
? 'id, message, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, retry_attempt'
|
|
203
|
+
: 'id, message, workflow_name, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, retry_attempt';
|
|
145
204
|
try {
|
|
146
205
|
while (retries < maxRetries) {
|
|
147
206
|
retries++;
|
|
148
207
|
const batchSize = options?.batchSize || 1;
|
|
149
208
|
const reservationTimeout = options?.reservationTimeout || 30;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
SET reserved_at = NOW(), reserved_by = $4
|
|
209
|
+
const res = await client.query(`UPDATE ${tableName}
|
|
210
|
+
SET reserved_at = NOW(), reserved_by = $3
|
|
153
211
|
WHERE id IN (
|
|
154
212
|
SELECT id FROM ${tableName}
|
|
155
|
-
WHERE stream_name = $1
|
|
156
|
-
AND group_name = $2
|
|
213
|
+
WHERE stream_name = $1
|
|
157
214
|
AND (reserved_at IS NULL OR reserved_at < NOW() - INTERVAL '${reservationTimeout} seconds')
|
|
158
215
|
AND expired_at IS NULL
|
|
159
216
|
AND visible_at <= NOW()
|
|
160
217
|
ORDER BY id
|
|
161
|
-
LIMIT $
|
|
218
|
+
LIMIT $2
|
|
162
219
|
FOR UPDATE SKIP LOCKED
|
|
163
220
|
)
|
|
164
|
-
RETURNING
|
|
221
|
+
RETURNING ${returningClause}`, [streamName, batchSize, consumerName]);
|
|
165
222
|
const messages = res.rows.map((row) => {
|
|
166
223
|
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
224
|
const hasDefaultRetryPolicy = (row.max_retry_attempts === 3 || row.max_retry_attempts === 5) &&
|
|
170
225
|
parseFloat(row.backoff_coefficient) === 10 &&
|
|
171
226
|
row.maximum_interval_seconds === 120;
|
|
@@ -176,10 +231,15 @@ async function fetchMessages(client, tableName, streamName, groupName, consumerN
|
|
|
176
231
|
maximum_interval_seconds: row.maximum_interval_seconds,
|
|
177
232
|
};
|
|
178
233
|
}
|
|
179
|
-
// Inject retry_attempt from database
|
|
180
234
|
if (row.retry_attempt !== undefined && row.retry_attempt !== null) {
|
|
181
235
|
data._retryAttempt = row.retry_attempt;
|
|
182
236
|
}
|
|
237
|
+
// Inject workflow_name from DB column into metadata for dispatch routing
|
|
238
|
+
if (!isEngine && row.workflow_name) {
|
|
239
|
+
if (data.metadata) {
|
|
240
|
+
data.metadata.wfn = row.workflow_name;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
183
243
|
return {
|
|
184
244
|
id: row.id.toString(),
|
|
185
245
|
data,
|
|
@@ -193,11 +253,9 @@ async function fetchMessages(client, tableName, streamName, groupName, consumerN
|
|
|
193
253
|
if (messages.length > 0 || !enableBackoff) {
|
|
194
254
|
return messages;
|
|
195
255
|
}
|
|
196
|
-
// Apply backoff if enabled and no messages found
|
|
197
256
|
await (0, utils_1.sleepFor)(backoff);
|
|
198
|
-
backoff = Math.min(backoff * 2, maxBackoff);
|
|
257
|
+
backoff = Math.min(backoff * 2, maxBackoff);
|
|
199
258
|
}
|
|
200
|
-
// Return empty array if maxRetries is reached and still no messages
|
|
201
259
|
return [];
|
|
202
260
|
}
|
|
203
261
|
catch (error) {
|
|
@@ -212,20 +270,19 @@ exports.fetchMessages = fetchMessages;
|
|
|
212
270
|
* Acknowledge messages (no-op for PostgreSQL - uses soft delete pattern).
|
|
213
271
|
*/
|
|
214
272
|
async function acknowledgeMessages(messageIds) {
|
|
215
|
-
// No-op for this implementation
|
|
216
273
|
return messageIds.length;
|
|
217
274
|
}
|
|
218
275
|
exports.acknowledgeMessages = acknowledgeMessages;
|
|
219
276
|
/**
|
|
220
277
|
* Delete messages by soft-deleting them (setting expired_at).
|
|
278
|
+
* No group_name needed - stream_name + table is sufficient.
|
|
221
279
|
*/
|
|
222
|
-
async function deleteMessages(client, tableName, streamName,
|
|
280
|
+
async function deleteMessages(client, tableName, streamName, messageIds, logger) {
|
|
223
281
|
try {
|
|
224
282
|
const ids = messageIds.map((id) => parseInt(id));
|
|
225
|
-
// Perform a soft delete by setting `expired_at` to the current timestamp
|
|
226
283
|
await client.query(`UPDATE ${tableName}
|
|
227
284
|
SET expired_at = NOW()
|
|
228
|
-
WHERE stream_name = $1 AND id = ANY($2::bigint[])
|
|
285
|
+
WHERE stream_name = $1 AND id = ANY($2::bigint[])`, [streamName, ids]);
|
|
229
286
|
return messageIds.length;
|
|
230
287
|
}
|
|
231
288
|
catch (error) {
|
|
@@ -239,15 +296,35 @@ exports.deleteMessages = deleteMessages;
|
|
|
239
296
|
/**
|
|
240
297
|
* Acknowledge and delete messages in one operation.
|
|
241
298
|
*/
|
|
242
|
-
async function ackAndDelete(client, tableName, streamName,
|
|
243
|
-
return await deleteMessages(client, tableName, streamName,
|
|
299
|
+
async function ackAndDelete(client, tableName, streamName, messageIds, logger) {
|
|
300
|
+
return await deleteMessages(client, tableName, streamName, messageIds, logger);
|
|
244
301
|
}
|
|
245
302
|
exports.ackAndDelete = ackAndDelete;
|
|
303
|
+
/**
|
|
304
|
+
* Move messages to the dead-letter state by setting dead_lettered_at
|
|
305
|
+
* and expired_at. The message payload is preserved for inspection.
|
|
306
|
+
*/
|
|
307
|
+
async function deadLetterMessages(client, tableName, streamName, messageIds, logger) {
|
|
308
|
+
try {
|
|
309
|
+
const ids = messageIds.map((id) => parseInt(id));
|
|
310
|
+
const res = await client.query(`UPDATE ${tableName}
|
|
311
|
+
SET dead_lettered_at = NOW(), expired_at = NOW()
|
|
312
|
+
WHERE stream_name = $1 AND id = ANY($2::bigint[])`, [streamName, ids]);
|
|
313
|
+
return res.rowCount;
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
logger.error(`postgres-stream-dead-letter-error-${streamName}`, {
|
|
317
|
+
error,
|
|
318
|
+
messageIds,
|
|
319
|
+
});
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
exports.deadLetterMessages = deadLetterMessages;
|
|
246
324
|
/**
|
|
247
325
|
* Retry messages (placeholder for future implementation).
|
|
248
326
|
*/
|
|
249
327
|
async function retryMessages(streamName, groupName, options) {
|
|
250
|
-
// Implement retry logic if needed
|
|
251
328
|
return [];
|
|
252
329
|
}
|
|
253
330
|
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;
|