@hotmeshio/hotmesh 0.25.2 → 0.25.4
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/build/package.json +1 -1
- package/build/services/engine/completion.js +26 -0
- package/build/services/router/consumption/index.d.ts +7 -1
- package/build/services/router/consumption/index.js +79 -16
- package/build/services/stream/index.d.ts +3 -0
- package/build/services/stream/providers/postgres/messages.d.ts +26 -1
- package/build/services/stream/providers/postgres/messages.js +126 -6
- package/build/services/stream/providers/postgres/postgres.d.ts +16 -0
- package/build/services/stream/providers/postgres/postgres.js +40 -2
- package/package.json +1 -1
package/build/package.json
CHANGED
|
@@ -68,6 +68,7 @@ function resolveError(metadata) {
|
|
|
68
68
|
exports.resolveError = resolveError;
|
|
69
69
|
async function interrupt(instance, topic, jobId, options = {}) {
|
|
70
70
|
await instance.store.interrupt(topic, jobId, options);
|
|
71
|
+
await expireStreamMessages(instance, jobId);
|
|
71
72
|
const context = (await instance.getState(topic, jobId));
|
|
72
73
|
const completionOpts = {
|
|
73
74
|
interrupt: options.descend,
|
|
@@ -77,9 +78,34 @@ async function interrupt(instance, topic, jobId, options = {}) {
|
|
|
77
78
|
}
|
|
78
79
|
exports.interrupt = interrupt;
|
|
79
80
|
async function scrub(instance, jobId) {
|
|
81
|
+
await expireStreamMessages(instance, jobId);
|
|
80
82
|
await instance.store.scrub(jobId);
|
|
81
83
|
}
|
|
82
84
|
exports.scrub = scrub;
|
|
85
|
+
/**
|
|
86
|
+
* Soft-deletes the job's stream messages (queued, reserved, and
|
|
87
|
+
* scheduled retries) so no message belonging to a dead job is ever
|
|
88
|
+
* delivered again. Interrupting the job wins or throws before this
|
|
89
|
+
* runs; failures here are logged and absorbed because the delivery
|
|
90
|
+
* liveness guard drops any surviving message at claim time.
|
|
91
|
+
*/
|
|
92
|
+
async function expireStreamMessages(instance, jobId) {
|
|
93
|
+
try {
|
|
94
|
+
const expired = await instance.stream.expireJobMessages?.(jobId);
|
|
95
|
+
if (expired) {
|
|
96
|
+
instance.logger.info('engine-job-stream-messages-expired', {
|
|
97
|
+
jobId,
|
|
98
|
+
expired,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
instance.logger.error('engine-job-stream-expire-error', {
|
|
104
|
+
jobId,
|
|
105
|
+
error,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
83
109
|
/**
|
|
84
110
|
* Orchestrates all post-completion work for a finished job:
|
|
85
111
|
* notify parent, publish to subscribers, schedule cleanup.
|
|
@@ -30,6 +30,7 @@ export declare class ConsumptionManager<S extends StreamService<ProviderClient,
|
|
|
30
30
|
private duressManager?;
|
|
31
31
|
private onDuressChange?;
|
|
32
32
|
private messagesSinceLastEval;
|
|
33
|
+
private canExtendReservations;
|
|
33
34
|
private adaptiveReservationTimeout;
|
|
34
35
|
private adaptiveBatchSize;
|
|
35
36
|
private lastDepthCheckAt;
|
|
@@ -52,7 +53,12 @@ export declare class ConsumptionManager<S extends StreamService<ProviderClient,
|
|
|
52
53
|
consumeMessages(stream: string, group: string, consumer: string, callback: (streamData: StreamData) => Promise<StreamDataResponse | void>): Promise<void>;
|
|
53
54
|
private consumeWithNotifications;
|
|
54
55
|
private consumeWithPolling;
|
|
55
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Whether reservation heartbeats are available: the provider must
|
|
58
|
+
* implement extendReservation and advertise the capability.
|
|
59
|
+
*/
|
|
60
|
+
private supportsHeartbeat;
|
|
61
|
+
consumeOne(stream: string, group: string, id: string, input: StreamData, callback: (streamData: StreamData) => Promise<StreamDataResponse | void>, consumer?: string): Promise<void>;
|
|
56
62
|
execStreamLeg(input: StreamData, stream: string, id: string, callback: (streamData: StreamData) => Promise<StreamDataResponse | void>): Promise<StreamDataResponse>;
|
|
57
63
|
ackAndDelete(stream: string, group: string, id: string): Promise<void>;
|
|
58
64
|
ackAndDeleteBatch(stream: string, group: string, ids: string[]): Promise<void>;
|
|
@@ -195,7 +195,7 @@ class ConsumptionManager {
|
|
|
195
195
|
if (this.lifecycleManager.isStopped(group, consumer, stream)) {
|
|
196
196
|
return;
|
|
197
197
|
}
|
|
198
|
-
return this.consumeOne(stream, group, message.id, message.data, callback);
|
|
198
|
+
return this.consumeOne(stream, group, message.id, message.data, callback, consumer);
|
|
199
199
|
});
|
|
200
200
|
// Process all messages in parallel
|
|
201
201
|
await Promise.allSettled(processingPromises);
|
|
@@ -212,7 +212,7 @@ class ConsumptionManager {
|
|
|
212
212
|
if (this.lifecycleManager.isStopped(group, consumer, stream)) {
|
|
213
213
|
return;
|
|
214
214
|
}
|
|
215
|
-
await this.consumeOne(stream, group, message.id, message.data, callback);
|
|
215
|
+
await this.consumeOne(stream, group, message.id, message.data, callback, consumer);
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
// Check for pending messages if supported and enough time has passed
|
|
@@ -231,7 +231,7 @@ class ConsumptionManager {
|
|
|
231
231
|
if (this.lifecycleManager.isStopped(group, consumer, stream)) {
|
|
232
232
|
return;
|
|
233
233
|
}
|
|
234
|
-
return this.consumeOne(stream, group, message.id, message.data, callback);
|
|
234
|
+
return this.consumeOne(stream, group, message.id, message.data, callback, consumer);
|
|
235
235
|
});
|
|
236
236
|
await Promise.allSettled(reclaimPromises);
|
|
237
237
|
}
|
|
@@ -240,7 +240,7 @@ class ConsumptionManager {
|
|
|
240
240
|
if (this.lifecycleManager.isStopped(group, consumer, stream)) {
|
|
241
241
|
return;
|
|
242
242
|
}
|
|
243
|
-
await this.consumeOne(stream, group, message.id, message.data, callback);
|
|
243
|
+
await this.consumeOne(stream, group, message.id, message.data, callback, consumer);
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
}
|
|
@@ -349,7 +349,7 @@ class ConsumptionManager {
|
|
|
349
349
|
if (this.lifecycleManager.isStopped(group, consumer, stream)) {
|
|
350
350
|
return;
|
|
351
351
|
}
|
|
352
|
-
return this.consumeOne(stream, group, message.id, message.data, callback);
|
|
352
|
+
return this.consumeOne(stream, group, message.id, message.data, callback, consumer);
|
|
353
353
|
});
|
|
354
354
|
// Process all messages in parallel
|
|
355
355
|
await Promise.allSettled(processingPromises);
|
|
@@ -366,7 +366,7 @@ class ConsumptionManager {
|
|
|
366
366
|
if (this.lifecycleManager.isStopped(group, consumer, stream)) {
|
|
367
367
|
return;
|
|
368
368
|
}
|
|
369
|
-
await this.consumeOne(stream, group, message.id, message.data, callback);
|
|
369
|
+
await this.consumeOne(stream, group, message.id, message.data, callback, consumer);
|
|
370
370
|
}
|
|
371
371
|
}
|
|
372
372
|
// Check for pending messages if supported and enough time has passed
|
|
@@ -385,7 +385,7 @@ class ConsumptionManager {
|
|
|
385
385
|
if (this.lifecycleManager.isStopped(group, consumer, stream)) {
|
|
386
386
|
return;
|
|
387
387
|
}
|
|
388
|
-
return this.consumeOne(stream, group, message.id, message.data, callback);
|
|
388
|
+
return this.consumeOne(stream, group, message.id, message.data, callback, consumer);
|
|
389
389
|
});
|
|
390
390
|
await Promise.allSettled(reclaimPromises);
|
|
391
391
|
}
|
|
@@ -394,7 +394,7 @@ class ConsumptionManager {
|
|
|
394
394
|
if (this.lifecycleManager.isStopped(group, consumer, stream)) {
|
|
395
395
|
return;
|
|
396
396
|
}
|
|
397
|
-
await this.consumeOne(stream, group, message.id, message.data, callback);
|
|
397
|
+
await this.consumeOne(stream, group, message.id, message.data, callback, consumer);
|
|
398
398
|
}
|
|
399
399
|
}
|
|
400
400
|
}
|
|
@@ -434,7 +434,20 @@ class ConsumptionManager {
|
|
|
434
434
|
};
|
|
435
435
|
consume.call(this);
|
|
436
436
|
}
|
|
437
|
-
|
|
437
|
+
/**
|
|
438
|
+
* Whether reservation heartbeats are available: the provider must
|
|
439
|
+
* implement extendReservation and advertise the capability.
|
|
440
|
+
*/
|
|
441
|
+
supportsHeartbeat() {
|
|
442
|
+
if (this.canExtendReservations === undefined) {
|
|
443
|
+
this.canExtendReservations =
|
|
444
|
+
typeof this.stream.extendReservation === 'function' &&
|
|
445
|
+
this.stream.getProviderSpecificFeatures()
|
|
446
|
+
.supportsReservationExtension === true;
|
|
447
|
+
}
|
|
448
|
+
return this.canExtendReservations;
|
|
449
|
+
}
|
|
450
|
+
async consumeOne(stream, group, id, input, callback, consumer) {
|
|
438
451
|
this.logger.debug(`stream-read-one`, { group, stream, id });
|
|
439
452
|
// Poison message circuit breaker. This is a SAFETY NET that sits above
|
|
440
453
|
// the normal retry mechanism (ErrorHandler.handleRetry / shouldRetry).
|
|
@@ -498,20 +511,67 @@ class ConsumptionManager {
|
|
|
498
511
|
}
|
|
499
512
|
return;
|
|
500
513
|
}
|
|
501
|
-
// Lease
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
|
|
514
|
+
// Lease strategy. With heartbeat support (provider capability +
|
|
515
|
+
// consumer identity), the reservation is refreshed at half the base
|
|
516
|
+
// window while the callback runs, so an activity that outlives the
|
|
517
|
+
// window stays leased and executes exactly once; the deadline then
|
|
518
|
+
// acts as a runaway cap (HMSH_RESERVATION_TIMEOUT_MAX_S). A crashed
|
|
519
|
+
// consumer stops heartbeating and its message is rescued within one
|
|
520
|
+
// window through the normal claim path. Providers without extension
|
|
521
|
+
// keep the hard deadline at the reservation timeout (N), with
|
|
522
|
+
// reclaim at N+5s.
|
|
523
|
+
const canHeartbeat = Boolean(consumer) && this.supportsHeartbeat();
|
|
524
|
+
const deadlineS = canHeartbeat
|
|
525
|
+
? config_1.HMSH_RESERVATION_TIMEOUT_MAX_S
|
|
526
|
+
: this.adaptiveReservationTimeout;
|
|
527
|
+
const deadlineMs = deadlineS * 1000;
|
|
506
528
|
let output;
|
|
507
529
|
const telemetry = new telemetry_1.RouterTelemetry(this.appId);
|
|
508
530
|
const processingStart = Date.now();
|
|
509
531
|
try {
|
|
510
532
|
telemetry.startStreamSpan(input, this.role);
|
|
511
533
|
let deadlineTimer;
|
|
534
|
+
let rejectLease;
|
|
512
535
|
const deadlinePromise = new Promise((_, reject) => {
|
|
513
|
-
|
|
536
|
+
rejectLease = reject;
|
|
537
|
+
deadlineTimer = setTimeout(() => reject(new errors_1.LeaseExpiredError(deadlineMs, deadlineS)), deadlineMs);
|
|
514
538
|
});
|
|
539
|
+
// Heartbeat at half the BASE window (adaptive scaling only grows
|
|
540
|
+
// the claim window, so the base cadence always outpaces reclaim
|
|
541
|
+
// eligibility). A beat that reports 0 means the lease is gone —
|
|
542
|
+
// reclaimed by another consumer, acked, or the job was
|
|
543
|
+
// interrupted — so this execution abandons without acking. A beat
|
|
544
|
+
// that errors is transient and retries on the next tick. Fast
|
|
545
|
+
// callbacks settle before the first beat ever fires.
|
|
546
|
+
let heartbeatTimer;
|
|
547
|
+
if (canHeartbeat) {
|
|
548
|
+
const intervalMs = Math.max(1000, Math.floor((config_1.HMSH_RESERVATION_TIMEOUT_S * 1000) / 2));
|
|
549
|
+
heartbeatTimer = setInterval(() => {
|
|
550
|
+
this.stream
|
|
551
|
+
.extendReservation(stream, id, consumer)
|
|
552
|
+
.then((extended) => {
|
|
553
|
+
if (extended === 0) {
|
|
554
|
+
this.logger.warn('stream-lease-lost', {
|
|
555
|
+
group,
|
|
556
|
+
stream,
|
|
557
|
+
id,
|
|
558
|
+
consumer,
|
|
559
|
+
topic: input.metadata?.topic,
|
|
560
|
+
jobId: input.metadata?.jid,
|
|
561
|
+
});
|
|
562
|
+
rejectLease(new errors_1.LeaseExpiredError(deadlineMs, deadlineS));
|
|
563
|
+
}
|
|
564
|
+
})
|
|
565
|
+
.catch((error) => {
|
|
566
|
+
this.logger.warn('stream-lease-extend-error', {
|
|
567
|
+
group,
|
|
568
|
+
stream,
|
|
569
|
+
id,
|
|
570
|
+
error,
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
}, intervalMs);
|
|
574
|
+
}
|
|
515
575
|
try {
|
|
516
576
|
output = await Promise.race([
|
|
517
577
|
this.execStreamLeg(input, stream, id, callback.bind(this)),
|
|
@@ -520,6 +580,9 @@ class ConsumptionManager {
|
|
|
520
580
|
}
|
|
521
581
|
finally {
|
|
522
582
|
clearTimeout(deadlineTimer);
|
|
583
|
+
if (heartbeatTimer) {
|
|
584
|
+
clearInterval(heartbeatTimer);
|
|
585
|
+
}
|
|
523
586
|
}
|
|
524
587
|
telemetry.setStreamErrorFromOutput(output);
|
|
525
588
|
this.errorCount = 0;
|
|
@@ -536,7 +599,7 @@ class ConsumptionManager {
|
|
|
536
599
|
stream,
|
|
537
600
|
id,
|
|
538
601
|
deadlineMs,
|
|
539
|
-
reservationTimeoutS:
|
|
602
|
+
reservationTimeoutS: deadlineS,
|
|
540
603
|
topic: input.metadata?.topic,
|
|
541
604
|
activityId: input.metadata?.aid,
|
|
542
605
|
jobId: input.metadata?.jid,
|
|
@@ -66,10 +66,13 @@ export declare abstract class StreamService<ClientProvider extends ProviderClien
|
|
|
66
66
|
supportsRetry: boolean;
|
|
67
67
|
supportsNotifications?: boolean;
|
|
68
68
|
supportsParallelProcessing?: boolean;
|
|
69
|
+
supportsReservationExtension?: boolean;
|
|
69
70
|
maxMessageSize: number;
|
|
70
71
|
maxBatchSize: number;
|
|
71
72
|
};
|
|
72
73
|
deadLetterMessages?(streamName: string, groupName: string, messageIds: string[]): Promise<number>;
|
|
74
|
+
expireJobMessages?(jid: string): Promise<number>;
|
|
75
|
+
extendReservation?(streamName: string, messageId: string, consumerName: string): Promise<number>;
|
|
73
76
|
stopNotificationConsumer?(streamName: string, groupName: string): Promise<void>;
|
|
74
77
|
cleanup?(): Promise<void>;
|
|
75
78
|
}
|
|
@@ -19,6 +19,31 @@ export declare function buildPublishSQL(tableName: string, streamName: string, i
|
|
|
19
19
|
sql: string;
|
|
20
20
|
params: any[];
|
|
21
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* Job-liveness context for the delivery guard. `keyPrefix` is the minted
|
|
24
|
+
* job-key prefix (`hmsh:<app>:j:`) so `keyPrefix || jid` addresses the
|
|
25
|
+
* jobs row. `enabled` is mutated to false (self-disable) when the jobs
|
|
26
|
+
* table is not visible from the stream connection.
|
|
27
|
+
*/
|
|
28
|
+
export interface JobLivenessContext {
|
|
29
|
+
jobsTable: string;
|
|
30
|
+
keyPrefix: string;
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Soft-delete every live stream row that belongs to a job. Called when a
|
|
35
|
+
* job is interrupted so its queued, reserved, and scheduled-retry
|
|
36
|
+
* messages are never delivered again. Uses the partial jid indexes
|
|
37
|
+
* (idx_*_streams_jid_created); runs once per interrupt.
|
|
38
|
+
*/
|
|
39
|
+
export declare function expireJobMessages(client: PostgresClientType & ProviderClient, tableNames: string[], jid: string, logger: ILogger): Promise<number>;
|
|
40
|
+
/**
|
|
41
|
+
* Refresh an owned reservation (heartbeat). Scoped to the owning
|
|
42
|
+
* consumer and to live rows: a message that was reclaimed by another
|
|
43
|
+
* consumer, acked, or expired (job interrupted) reports 0 so the
|
|
44
|
+
* stale consumer can abandon its execution.
|
|
45
|
+
*/
|
|
46
|
+
export declare function extendReservation(client: PostgresClientType & ProviderClient, tableName: string, streamName: string, messageId: string, consumerName: string, logger: ILogger): Promise<number>;
|
|
22
47
|
/**
|
|
23
48
|
* Fetch messages from the stream with optional exponential backoff.
|
|
24
49
|
* Uses SKIP LOCKED for high-concurrency consumption.
|
|
@@ -33,7 +58,7 @@ export declare function fetchMessages(client: PostgresClientType & ProviderClien
|
|
|
33
58
|
initialBackoff?: number;
|
|
34
59
|
maxBackoff?: number;
|
|
35
60
|
maxRetries?: number;
|
|
36
|
-
}, logger: ILogger): Promise<StreamMessage[]>;
|
|
61
|
+
}, logger: ILogger, liveness?: JobLivenessContext): Promise<StreamMessage[]>;
|
|
37
62
|
/**
|
|
38
63
|
* Acknowledge messages (no-op for PostgreSQL - uses soft delete pattern).
|
|
39
64
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.retryMessages = exports.deadLetterMessages = exports.ackAndDelete = exports.deleteMessages = exports.acknowledgeMessages = exports.fetchMessages = exports.buildPublishSQL = exports.publishMessages = exports.getMessagePriority = void 0;
|
|
3
|
+
exports.retryMessages = exports.deadLetterMessages = exports.ackAndDelete = exports.deleteMessages = exports.acknowledgeMessages = exports.fetchMessages = exports.extendReservation = exports.expireJobMessages = exports.buildPublishSQL = exports.publishMessages = exports.getMessagePriority = void 0;
|
|
4
4
|
const enums_1 = require("../../../../modules/enums");
|
|
5
5
|
const utils_1 = require("../../../../modules/utils");
|
|
6
6
|
const stream_1 = require("../../../../types/stream");
|
|
@@ -203,12 +203,121 @@ function buildPublishSQL(tableName, streamName, isEngine, messages, options) {
|
|
|
203
203
|
};
|
|
204
204
|
}
|
|
205
205
|
exports.buildPublishSQL = buildPublishSQL;
|
|
206
|
+
/**
|
|
207
|
+
* Soft-delete every live stream row that belongs to a job. Called when a
|
|
208
|
+
* job is interrupted so its queued, reserved, and scheduled-retry
|
|
209
|
+
* messages are never delivered again. Uses the partial jid indexes
|
|
210
|
+
* (idx_*_streams_jid_created); runs once per interrupt.
|
|
211
|
+
*/
|
|
212
|
+
async function expireJobMessages(client, tableNames, jid, logger) {
|
|
213
|
+
if (!jid)
|
|
214
|
+
return 0;
|
|
215
|
+
let total = 0;
|
|
216
|
+
for (const tableName of tableNames) {
|
|
217
|
+
try {
|
|
218
|
+
const res = await client.query(`UPDATE ${tableName}
|
|
219
|
+
SET expired_at = NOW()
|
|
220
|
+
WHERE jid = $1 AND expired_at IS NULL`, [jid]);
|
|
221
|
+
total += res.rowCount ?? 0;
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
logger.error(`postgres-stream-expire-job-error`, {
|
|
225
|
+
tableName,
|
|
226
|
+
jid,
|
|
227
|
+
error,
|
|
228
|
+
});
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return total;
|
|
233
|
+
}
|
|
234
|
+
exports.expireJobMessages = expireJobMessages;
|
|
235
|
+
/**
|
|
236
|
+
* Refresh an owned reservation (heartbeat). Scoped to the owning
|
|
237
|
+
* consumer and to live rows: a message that was reclaimed by another
|
|
238
|
+
* consumer, acked, or expired (job interrupted) reports 0 so the
|
|
239
|
+
* stale consumer can abandon its execution.
|
|
240
|
+
*/
|
|
241
|
+
async function extendReservation(client, tableName, streamName, messageId, consumerName, logger) {
|
|
242
|
+
try {
|
|
243
|
+
const res = await client.query(`UPDATE ${tableName}
|
|
244
|
+
SET reserved_at = NOW()
|
|
245
|
+
WHERE stream_name = $1 AND id = $2
|
|
246
|
+
AND reserved_by = $3 AND expired_at IS NULL`, [streamName, parseInt(messageId), consumerName]);
|
|
247
|
+
return res.rowCount ?? 0;
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
logger.error(`postgres-stream-extend-error-${streamName}`, {
|
|
251
|
+
messageId,
|
|
252
|
+
error,
|
|
253
|
+
});
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
exports.extendReservation = extendReservation;
|
|
258
|
+
/**
|
|
259
|
+
* Delivery liveness guard. Scoped to messages that are REDELIVERIES
|
|
260
|
+
* (a prior reservation lapsed) or RETRIES (retry_attempt > 0) — zombie
|
|
261
|
+
* messages of interrupted jobs only resurface through those paths, so
|
|
262
|
+
* first deliveries (the steady-state hot path) pay zero extra cost.
|
|
263
|
+
* A suspect whose job exists, is live, and has status <= 0 is expired
|
|
264
|
+
* in place and dropped from the batch. A missing job row still delivers
|
|
265
|
+
* (job-creating messages precede the row). Fails open on any error;
|
|
266
|
+
* self-disables when the jobs table is not visible (42P01).
|
|
267
|
+
*/
|
|
268
|
+
async function dropDeadJobMessages(client, tableName, streamName, rows, liveness, logger) {
|
|
269
|
+
const deadIds = new Set();
|
|
270
|
+
const suspects = rows.filter((row) => row.jid && (row.redelivered || row.retry_attempt > 0));
|
|
271
|
+
if (suspects.length === 0) {
|
|
272
|
+
return deadIds;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const res = await client.query(`UPDATE ${tableName} t
|
|
276
|
+
SET expired_at = NOW()
|
|
277
|
+
FROM ${liveness.jobsTable} j
|
|
278
|
+
WHERE t.stream_name = $1
|
|
279
|
+
AND t.id = ANY($2::bigint[])
|
|
280
|
+
AND j.key = $3 || t.jid
|
|
281
|
+
AND j.is_live
|
|
282
|
+
AND j.status <= 0
|
|
283
|
+
RETURNING t.id`, [streamName, suspects.map((row) => row.id), liveness.keyPrefix]);
|
|
284
|
+
for (const row of res.rows) {
|
|
285
|
+
deadIds.add(row.id.toString());
|
|
286
|
+
}
|
|
287
|
+
if (deadIds.size > 0) {
|
|
288
|
+
logger.warn(`postgres-stream-zombie-dropped-${streamName}`, {
|
|
289
|
+
count: deadIds.size,
|
|
290
|
+
jids: [
|
|
291
|
+
...new Set(suspects
|
|
292
|
+
.filter((row) => deadIds.has(row.id.toString()))
|
|
293
|
+
.map((row) => row.jid)),
|
|
294
|
+
],
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
if (error?.code === '42P01') {
|
|
300
|
+
//jobs table is not visible from this connection; the guard cannot
|
|
301
|
+
//run here — interrupt-time purging (expireJobMessages) still applies
|
|
302
|
+
liveness.enabled = false;
|
|
303
|
+
logger.info('postgres-stream-liveness-guard-disabled', {
|
|
304
|
+
jobsTable: liveness.jobsTable,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
logger.error(`postgres-stream-liveness-guard-error-${streamName}`, {
|
|
309
|
+
error,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return deadIds;
|
|
314
|
+
}
|
|
206
315
|
/**
|
|
207
316
|
* Fetch messages from the stream with optional exponential backoff.
|
|
208
317
|
* Uses SKIP LOCKED for high-concurrency consumption.
|
|
209
318
|
* No group_name filter needed - the table itself determines engine vs worker.
|
|
210
319
|
*/
|
|
211
|
-
async function fetchMessages(client, tableName, streamName, isEngine, consumerName, options = {}, logger) {
|
|
320
|
+
async function fetchMessages(client, tableName, streamName, isEngine, consumerName, options = {}, logger, liveness) {
|
|
212
321
|
const enableBackoff = options?.enableBackoff ?? false;
|
|
213
322
|
const initialBackoff = options?.initialBackoff ?? 100;
|
|
214
323
|
const maxBackoff = options?.maxBackoff ?? 3000;
|
|
@@ -217,10 +326,11 @@ async function fetchMessages(client, tableName, streamName, isEngine, consumerNa
|
|
|
217
326
|
let retries = 0;
|
|
218
327
|
// Include workflow_name in RETURNING for worker streams. Columns are
|
|
219
328
|
// qualified with the update target's alias because the claim UPDATE
|
|
220
|
-
// joins a CTE that also exposes an id column.
|
|
329
|
+
// joins a CTE that also exposes an id column. Worker streams also
|
|
330
|
+
// return jid and the pre-claim redelivery flag for the liveness guard.
|
|
221
331
|
const returningClause = isEngine
|
|
222
332
|
? 't.id, t.message, t.max_retry_attempts, t.backoff_coefficient, t.maximum_interval_seconds, t.retry_attempt'
|
|
223
|
-
: 't.id, t.message, t.workflow_name, t.max_retry_attempts, t.backoff_coefficient, t.maximum_interval_seconds, t.retry_attempt';
|
|
333
|
+
: 't.id, t.message, t.workflow_name, t.max_retry_attempts, t.backoff_coefficient, t.maximum_interval_seconds, t.retry_attempt, t.jid, candidates.redelivered';
|
|
224
334
|
try {
|
|
225
335
|
while (retries < maxRetries) {
|
|
226
336
|
retries++;
|
|
@@ -232,8 +342,11 @@ async function fetchMessages(client, tableName, streamName, isEngine, consumerNa
|
|
|
232
342
|
// reserves MORE rows than LIMIT. The UPDATE repeats stream_name so
|
|
233
343
|
// the planner prunes to a single hash partition and joins on the
|
|
234
344
|
// (stream_name, id) primary key.
|
|
345
|
+
// candidates exposes the PRE-claim reservation state: a non-null
|
|
346
|
+
// reserved_at at claim time means a prior reservation lapsed
|
|
347
|
+
// (redelivery) — the trigger condition for the liveness guard.
|
|
235
348
|
const res = await client.query(`WITH candidates AS MATERIALIZED (
|
|
236
|
-
SELECT id FROM ${tableName}
|
|
349
|
+
SELECT id, (reserved_at IS NOT NULL) AS redelivered FROM ${tableName}
|
|
237
350
|
WHERE stream_name = $1
|
|
238
351
|
AND (reserved_at IS NULL OR reserved_at < NOW() - INTERVAL '${reservationTimeout} seconds')
|
|
239
352
|
AND expired_at IS NULL
|
|
@@ -247,7 +360,14 @@ async function fetchMessages(client, tableName, streamName, isEngine, consumerNa
|
|
|
247
360
|
FROM candidates
|
|
248
361
|
WHERE t.stream_name = $1 AND t.id = candidates.id
|
|
249
362
|
RETURNING ${returningClause}`, [streamName, batchSize, consumerName]);
|
|
250
|
-
|
|
363
|
+
let rows = res.rows;
|
|
364
|
+
if (!isEngine && liveness?.enabled && rows.length > 0) {
|
|
365
|
+
const deadIds = await dropDeadJobMessages(client, tableName, streamName, rows, liveness, logger);
|
|
366
|
+
if (deadIds.size > 0) {
|
|
367
|
+
rows = rows.filter((row) => !deadIds.has(row.id.toString()));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const messages = rows.map((row) => {
|
|
251
371
|
const data = (0, utils_1.parseStreamMessage)(row.message);
|
|
252
372
|
const hasDefaultRetryPolicy = (row.max_retry_attempts === 3 || row.max_retry_attempts === 5) &&
|
|
253
373
|
parseFloat(row.backoff_coefficient) === 10 &&
|
|
@@ -31,6 +31,7 @@ declare class PostgresStreamService extends StreamService<PostgresClientType & P
|
|
|
31
31
|
*/
|
|
32
32
|
securedMode: boolean;
|
|
33
33
|
private scoutManager;
|
|
34
|
+
private liveness;
|
|
34
35
|
private notificationManager;
|
|
35
36
|
constructor(streamClient: PostgresClientType & ProviderClient, storeClient: ProviderClient, config?: StreamConfig);
|
|
36
37
|
init(namespace: string, appId: string, logger: ILogger): Promise<void>;
|
|
@@ -91,6 +92,20 @@ declare class PostgresStreamService extends StreamService<PostgresClientType & P
|
|
|
91
92
|
private setupNotificationConsumer;
|
|
92
93
|
stopNotificationConsumer(streamName: string, groupName: string): Promise<void>;
|
|
93
94
|
private fetchMessages;
|
|
95
|
+
/**
|
|
96
|
+
* Refreshes an owned reservation (heartbeat). Called by the consumer
|
|
97
|
+
* while an activity callback is still running so the message stays
|
|
98
|
+
* leased past the base reservation window. Returns 0 when the lease
|
|
99
|
+
* is no longer this consumer's to hold (reclaimed, acked, or expired).
|
|
100
|
+
*/
|
|
101
|
+
extendReservation(streamName: string, messageId: string, consumerName: string): Promise<number>;
|
|
102
|
+
/**
|
|
103
|
+
* Soft-deletes every live stream row that belongs to a job, across the
|
|
104
|
+
* worker and engine stream tables. Called by the engine when a job is
|
|
105
|
+
* interrupted or scrubbed so its queued, reserved, and scheduled-retry
|
|
106
|
+
* messages are never delivered again.
|
|
107
|
+
*/
|
|
108
|
+
expireJobMessages(jid: string): Promise<number>;
|
|
94
109
|
ackAndDelete(streamName: string, groupName: string, messageIds: string[]): Promise<number>;
|
|
95
110
|
deadLetterMessages(streamName: string, groupName: string, messageIds: string[]): Promise<number>;
|
|
96
111
|
acknowledgeMessages(streamName: string, groupName: string, messageIds: string[], options?: StringAnyType): Promise<number>;
|
|
@@ -118,6 +133,7 @@ declare class PostgresStreamService extends StreamService<PostgresClientType & P
|
|
|
118
133
|
exactLimit?: boolean;
|
|
119
134
|
}): Promise<number>;
|
|
120
135
|
getProviderSpecificFeatures(): {
|
|
136
|
+
supportsReservationExtension: boolean;
|
|
121
137
|
supportsBatching: boolean;
|
|
122
138
|
supportsDeadLetterQueue: boolean;
|
|
123
139
|
supportsOrdering: boolean;
|
|
@@ -62,6 +62,14 @@ class PostgresStreamService extends index_1.StreamService {
|
|
|
62
62
|
// and never need direct table access.
|
|
63
63
|
if (!this.securedMode) {
|
|
64
64
|
await (0, kvtables_1.deploySchema)(this.streamClient, this.appId, this.logger);
|
|
65
|
+
//delivery liveness guard: drops redelivered/retried worker messages
|
|
66
|
+
//whose job is dead. Secured workers cannot read the jobs table —
|
|
67
|
+
//interrupt-time purging (expireJobMessages) covers them instead.
|
|
68
|
+
this.liveness = {
|
|
69
|
+
jobsTable: `${this.safeName(this.appId)}.jobs`,
|
|
70
|
+
keyPrefix: this.mintKey(key_1.KeyType.JOB_STATE, { jobId: '' }),
|
|
71
|
+
enabled: true,
|
|
72
|
+
};
|
|
65
73
|
}
|
|
66
74
|
// Initialize scout manager (skipped in secured mode — roles table inaccessible)
|
|
67
75
|
this.scoutManager = this.securedMode ? null : new scout_1.ScoutManager(this.streamClient, this.appId, this.getEngineTableName.bind(this), this.mintKey.bind(this), this.logger);
|
|
@@ -285,7 +293,32 @@ class PostgresStreamService extends index_1.StreamService {
|
|
|
285
293
|
return Secured.fetchMessagesSecured(this.streamClient, this.safeName(this.appId), target.streamName, consumerName, options || {}, this.logger);
|
|
286
294
|
}
|
|
287
295
|
const target = this.resolveStreamTarget(streamName);
|
|
288
|
-
return Messages.fetchMessages(this.streamClient, target.tableName, target.streamName, target.isEngine, consumerName, options || {}, this.logger);
|
|
296
|
+
return Messages.fetchMessages(this.streamClient, target.tableName, target.streamName, target.isEngine, consumerName, options || {}, this.logger, this.liveness);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Refreshes an owned reservation (heartbeat). Called by the consumer
|
|
300
|
+
* while an activity callback is still running so the message stays
|
|
301
|
+
* leased past the base reservation window. Returns 0 when the lease
|
|
302
|
+
* is no longer this consumer's to hold (reclaimed, acked, or expired).
|
|
303
|
+
*/
|
|
304
|
+
async extendReservation(streamName, messageId, consumerName) {
|
|
305
|
+
if (this.securedMode) {
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
const target = this.resolveStreamTarget(streamName);
|
|
309
|
+
return Messages.extendReservation(this.streamClient, target.tableName, target.streamName, messageId, consumerName, this.logger);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Soft-deletes every live stream row that belongs to a job, across the
|
|
313
|
+
* worker and engine stream tables. Called by the engine when a job is
|
|
314
|
+
* interrupted or scrubbed so its queued, reserved, and scheduled-retry
|
|
315
|
+
* messages are never delivered again.
|
|
316
|
+
*/
|
|
317
|
+
async expireJobMessages(jid) {
|
|
318
|
+
if (this.securedMode) {
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
321
|
+
return Messages.expireJobMessages(this.streamClient, [this.getEngineTableName(), this.getWorkerTableName()], jid, this.logger);
|
|
289
322
|
}
|
|
290
323
|
async ackAndDelete(streamName, groupName, messageIds) {
|
|
291
324
|
if (this.securedMode) {
|
|
@@ -355,7 +388,12 @@ class PostgresStreamService extends index_1.StreamService {
|
|
|
355
388
|
return Stats.trimStream(this.streamClient, target.tableName, target.streamName, options, this.logger);
|
|
356
389
|
}
|
|
357
390
|
getProviderSpecificFeatures() {
|
|
358
|
-
return
|
|
391
|
+
return {
|
|
392
|
+
...Stats.getProviderSpecificFeatures(this.config),
|
|
393
|
+
//heartbeat lease extension uses direct table access; secured
|
|
394
|
+
//workers use stored procedures and keep hard-deadline leases
|
|
395
|
+
supportsReservationExtension: !this.securedMode,
|
|
396
|
+
};
|
|
359
397
|
}
|
|
360
398
|
async cleanup() {
|
|
361
399
|
if (this.scoutManager) {
|