@hotmeshio/hotmesh 0.25.5 → 0.26.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/OPTIMIZATIONS.md +128 -0
- package/RELEASE-0.26.0.md +111 -0
- package/build/package.json +1 -1
- package/build/services/activities/hook.js +22 -0
- package/build/services/durable/workflow/condition.d.ts +20 -3
- package/build/services/durable/workflow/condition.js +20 -3
- package/build/services/durable/workflow/proxyActivities.d.ts +14 -0
- package/build/services/durable/workflow/proxyActivities.js +14 -0
- package/build/services/escalations/client.d.ts +26 -8
- package/build/services/escalations/client.js +43 -17
- package/build/services/store/providers/postgres/postgres.d.ts +35 -7
- package/build/services/store/providers/postgres/postgres.js +244 -142
- package/build/services/stream/providers/postgres/kvtables.js +76 -14
- package/build/services/stream/providers/postgres/messages.js +6 -3
- package/build/services/stream/providers/postgres/procedures.js +6 -4
- package/build/types/hmsh_escalations.d.ts +30 -8
- package/package.json +1 -1
- package/ISSUE.md +0 -53
|
@@ -35,6 +35,10 @@ async function deploySchema(streamClient, appId, logger) {
|
|
|
35
35
|
await ensureIndexes(client, schemaName);
|
|
36
36
|
await ensureProcedures(client, schemaName);
|
|
37
37
|
await ensureStatementLevelTriggers(client, schemaName);
|
|
38
|
+
// Re-deploy the fallback poller's discovery function so existing
|
|
39
|
+
// databases receive predicate changes (v0.25.6: stale-reservation
|
|
40
|
+
// reclaim — see getNotifyVisibleMessagesSQL)
|
|
41
|
+
await client.query(getNotifyVisibleMessagesSQL(schemaName));
|
|
38
42
|
}
|
|
39
43
|
finally {
|
|
40
44
|
await client.query('SELECT pg_advisory_unlock($1)', [lockId]);
|
|
@@ -177,6 +181,19 @@ async function ensureIndexes(client, schemaName) {
|
|
|
177
181
|
CREATE INDEX IF NOT EXISTS idx_worker_streams_message_fetch
|
|
178
182
|
ON ${workerTable} (stream_name, priority DESC, id)
|
|
179
183
|
WHERE expired_at IS NULL;
|
|
184
|
+
`);
|
|
185
|
+
// v0.25.6: in-flight partial indexes — bounded to currently reserved
|
|
186
|
+
// rows (≈ concurrent executions) — serve the fallback poller's
|
|
187
|
+
// stale-reservation reclaim branch (see getNotifyVisibleMessagesSQL)
|
|
188
|
+
await client.query(`
|
|
189
|
+
CREATE INDEX IF NOT EXISTS idx_engine_streams_inflight
|
|
190
|
+
ON ${engineTable} (stream_name, reserved_at)
|
|
191
|
+
WHERE expired_at IS NULL AND reserved_at IS NOT NULL;
|
|
192
|
+
`);
|
|
193
|
+
await client.query(`
|
|
194
|
+
CREATE INDEX IF NOT EXISTS idx_worker_streams_inflight
|
|
195
|
+
ON ${workerTable} (stream_name, reserved_at)
|
|
196
|
+
WHERE expired_at IS NULL AND reserved_at IS NOT NULL;
|
|
180
197
|
`);
|
|
181
198
|
// v0.18.0: add jid column to engine_streams for job tracing
|
|
182
199
|
await client.query(`ALTER TABLE ${engineTable} ADD COLUMN IF NOT EXISTS jid TEXT NOT NULL DEFAULT ''`);
|
|
@@ -461,7 +478,34 @@ async function createNotificationTriggers(client, schemaName) {
|
|
|
461
478
|
EXECUTE FUNCTION ${schemaName}.notify_new_worker_stream_message();
|
|
462
479
|
`);
|
|
463
480
|
// ---- Visibility timeout notification function (queries both tables) ----
|
|
464
|
-
await client.query(
|
|
481
|
+
await client.query(getNotifyVisibleMessagesSQL(schemaName));
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* The fallback poller's discovery function. Surfaces streams that have
|
|
485
|
+
* deliverable work: unreserved visible messages AND stale reservations
|
|
486
|
+
* whose holder died (heartbeats refresh reserved_at every ~15s while an
|
|
487
|
+
* activity runs, so any reservation older than 60s has a dead holder or
|
|
488
|
+
* is already claimable). Stale-reservation discovery is the ONLY path
|
|
489
|
+
* that reclaims orphaned in-flight work on a quiet stream — without it,
|
|
490
|
+
* a process death wedges its reserved messages forever. A premature
|
|
491
|
+
* surface (reservation window configured above 60s on a provider with
|
|
492
|
+
* static leases) costs one empty fetch; the claim query enforces the
|
|
493
|
+
* real window.
|
|
494
|
+
*
|
|
495
|
+
* Deployed on fresh schemas AND re-deployed on every boot (see
|
|
496
|
+
* deploySchema) so existing databases receive predicate changes.
|
|
497
|
+
*/
|
|
498
|
+
function getNotifyVisibleMessagesSQL(schemaName) {
|
|
499
|
+
const engineTable = `${schemaName}.engine_streams`;
|
|
500
|
+
const workerTable = `${schemaName}.worker_streams`;
|
|
501
|
+
//stale threshold tracks the configured base window: heartbeats refresh
|
|
502
|
+
//reserved_at every base/2 (15s default), so live holders never look
|
|
503
|
+
//stale; 2x base keeps static-lease holders (secured workers, one
|
|
504
|
+
//adaptive doubling) from surfacing as false positives. Baked at
|
|
505
|
+
//deploy time from the deploying node's env — a premature surface
|
|
506
|
+
//costs one empty fetch (the claim query enforces the real window).
|
|
507
|
+
const staleSeconds = Math.max(60, enums_1.HMSH_RESERVATION_TIMEOUT_S * 2);
|
|
508
|
+
return `
|
|
465
509
|
CREATE OR REPLACE FUNCTION ${schemaName}.notify_visible_messages()
|
|
466
510
|
RETURNS INTEGER AS $$
|
|
467
511
|
DECLARE
|
|
@@ -470,13 +514,23 @@ async function createNotificationTriggers(client, schemaName) {
|
|
|
470
514
|
payload JSON;
|
|
471
515
|
notification_count INTEGER := 0;
|
|
472
516
|
BEGIN
|
|
473
|
-
-- Engine streams
|
|
517
|
+
-- Engine streams: visible unreserved work (active_messages
|
|
518
|
+
-- partial index) UNION stale reservations whose holder died
|
|
519
|
+
-- (inflight partial index) — each branch index-matched
|
|
474
520
|
FOR msg IN
|
|
475
|
-
SELECT DISTINCT stream_name
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
521
|
+
SELECT DISTINCT u.stream_name FROM (
|
|
522
|
+
(SELECT stream_name FROM ${engineTable}
|
|
523
|
+
WHERE visible_at <= NOW()
|
|
524
|
+
AND reserved_at IS NULL
|
|
525
|
+
AND expired_at IS NULL
|
|
526
|
+
LIMIT 50)
|
|
527
|
+
UNION ALL
|
|
528
|
+
(SELECT stream_name FROM ${engineTable}
|
|
529
|
+
WHERE expired_at IS NULL
|
|
530
|
+
AND reserved_at IS NOT NULL
|
|
531
|
+
AND reserved_at < NOW() - INTERVAL '${staleSeconds} seconds'
|
|
532
|
+
LIMIT 50)
|
|
533
|
+
) u
|
|
480
534
|
LIMIT 50
|
|
481
535
|
LOOP
|
|
482
536
|
channel_name := 'eng_' || msg.stream_name;
|
|
@@ -493,13 +547,21 @@ async function createNotificationTriggers(client, schemaName) {
|
|
|
493
547
|
notification_count := notification_count + 1;
|
|
494
548
|
END LOOP;
|
|
495
549
|
|
|
496
|
-
-- Worker streams
|
|
550
|
+
-- Worker streams: same two index-matched branches
|
|
497
551
|
FOR msg IN
|
|
498
|
-
SELECT DISTINCT stream_name
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
552
|
+
SELECT DISTINCT u.stream_name FROM (
|
|
553
|
+
(SELECT stream_name FROM ${workerTable}
|
|
554
|
+
WHERE visible_at <= NOW()
|
|
555
|
+
AND reserved_at IS NULL
|
|
556
|
+
AND expired_at IS NULL
|
|
557
|
+
LIMIT 50)
|
|
558
|
+
UNION ALL
|
|
559
|
+
(SELECT stream_name FROM ${workerTable}
|
|
560
|
+
WHERE expired_at IS NULL
|
|
561
|
+
AND reserved_at IS NOT NULL
|
|
562
|
+
AND reserved_at < NOW() - INTERVAL '${staleSeconds} seconds'
|
|
563
|
+
LIMIT 50)
|
|
564
|
+
) u
|
|
503
565
|
LIMIT 50
|
|
504
566
|
LOOP
|
|
505
567
|
channel_name := 'wrk_' || msg.stream_name;
|
|
@@ -519,7 +581,7 @@ async function createNotificationTriggers(client, schemaName) {
|
|
|
519
581
|
RETURN notification_count;
|
|
520
582
|
END;
|
|
521
583
|
$$ LANGUAGE plpgsql;
|
|
522
|
-
|
|
584
|
+
`;
|
|
523
585
|
}
|
|
524
586
|
function getNotificationChannelName(streamName, isEngine) {
|
|
525
587
|
const prefix = isEngine ? 'eng_' : 'wrk_';
|
|
@@ -296,12 +296,15 @@ async function dropDeadJobMessages(client, tableName, streamName, rows, liveness
|
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
298
|
catch (error) {
|
|
299
|
-
if (error?.code === '42P01') {
|
|
300
|
-
//jobs table is not visible
|
|
301
|
-
//run here — interrupt-time
|
|
299
|
+
if (error?.code === '42P01' || error?.code === '42501') {
|
|
300
|
+
//jobs table is not visible (42P01) or not readable (42501) from
|
|
301
|
+
//this connection; the guard cannot run here — interrupt-time
|
|
302
|
+
//purging (expireJobMessages) still applies. Self-disable so the
|
|
303
|
+
//hot path stops issuing a failing cross-table query per fetch.
|
|
302
304
|
liveness.enabled = false;
|
|
303
305
|
logger.info('postgres-stream-liveness-guard-disabled', {
|
|
304
306
|
jobsTable: liveness.jobsTable,
|
|
307
|
+
code: error.code,
|
|
305
308
|
});
|
|
306
309
|
}
|
|
307
310
|
else {
|
|
@@ -150,17 +150,19 @@ function getCreateProceduresSQL(schemaName) {
|
|
|
150
150
|
-- Engine stream_name is the schema/appId name
|
|
151
151
|
engine_stream_name := '${schemaName}';
|
|
152
152
|
|
|
153
|
+
-- jid stamped from the message metadata (parity with the raw
|
|
154
|
+
-- publish path) so job-scoped expiry can address these rows
|
|
153
155
|
IF p_max_retry_attempts IS NOT NULL THEN
|
|
154
156
|
INSERT INTO ${engineTable}
|
|
155
|
-
(stream_name, message, priority, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)
|
|
157
|
+
(stream_name, jid, message, priority, max_retry_attempts, backoff_coefficient, maximum_interval_seconds, visible_at, retry_attempt)
|
|
156
158
|
VALUES
|
|
157
|
-
(engine_stream_name, p_message, p_priority, p_max_retry_attempts, p_backoff_coefficient, p_maximum_interval_seconds, p_visible_at, p_retry_attempt)
|
|
159
|
+
(engine_stream_name, COALESCE(p_message::jsonb -> 'metadata' ->> 'jid', ''), p_message, p_priority, p_max_retry_attempts, p_backoff_coefficient, p_maximum_interval_seconds, p_visible_at, p_retry_attempt)
|
|
158
160
|
RETURNING id INTO new_id;
|
|
159
161
|
ELSE
|
|
160
162
|
INSERT INTO ${engineTable}
|
|
161
|
-
(stream_name, message, priority, visible_at, retry_attempt)
|
|
163
|
+
(stream_name, jid, message, priority, visible_at, retry_attempt)
|
|
162
164
|
VALUES
|
|
163
|
-
(engine_stream_name, p_message, p_priority, p_visible_at, p_retry_attempt)
|
|
165
|
+
(engine_stream_name, COALESCE(p_message::jsonb -> 'metadata' ->> 'jid', ''), p_message, p_priority, p_visible_at, p_retry_attempt)
|
|
164
166
|
RETURNING id INTO new_id;
|
|
165
167
|
END IF;
|
|
166
168
|
|
|
@@ -106,17 +106,16 @@ export type ResolveEscalationResult = {
|
|
|
106
106
|
reason: 'not-found' | 'already-resolved' | 'already-cancelled' | 'already-expired';
|
|
107
107
|
};
|
|
108
108
|
/**
|
|
109
|
-
* A pre-built wake
|
|
110
|
-
* awaiting workflow's wake
|
|
111
|
-
*
|
|
112
|
-
* pins the
|
|
113
|
-
* when the
|
|
114
|
-
* to post-commit delivery).
|
|
109
|
+
* A pre-built wake message, committed INSIDE the resolve/cancel transaction
|
|
110
|
+
* so the awaiting workflow's wake is durable with the status change. The
|
|
111
|
+
* client composes the webhook message; the store owns the stream INSERT.
|
|
112
|
+
* `forSignalKey` pins the wake to the row it was built for — it is written
|
|
113
|
+
* only when the affected row's `signal_key` matches (a mismatched row falls
|
|
114
|
+
* back to post-commit delivery).
|
|
115
115
|
*/
|
|
116
116
|
export interface EscalationWakeCommand {
|
|
117
117
|
forSignalKey: string;
|
|
118
|
-
|
|
119
|
-
params: unknown[];
|
|
118
|
+
message: string;
|
|
120
119
|
}
|
|
121
120
|
export type ReleaseEscalationResult = {
|
|
122
121
|
ok: true;
|
|
@@ -132,6 +131,29 @@ export type CancelEscalationResult = {
|
|
|
132
131
|
ok: false;
|
|
133
132
|
reason: 'not-found' | 'already-terminal';
|
|
134
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* Retention parameters for `pruneEscalations`. Prunes only terminal rows
|
|
136
|
+
* (`resolved`/`cancelled`/`expired`) — the statuses every engine state
|
|
137
|
+
* transition treats as final — older than the given horizon.
|
|
138
|
+
*/
|
|
139
|
+
export interface PruneEscalationsParams {
|
|
140
|
+
/**
|
|
141
|
+
* Age horizon as a Postgres interval string (e.g. `'90 days'`, `'12 hours'`).
|
|
142
|
+
* Rows qualify when `updated_at < NOW() - olderThan`.
|
|
143
|
+
*/
|
|
144
|
+
olderThan: string;
|
|
145
|
+
/** Terminal statuses to prune. Defaults to all three; non-terminal values are ignored. */
|
|
146
|
+
statuses?: Array<'resolved' | 'cancelled' | 'expired'>;
|
|
147
|
+
namespace?: string;
|
|
148
|
+
/**
|
|
149
|
+
* Max rows deleted per call (bounds lock time and vacuum pressure).
|
|
150
|
+
* Default 10,000, capped at 100,000. Loop until `deleted` is 0 to drain.
|
|
151
|
+
*/
|
|
152
|
+
limit?: number;
|
|
153
|
+
}
|
|
154
|
+
export interface PruneEscalationsResult {
|
|
155
|
+
deleted: number;
|
|
156
|
+
}
|
|
135
157
|
export interface ListEscalationsParams {
|
|
136
158
|
namespace?: string;
|
|
137
159
|
role?: string;
|
package/package.json
CHANGED
package/ISSUE.md
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
# ADDENDUM — lost condition wakes are NOT restart-dependent (registration-window race)
|
|
2
|
-
|
|
3
|
-
Follow-up to the earlier "Durable wakes lost across process restart" report filed
|
|
4
|
-
tonight (sleep timers + condition signals). New evidence sharpens the condition case.
|
|
5
|
-
|
|
6
|
-
## Reproduced with no restart involved
|
|
7
|
-
|
|
8
|
-
Same environment (hotmesh 0.25.4, Postgres, long-tail 0.7.3), process running
|
|
9
|
-
continuously the whole time:
|
|
10
|
-
|
|
11
|
-
- `13:59:36.204` — pill escalation created (conditionLT atomic park, child
|
|
12
|
-
`...-winddown-gluer-0`).
|
|
13
|
-
- `13:59:36.237` — row **resolved**, 33 ms after creation (a hot consumer was
|
|
14
|
-
already scanning the pond and grabbed it instantly).
|
|
15
|
-
- 15+ minutes later — the awaiting child is still `status > 0`, never woke;
|
|
16
|
-
its parent's `Promise.all` wedged with it.
|
|
17
|
-
|
|
18
|
-
## The pattern across all three observed condition cases
|
|
19
|
-
|
|
20
|
-
Every lost condition wake we have seen shares one property: **the resolve landed
|
|
21
|
-
sub-second after the conditionLT park** (33 ms, 232 ms, ~600 ms). Slow resolves
|
|
22
|
-
(seconds or minutes after park) have never lost a wake across hundreds of
|
|
23
|
-
station-worker resolutions tonight.
|
|
24
|
-
|
|
25
|
-
Hypothesis: a resolve that commits **inside the awaiting workflow's
|
|
26
|
-
wake-registration window** — after the escalation row is visible to consumers
|
|
27
|
-
but before the awaiter's subscription is fully registered — delivers to nobody,
|
|
28
|
-
and nothing ever re-checks the persisted row against sleeping awaiters. The
|
|
29
|
-
restart in the earlier case was likely incidental; the window is the bug.
|
|
30
|
-
|
|
31
|
-
## Suggested repro (tighter than the original)
|
|
32
|
-
|
|
33
|
-
1. Workflow A parks via `conditionLT` (the escalation-creating overload).
|
|
34
|
-
2. A hot loop polls for the row and resolves it the instant it appears
|
|
35
|
-
(sub-100 ms). No process kill needed.
|
|
36
|
-
3. Observe whether A ever wakes. In our runs, a same-process consumer beating
|
|
37
|
-
the registration window wedges A reliably enough to hit twice in one evening.
|
|
38
|
-
|
|
39
|
-
## Suggested direction (unchanged, reinforced)
|
|
40
|
-
|
|
41
|
-
Boot-time AND periodic reconciliation of resolved-rows-with-sleeping-awaiters
|
|
42
|
-
would heal both the restart loss and this race — the row carries the awaiting
|
|
43
|
-
`workflow_id`; the join is cheap. Making the wake itself a stream message
|
|
44
|
-
(liveness-redelivered since 0.25.4) also closes the window, since the message
|
|
45
|
-
persists until reserved and acked.
|
|
46
|
-
|
|
47
|
-
## Our workaround (in production use as of tonight)
|
|
48
|
-
|
|
49
|
-
Settlement guard in the watcher activity: after the row's outcome is known,
|
|
50
|
-
poll `durable.jobs` for the awaiting child's status; if still running ~10s
|
|
51
|
-
after its row resolved, terminate the child by id (terminate purges cleanly
|
|
52
|
-
since 0.25.4) and treat the row's persisted resolver payload as authoritative.
|
|
53
|
-
The parent catches the child's terminate rejection. Ugly but wedge-proof.
|