@hotmeshio/hotmesh 0.25.4 → 0.25.5
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/ISSUE.md +53 -0
- package/build/package.json +1 -1
- package/build/services/escalations/client.d.ts +10 -0
- package/build/services/escalations/client.js +67 -4
- package/build/services/store/index.d.ts +10 -1
- package/build/services/store/providers/postgres/postgres.d.ts +27 -3
- package/build/services/store/providers/postgres/postgres.js +96 -5
- package/build/services/task/index.js +7 -2
- package/build/types/hmsh_escalations.d.ts +13 -0
- package/package.json +1 -1
package/ISSUE.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
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.
|
package/build/package.json
CHANGED
|
@@ -72,6 +72,16 @@ export declare class EscalationClientService {
|
|
|
72
72
|
private _makeEngineFactory;
|
|
73
73
|
private _hashConnection;
|
|
74
74
|
private _deliverEscalationSignal;
|
|
75
|
+
/**
|
|
76
|
+
* Builds the wake publish as a SQL command so the store can commit it
|
|
77
|
+
* INSIDE the resolve transaction — the wake becomes durable with the
|
|
78
|
+
* resolved row, closing the crash window between resolve commit and
|
|
79
|
+
* post-commit signal delivery. Mirrors `_deliverEscalationSignal`'s
|
|
80
|
+
* topic fallback chain; returns null when no hook rule is deployed
|
|
81
|
+
* for any candidate topic (the caller then keeps post-commit
|
|
82
|
+
* delivery as the only path, preserving prior behavior).
|
|
83
|
+
*/
|
|
84
|
+
private _buildWakeCommand;
|
|
75
85
|
/**
|
|
76
86
|
* Returns all escalation rows matching the given filters. Each row includes
|
|
77
87
|
* a computed `available` field (true = claimable). Supports `sortBy`,
|
|
@@ -3,8 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.EscalationClientService = void 0;
|
|
4
4
|
const enums_1 = require("../../modules/enums");
|
|
5
5
|
const utils_1 = require("../../modules/utils");
|
|
6
|
+
const key_1 = require("../../modules/key");
|
|
6
7
|
const hotmesh_1 = require("../hotmesh");
|
|
7
8
|
const types_1 = require("../../types");
|
|
9
|
+
const stream_1 = require("../../types/stream");
|
|
8
10
|
const factory_1 = require("../durable/schemas/factory");
|
|
9
11
|
/**
|
|
10
12
|
* Standalone client for the `public.hmsh_escalations` signal-pause surface.
|
|
@@ -145,6 +147,46 @@ class EscalationClientService {
|
|
|
145
147
|
catch { }
|
|
146
148
|
return delivered;
|
|
147
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Builds the wake publish as a SQL command so the store can commit it
|
|
152
|
+
* INSIDE the resolve transaction — the wake becomes durable with the
|
|
153
|
+
* resolved row, closing the crash window between resolve commit and
|
|
154
|
+
* post-commit signal delivery. Mirrors `_deliverEscalationSignal`'s
|
|
155
|
+
* topic fallback chain; returns null when no hook rule is deployed
|
|
156
|
+
* for any candidate topic (the caller then keeps post-commit
|
|
157
|
+
* delivery as the only path, preserving prior behavior).
|
|
158
|
+
*/
|
|
159
|
+
async _buildWakeCommand(ns, topic, signalKey, data) {
|
|
160
|
+
const hm = await this._engine(null, ns);
|
|
161
|
+
const engine = hm.engine;
|
|
162
|
+
const candidates = [topic, `${ns}.wfs.signal`, `${ns}.wfs.wait`].filter(Boolean);
|
|
163
|
+
for (const candidate of candidates) {
|
|
164
|
+
try {
|
|
165
|
+
const hookRule = await engine.taskService.getHookRule(candidate);
|
|
166
|
+
if (!hookRule)
|
|
167
|
+
continue;
|
|
168
|
+
const [aid] = await engine.getSchema(`.${hookRule.to}`);
|
|
169
|
+
const streamData = {
|
|
170
|
+
type: stream_1.StreamDataType.WEBHOOK,
|
|
171
|
+
status: types_1.StreamStatus.SUCCESS,
|
|
172
|
+
code: 200,
|
|
173
|
+
metadata: { guid: (0, utils_1.guid)(), aid, topic: candidate },
|
|
174
|
+
data: { id: signalKey, data },
|
|
175
|
+
};
|
|
176
|
+
const streamKey = engine.stream.mintKey(key_1.KeyType.STREAMS, {
|
|
177
|
+
topic: null,
|
|
178
|
+
});
|
|
179
|
+
const { sql, params } = engine.stream._publishMessages(streamKey, [
|
|
180
|
+
JSON.stringify(streamData),
|
|
181
|
+
]);
|
|
182
|
+
return { forSignalKey: signalKey, sql, params };
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
/* candidate not deployed — try the next topic */
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
148
190
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
149
191
|
/**
|
|
150
192
|
* Returns all escalation rows matching the given filters. Each row includes
|
|
@@ -292,10 +334,19 @@ class EscalationClientService {
|
|
|
292
334
|
const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
|
|
293
335
|
const hm = await this._engine(null, ns);
|
|
294
336
|
const store = hm.engine.store;
|
|
295
|
-
|
|
337
|
+
//pre-build the wake so it commits INSIDE the resolve transaction; the
|
|
338
|
+
//row's signal routing (signal_key, topic) is immutable after creation
|
|
339
|
+
let wakeCommand = null;
|
|
340
|
+
const preview = await store.getEscalation(params.id, params.namespace);
|
|
341
|
+
if (preview?.signal_key) {
|
|
342
|
+
wakeCommand = await this._buildWakeCommand(ns, preview.topic, preview.signal_key, params.resolverPayload ?? {});
|
|
343
|
+
}
|
|
344
|
+
const dbResult = await store.resolveEscalation({ id: params.id, resolverPayload: params.resolverPayload, metadata: params.metadata }, wakeCommand ?? undefined);
|
|
296
345
|
if (!dbResult.ok)
|
|
297
346
|
return dbResult;
|
|
298
|
-
if (dbResult.signalKey) {
|
|
347
|
+
if (dbResult.signalKey && !dbResult.wakeEnqueued) {
|
|
348
|
+
//the wake was not part of the commit (no hook rule found, or the
|
|
349
|
+
//enqueue was rolled back to its savepoint) — deliver post-commit
|
|
299
350
|
await this._deliverEscalationSignal(ns, dbResult.topic, {
|
|
300
351
|
id: dbResult.signalKey,
|
|
301
352
|
data: params.resolverPayload ?? {},
|
|
@@ -316,10 +367,22 @@ class EscalationClientService {
|
|
|
316
367
|
const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
|
|
317
368
|
const hm = await this._engine(null, ns);
|
|
318
369
|
const store = hm.engine.store;
|
|
319
|
-
|
|
370
|
+
//peek the row the resolve is expected to lock and pre-build its wake;
|
|
371
|
+
//the forSignalKey guard covers the race where a different row wins
|
|
372
|
+
let wakeCommand = null;
|
|
373
|
+
const preview = await store.peekEscalationByMetadata({
|
|
374
|
+
key: params.key,
|
|
375
|
+
value: params.value,
|
|
376
|
+
roles: params.roles,
|
|
377
|
+
namespace: params.namespace,
|
|
378
|
+
});
|
|
379
|
+
if (preview?.signalKey) {
|
|
380
|
+
wakeCommand = await this._buildWakeCommand(ns, preview.topic, preview.signalKey, params.resolverPayload ?? {});
|
|
381
|
+
}
|
|
382
|
+
const dbResult = await store.resolveEscalationByMetadata({ key: params.key, value: params.value, resolverPayload: params.resolverPayload, roles: params.roles, metadata: params.metadata }, wakeCommand ?? undefined);
|
|
320
383
|
if (!dbResult.ok)
|
|
321
384
|
return dbResult;
|
|
322
|
-
if (dbResult.signalKey) {
|
|
385
|
+
if (dbResult.signalKey && !dbResult.wakeEnqueued) {
|
|
323
386
|
await this._deliverEscalationSignal(ns, dbResult.topic, {
|
|
324
387
|
id: dbResult.signalKey,
|
|
325
388
|
data: params.resolverPayload ?? {},
|
|
@@ -68,8 +68,17 @@ declare abstract class StoreService<Provider extends ProviderClient, Transaction
|
|
|
68
68
|
* Leg1: Attempts to set the hook signal. If a pending signal occupies
|
|
69
69
|
* the key (race condition), overwrites it and returns the pending data.
|
|
70
70
|
* When called with a transaction, queues the setnxex (no pending detection).
|
|
71
|
+
*
|
|
72
|
+
* When `redelivery` is provided (the webhook routing for this hook),
|
|
73
|
+
* a consumed pending signal is republished as an engine stream message
|
|
74
|
+
* in the SAME transaction that overwrites the marker — the wake
|
|
75
|
+
* survives a crash at any instant. `pendingData` is then not returned,
|
|
76
|
+
* since the store already owns the redelivery.
|
|
71
77
|
*/
|
|
72
|
-
abstract setHookSignal(hook: HookSignal, transaction?: TransactionProvider
|
|
78
|
+
abstract setHookSignal(hook: HookSignal, transaction?: TransactionProvider, redelivery?: {
|
|
79
|
+
aid: string;
|
|
80
|
+
topic: string;
|
|
81
|
+
}): Promise<{
|
|
73
82
|
success: boolean;
|
|
74
83
|
pendingData?: string;
|
|
75
84
|
}>;
|
|
@@ -128,7 +128,10 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
|
|
|
128
128
|
*
|
|
129
129
|
* In a transaction: queues the setnxex; pending detection deferred.
|
|
130
130
|
*/
|
|
131
|
-
setHookSignal(hook: HookSignal, transaction?: ProviderTransaction
|
|
131
|
+
setHookSignal(hook: HookSignal, transaction?: ProviderTransaction, redelivery?: {
|
|
132
|
+
aid: string;
|
|
133
|
+
topic: string;
|
|
134
|
+
}): Promise<{
|
|
132
135
|
success: boolean;
|
|
133
136
|
pendingData?: string;
|
|
134
137
|
}>;
|
|
@@ -270,13 +273,34 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
|
|
|
270
273
|
claimEscalation(params: import('../../../../types/hmsh_escalations').ClaimEscalationParams): Promise<import('../../../../types/hmsh_escalations').ClaimEscalationResult>;
|
|
271
274
|
claimEscalationByMetadata(params: import('../../../../types/hmsh_escalations').ClaimByMetadataParams): Promise<import('../../../../types/hmsh_escalations').ClaimByMetadataResult>;
|
|
272
275
|
releaseEscalation(params: import('../../../../types/hmsh_escalations').ReleaseEscalationParams): Promise<import('../../../../types/hmsh_escalations').ReleaseEscalationResult>;
|
|
273
|
-
|
|
276
|
+
/**
|
|
277
|
+
* Executes a pre-built wake publish inside the currently open resolve
|
|
278
|
+
* transaction, guarded by a SAVEPOINT so a wake failure can never roll
|
|
279
|
+
* back the resolve itself. Returns true when the wake row committed
|
|
280
|
+
* with the transaction; false means the caller should fall back to
|
|
281
|
+
* post-commit delivery.
|
|
282
|
+
*/
|
|
283
|
+
private enqueueEscalationWake;
|
|
284
|
+
resolveEscalation(params: import('../../../../types/hmsh_escalations').ResolveEscalationParams, wakeCommand?: import('../../../../types/hmsh_escalations').EscalationWakeCommand): Promise<import('../../../../types/hmsh_escalations').ResolveEscalationResult & {
|
|
274
285
|
signalKey?: string | null;
|
|
275
286
|
topic?: string | null;
|
|
287
|
+
wakeEnqueued?: boolean;
|
|
276
288
|
}>;
|
|
277
|
-
|
|
289
|
+
/**
|
|
290
|
+
* Non-locking preview of the row `resolveEscalationByMetadata` would
|
|
291
|
+
* select — used to pre-build the wake command before the resolve
|
|
292
|
+
* transaction opens. The `forSignalKey` guard on the wake command
|
|
293
|
+
* handles the race where a different row wins the lock.
|
|
294
|
+
*/
|
|
295
|
+
peekEscalationByMetadata(params: import('../../../../types/hmsh_escalations').ResolveByMetadataParams): Promise<{
|
|
296
|
+
id: string;
|
|
297
|
+
signalKey: string | null;
|
|
298
|
+
topic: string | null;
|
|
299
|
+
} | null>;
|
|
300
|
+
resolveEscalationByMetadata(params: import('../../../../types/hmsh_escalations').ResolveByMetadataParams, wakeCommand?: import('../../../../types/hmsh_escalations').EscalationWakeCommand): Promise<import('../../../../types/hmsh_escalations').ResolveEscalationResult & {
|
|
278
301
|
signalKey?: string | null;
|
|
279
302
|
topic?: string | null;
|
|
303
|
+
wakeEnqueued?: boolean;
|
|
280
304
|
}>;
|
|
281
305
|
cancelEscalation(id: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').CancelEscalationResult>;
|
|
282
306
|
escalateEscalationToRole(params: import('../../../../types/hmsh_escalations').EscalateToRoleParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
|
|
@@ -777,7 +777,7 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
777
777
|
*
|
|
778
778
|
* In a transaction: queues the setnxex; pending detection deferred.
|
|
779
779
|
*/
|
|
780
|
-
async setHookSignal(hook, transaction) {
|
|
780
|
+
async setHookSignal(hook, transaction, redelivery) {
|
|
781
781
|
const key = this.mintKey(key_1.KeyType.SIGNALS, { appId: this.appId });
|
|
782
782
|
const { topic, resolved, jobId } = hook;
|
|
783
783
|
const signalKey = `${topic}:${resolved}`;
|
|
@@ -813,6 +813,45 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
813
813
|
const captured = lockResult.rows[0]?.value;
|
|
814
814
|
const wasInsert = lockResult.rows[0]?.inserted;
|
|
815
815
|
const isPending = captured?.startsWith('$pending::');
|
|
816
|
+
if (isPending && redelivery) {
|
|
817
|
+
//consume the marker and commit its redelivery as ONE unit: the
|
|
818
|
+
//pending wake becomes a durable engine stream message in the
|
|
819
|
+
//same transaction that destroys the marker, so the wake
|
|
820
|
+
//survives a crash at any instant. A crash before COMMIT leaves
|
|
821
|
+
//the marker intact for the resume path to consume again.
|
|
822
|
+
const pendingData = captured.slice('$pending::'.length);
|
|
823
|
+
const message = JSON.stringify({
|
|
824
|
+
type: 'webhook',
|
|
825
|
+
status: 'success',
|
|
826
|
+
code: 200,
|
|
827
|
+
metadata: {
|
|
828
|
+
guid: (0, utils_1.guid)(),
|
|
829
|
+
aid: redelivery.aid,
|
|
830
|
+
topic: redelivery.topic,
|
|
831
|
+
},
|
|
832
|
+
data: JSON.parse(pendingData),
|
|
833
|
+
});
|
|
834
|
+
const schemaName = this.kvsql().safeName(this.appId);
|
|
835
|
+
await this.pgClient.query('BEGIN');
|
|
836
|
+
try {
|
|
837
|
+
await this.pgClient.query(`UPDATE ${tableName}
|
|
838
|
+
SET value = $1, expiry = NOW() + INTERVAL '${delay} seconds'
|
|
839
|
+
WHERE key = $2`, [jobId, storedKey]);
|
|
840
|
+
await this.pgClient.query(`INSERT INTO ${schemaName}.engine_streams
|
|
841
|
+
(stream_name, message, priority)
|
|
842
|
+
VALUES ($1, $2, 5)`, [this.appId, message]);
|
|
843
|
+
await this.pgClient.query('COMMIT');
|
|
844
|
+
}
|
|
845
|
+
catch (redeliveryError) {
|
|
846
|
+
await this.pgClient.query('ROLLBACK');
|
|
847
|
+
throw redeliveryError;
|
|
848
|
+
}
|
|
849
|
+
this.logger.warn('hook-signal-pending-redelivered', {
|
|
850
|
+
key: signalKey,
|
|
851
|
+
topic: redelivery.topic,
|
|
852
|
+
});
|
|
853
|
+
return { success: true };
|
|
854
|
+
}
|
|
816
855
|
if (!wasInsert) {
|
|
817
856
|
//step 2: row existed — overwrite with hook value
|
|
818
857
|
await this.pgClient.query(`UPDATE ${tableName}
|
|
@@ -1792,7 +1831,32 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1792
1831
|
return { ok: false, reason: 'wrong-assignee' };
|
|
1793
1832
|
return { ok: true, entry: row.entry_json };
|
|
1794
1833
|
}
|
|
1795
|
-
|
|
1834
|
+
/**
|
|
1835
|
+
* Executes a pre-built wake publish inside the currently open resolve
|
|
1836
|
+
* transaction, guarded by a SAVEPOINT so a wake failure can never roll
|
|
1837
|
+
* back the resolve itself. Returns true when the wake row committed
|
|
1838
|
+
* with the transaction; false means the caller should fall back to
|
|
1839
|
+
* post-commit delivery.
|
|
1840
|
+
*/
|
|
1841
|
+
async enqueueEscalationWake(wakeCommand, signalKey, escalationId) {
|
|
1842
|
+
if (!wakeCommand || !signalKey || wakeCommand.forSignalKey !== signalKey) {
|
|
1843
|
+
return false;
|
|
1844
|
+
}
|
|
1845
|
+
await this.pgClient.query('SAVEPOINT escalation_wake');
|
|
1846
|
+
try {
|
|
1847
|
+
await this.pgClient.query(wakeCommand.sql, wakeCommand.params);
|
|
1848
|
+
return true;
|
|
1849
|
+
}
|
|
1850
|
+
catch (error) {
|
|
1851
|
+
await this.pgClient.query('ROLLBACK TO SAVEPOINT escalation_wake');
|
|
1852
|
+
this.logger.warn('escalation-wake-enqueue-error', {
|
|
1853
|
+
escalationId,
|
|
1854
|
+
error: error.message,
|
|
1855
|
+
});
|
|
1856
|
+
return false;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
async resolveEscalation(params, wakeCommand) {
|
|
1796
1860
|
const { id, namespace, resolverPayload, metadata } = params;
|
|
1797
1861
|
const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
|
|
1798
1862
|
// metaJson is bound at a fixed index; the CASE no-ops when null so resolution
|
|
@@ -1835,15 +1899,41 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1835
1899
|
await this.pgClient.query('ROLLBACK');
|
|
1836
1900
|
return { ok: false, reason: 'already-resolved' };
|
|
1837
1901
|
}
|
|
1902
|
+
//the wake commits WITH the resolve: a crash after COMMIT leaves both
|
|
1903
|
+
//the resolved row and its wake message durable; a crash before
|
|
1904
|
+
//leaves neither. The engine_streams INSERT trigger emits the
|
|
1905
|
+
//delivery NOTIFY on commit.
|
|
1906
|
+
const wakeEnqueued = await this.enqueueEscalationWake(wakeCommand, signal_key, id);
|
|
1838
1907
|
await this.pgClient.query('COMMIT');
|
|
1839
|
-
return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic };
|
|
1908
|
+
return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic, wakeEnqueued };
|
|
1840
1909
|
}
|
|
1841
1910
|
catch (e) {
|
|
1842
1911
|
await this.pgClient.query('ROLLBACK');
|
|
1843
1912
|
throw e;
|
|
1844
1913
|
}
|
|
1845
1914
|
}
|
|
1846
|
-
|
|
1915
|
+
/**
|
|
1916
|
+
* Non-locking preview of the row `resolveEscalationByMetadata` would
|
|
1917
|
+
* select — used to pre-build the wake command before the resolve
|
|
1918
|
+
* transaction opens. The `forSignalKey` guard on the wake command
|
|
1919
|
+
* handles the race where a different row wins the lock.
|
|
1920
|
+
*/
|
|
1921
|
+
async peekEscalationByMetadata(params) {
|
|
1922
|
+
const { key, value, namespace, roles } = params;
|
|
1923
|
+
const filter = JSON.stringify({ [key]: value });
|
|
1924
|
+
const result = await this.pgClient.query(`SELECT id, signal_key, topic FROM public.hmsh_escalations
|
|
1925
|
+
WHERE ${namespace ? 'namespace = $3 AND' : ''}
|
|
1926
|
+
metadata @> $1::jsonb
|
|
1927
|
+
AND ($2::text[] IS NULL OR role = ANY($2::text[]))
|
|
1928
|
+
AND status IN ('pending', 'cancelled')
|
|
1929
|
+
ORDER BY priority ASC, created_at ASC
|
|
1930
|
+
LIMIT 1`, namespace ? [filter, roles ?? null, namespace] : [filter, roles ?? null]);
|
|
1931
|
+
const row = result.rows[0];
|
|
1932
|
+
if (!row)
|
|
1933
|
+
return null;
|
|
1934
|
+
return { id: row.id, signalKey: row.signal_key, topic: row.topic };
|
|
1935
|
+
}
|
|
1936
|
+
async resolveEscalationByMetadata(params, wakeCommand) {
|
|
1847
1937
|
const { key, value, namespace, resolverPayload, roles, metadata } = params;
|
|
1848
1938
|
const filter = JSON.stringify({ [key]: value });
|
|
1849
1939
|
const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
|
|
@@ -1878,8 +1968,9 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1878
1968
|
await this.pgClient.query('ROLLBACK');
|
|
1879
1969
|
return { ok: false, reason: 'already-resolved' };
|
|
1880
1970
|
}
|
|
1971
|
+
const wakeEnqueued = await this.enqueueEscalationWake(wakeCommand, signal_key, id);
|
|
1881
1972
|
await this.pgClient.query('COMMIT');
|
|
1882
|
-
return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic };
|
|
1973
|
+
return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic, wakeEnqueued };
|
|
1883
1974
|
}
|
|
1884
1975
|
catch (e) {
|
|
1885
1976
|
await this.pgClient.query('ROLLBACK');
|
|
@@ -73,8 +73,13 @@ class TaskService {
|
|
|
73
73
|
expire,
|
|
74
74
|
};
|
|
75
75
|
//called standalone (no transaction) so the single CTE query can
|
|
76
|
-
//atomically detect and return pending signal data on collision
|
|
77
|
-
|
|
76
|
+
//atomically detect and return pending signal data on collision.
|
|
77
|
+
//the redelivery routing lets the store republish a consumed
|
|
78
|
+
//pending signal durably, in the same transaction that consumes it
|
|
79
|
+
const result = await this.store.setHookSignal(hook, undefined, {
|
|
80
|
+
aid: hookRule.to,
|
|
81
|
+
topic,
|
|
82
|
+
});
|
|
78
83
|
if (result.pendingData) {
|
|
79
84
|
this.logger.warn('task-signal-race-pending-consumed', {
|
|
80
85
|
topic,
|
|
@@ -105,6 +105,19 @@ export type ResolveEscalationResult = {
|
|
|
105
105
|
ok: false;
|
|
106
106
|
reason: 'not-found' | 'already-resolved' | 'already-cancelled' | 'already-expired';
|
|
107
107
|
};
|
|
108
|
+
/**
|
|
109
|
+
* A pre-built wake publish, executed INSIDE the resolve transaction so the
|
|
110
|
+
* awaiting workflow's wake commits atomically with `status='resolved'`. The
|
|
111
|
+
* SQL is a stream INSERT produced by the stream provider; `forSignalKey`
|
|
112
|
+
* pins the command to the row it was built for — the store executes it only
|
|
113
|
+
* when the locked row's `signal_key` matches (a mismatched row falls back
|
|
114
|
+
* to post-commit delivery).
|
|
115
|
+
*/
|
|
116
|
+
export interface EscalationWakeCommand {
|
|
117
|
+
forSignalKey: string;
|
|
118
|
+
sql: string;
|
|
119
|
+
params: unknown[];
|
|
120
|
+
}
|
|
108
121
|
export type ReleaseEscalationResult = {
|
|
109
122
|
ok: true;
|
|
110
123
|
entry: EscalationEntry;
|