@hotmeshio/hotmesh 0.25.4 → 0.25.6
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 +64 -0
- package/RESPONSE-0.25.6.md +77 -0
- package/build/package.json +1 -1
- package/build/services/activities/hook.js +22 -0
- package/build/services/escalations/client.d.ts +20 -4
- package/build/services/escalations/client.js +84 -10
- package/build/services/store/index.d.ts +10 -1
- package/build/services/store/providers/postgres/postgres.d.ts +43 -4
- package/build/services/store/providers/postgres/postgres.js +238 -84
- 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/services/task/index.js +7 -2
- package/build/types/hmsh_escalations.d.ts +12 -0
- package/package.json +1 -1
- package/wedge-capture-20260706.csv +19 -0
package/ISSUE.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
## EIGHTH ITEM — a claimed row's post-signal settle can fail, stranding pending state that contradicts the workflow (dashboard-visible)
|
|
2
|
+
|
|
3
|
+
Two order trails, same signature (hike-mono, 0.25.5/0.7.5):
|
|
4
|
+
- run A order `a0e68dba`: every station row resolved EXCEPT quality-control —
|
|
5
|
+
yet the QC stationWorker delivered (shipping minted 5s later, order
|
|
6
|
+
COMPLETED). The QC row sits `pending` + claimed forever.
|
|
7
|
+
- run B order `89942462`: identical, at post-print-qa (claimed 07:34:44,
|
|
8
|
+
gluing resolved 07:34:54, order COMPLETED 07:35:11; the qa row still
|
|
9
|
+
pending+claimed 20+ min later).
|
|
10
|
+
|
|
11
|
+
Trigger context (our side, fixed by us): two associates raced a shallow pond
|
|
12
|
+
and BOTH booked the same order in the same second (audit rows at 07:34:44) —
|
|
13
|
+
so a concurrent claim write was interleaved with the resolve.
|
|
14
|
+
|
|
15
|
+
The library-side gap: the resolve path delivered the SIGNAL (workflow
|
|
16
|
+
advanced, next station minted) but the row's own transition to `resolved`
|
|
17
|
+
was lost against the concurrent claim — no error surfaced anywhere. The
|
|
18
|
+
result is dashboard-visible corruption: a live, claimable, countdown-bearing
|
|
19
|
+
work item asserting QA is owed on a COMPLETED order (real associates would
|
|
20
|
+
work it). Whatever branch handled this (atomic signal_key resolve or the
|
|
21
|
+
legacy handle.signal → ltResolveEscalation), signal delivery and row settle
|
|
22
|
+
must be atomic — or the settle must retry until the row cannot lie.
|
|
23
|
+
|
|
24
|
+
Repro shape: claim a signal_key escalation from principal A, and within the
|
|
25
|
+
same second re-claim/book from principal B while principal C resolves by
|
|
26
|
+
metadata. Expect: signal delivered AND row resolved, always.
|
|
27
|
+
|
|
28
|
+
# RE-FILED — two reports lost when this file was replaced with your 0.25.6 reply (both found the same evening, before it)
|
|
29
|
+
|
|
30
|
+
## NINTH ITEM (re-filed seventh) — a timeout wake KILLS the collator of a collated wait set; parent hangs forever
|
|
31
|
+
|
|
32
|
+
`Promise.all(21 × condition(signalId, {timeout:'1320s'}))` in one parent
|
|
33
|
+
(hike-mono simulationDirector, 0.25.5). Signal-path delivery through the
|
|
34
|
+
collation worked (seat 18 resolved cleanly at 04:55:05, its collator_waiter
|
|
35
|
+
slot consumed). At 05:07:25 UTC the FIRST batch of SLA expirations landed
|
|
36
|
+
(vm0..vm4 rows flipped 'expired' in the same second) and the collator job
|
|
37
|
+
(`HfSVaQSgUaYu3XGsCCjyQWy$C`) went **status -1 in that same second**
|
|
38
|
+
(context={}, is_live=t). Every subsequent expiry wake then hit
|
|
39
|
+
`Inactive job …$C` at CollatorService.assertJobActive (collator/index.js:13)
|
|
40
|
+
via Hook.processTimeHookEvent (hook.js:650) and was dropped. End state,
|
|
41
|
+
stable 10+ min: 20 live collator_waiter registry rows, all rows 'expired',
|
|
42
|
+
zero pending engine_streams for the collator, parent wedged in Promise.all
|
|
43
|
+
forever with no surfaced error.
|
|
44
|
+
|
|
45
|
+
Repro: `await Promise.all([condition('k1',{timeout:'30s'}), condition('k2',{timeout:'30s'})])`,
|
|
46
|
+
no resolver — let both expire. Expect collator -1 on first expiry, parent wedged.
|
|
47
|
+
|
|
48
|
+
Our standing mitigation: each timeout-bearing wait moved into its own child
|
|
49
|
+
workflow (inline wait), parent collates ExecChild completions — works, but
|
|
50
|
+
collated waits + SLA should compose.
|
|
51
|
+
|
|
52
|
+
## TENTH ITEM (re-filed follow-up) — second of two near-simultaneous sibling wakes lost (no crash, no restart)
|
|
53
|
+
|
|
54
|
+
Clean floor, 10 orders: per order, TWO sibling printJobWorkers under one
|
|
55
|
+
dispatcher park signal_key waits; a machine resolves BOTH escalations
|
|
56
|
+
seconds apart. 3-for-3 wedged orders: LEFT worker woke and completed; RIGHT
|
|
57
|
+
worker (resolved moments later) never woke — job row untouched since
|
|
58
|
+
creation, 10+ min, stored-pending-signal path did not recover it. 14/14
|
|
59
|
+
wakes delivered for the 7 orders whose resolves were spread out. Process up
|
|
60
|
+
the whole time (distinct from the reclaim case). Plausibly fixed by commit
|
|
61
|
+
3's atomic wake INSERT ("no interleave window") — flagging so it gets a
|
|
62
|
+
sibling-burst regression test: resolve two waits of one parent within the
|
|
63
|
+
same tick, both must wake. Our mitigation: 0–4s jitter between sibling
|
|
64
|
+
resolves (also more realistic for print hardware).
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# hotmesh 0.25.6 — fixes for the wake-loss and reservation reports (branch: fix/atomic-cancel-wake, three commits)
|
|
2
|
+
|
|
3
|
+
Everything below is implemented and fail-first tested (each fix was watched failing
|
|
4
|
+
before its change); the branch is in final full-suite verification before merge.
|
|
5
|
+
|
|
6
|
+
## Commit 1 — cancellation wakes are durable
|
|
7
|
+
|
|
8
|
+
**Your second addendum (3 of 6 pills slept after the 400ms cancel wave).** Your
|
|
9
|
+
watchers settle waits via `cancel()`, and cancel's wake was a post-commit,
|
|
10
|
+
best-effort publish whose failure was silently discarded. Now the cancellation
|
|
11
|
+
wake commits inside the cancel transaction: a cancel that is durable always
|
|
12
|
+
resumes its `condition()` with `null`. A native six-pill burst test pins your
|
|
13
|
+
wave shape.
|
|
14
|
+
|
|
15
|
+
Also adds a permanent regression test for your converge topology
|
|
16
|
+
(`Promise.all([executeChild x4, sleep branch])`): the sleep's timehook mints and
|
|
17
|
+
the converge settles.
|
|
18
|
+
|
|
19
|
+
## Commit 2 — dead reservations reclaim; resolved waits disarm their timers
|
|
20
|
+
|
|
21
|
+
**Your fifth data point (reservations never reclaimed after process death) — the
|
|
22
|
+
highest-leverage fix.** The fallback poller's discovery query filtered
|
|
23
|
+
`reserved_at IS NULL`, so a dead holder's reservation was invisible forever.
|
|
24
|
+
It now surfaces stale reservations (threshold tracks the configured window;
|
|
25
|
+
heartbeating work never matches), through dedicated index-backed branches, and
|
|
26
|
+
the fixed function re-deploys to existing databases on boot. A crash or deploy
|
|
27
|
+
now costs ≤ ~90 seconds of redelivery latency instead of a permanent wedge —
|
|
28
|
+
and this most plausibly explains your third data point too (wake messages
|
|
29
|
+
claimed by churning per-principal clients that died were never reclaimed).
|
|
30
|
+
|
|
31
|
+
**Your sixth item (armed-timeout drizzle).** When the signal wins an SLA-gated
|
|
32
|
+
wait, the armed timeout is now disarmed with the settled wait — root and cycled
|
|
33
|
+
dimensions both covered. New timers are jid-stamped so job-scoped purges
|
|
34
|
+
(terminate) remove them as well. Timers armed before the upgrade drain as the
|
|
35
|
+
one-shot benign events you observed.
|
|
36
|
+
|
|
37
|
+
Also hardens the pending-signal handoff and cancel wake into single atomic
|
|
38
|
+
statements (safe on pooled connections, no interleave window; an in-window
|
|
39
|
+
wake refreshed by a newer signal delivers the newest payload).
|
|
40
|
+
|
|
41
|
+
## Commit 3 — resolve paths made single-statement; bulk resolve protects waiters
|
|
42
|
+
|
|
43
|
+
- `resolve()` and `resolveByMetadata()` now use the same single-statement
|
|
44
|
+
atomic idiom as cancel: row lock, status guard, and the wake INSERT commit
|
|
45
|
+
as one unit on any connection type.
|
|
46
|
+
- `resolveMany()` now excludes rows backing a live `condition()` waiter — bulk
|
|
47
|
+
resolution carries no wake, so those rows stay pending for a targeted
|
|
48
|
+
`resolve()`/`cancel()` instead of silently stranding the workflow.
|
|
49
|
+
- Secured-mode worker responses stamp `jid`, so job-scoped purges address them.
|
|
50
|
+
|
|
51
|
+
## Status of the remaining items
|
|
52
|
+
|
|
53
|
+
- **Lost `sleep()` timer (your timer forensics):** still not reproduced — your
|
|
54
|
+
exact topology mints and converges cleanly under test, and every registration
|
|
55
|
+
path checks out atomic. The reclaim fix (commit 2) may retire this too if the
|
|
56
|
+
collator's driving message was claimed by a dying client. The decisive
|
|
57
|
+
forensic if it recurs: the wedged director's collator job
|
|
58
|
+
(`SELECT * FROM durable.jobs WHERE key LIKE '%$C'`) plus that collator jid's
|
|
59
|
+
`engine_streams` rows, no filters — it records exactly which item-cycle
|
|
60
|
+
stopped.
|
|
61
|
+
- **`createClient` construction churn (your fourth data point):** your
|
|
62
|
+
per-principal memoization is the right mitigation; an upstream answer
|
|
63
|
+
(internal per-auth memoization or an explicit `client.close()`) is under
|
|
64
|
+
design as its own change.
|
|
65
|
+
- **Crash windows on interrupt's parent notification** (found by our internal
|
|
66
|
+
audit, same family as your wake fixes): being engineered in a dedicated
|
|
67
|
+
follow-up PR — the straightforward implementation measurably slowed interrupt
|
|
68
|
+
dispatch, and interrupts race against live completions by design, so it is
|
|
69
|
+
getting the unhurried version.
|
|
70
|
+
|
|
71
|
+
## After upgrading
|
|
72
|
+
|
|
73
|
+
- Restarting workers drains any orphaned-reservation backlog automatically.
|
|
74
|
+
- The settlement guard can be retired again — cancel and resolve wakes are both
|
|
75
|
+
transactional now, and dead reservations self-heal.
|
|
76
|
+
- Watch for `hook-signal-pending-redelivered` (in-window wake handled) and the
|
|
77
|
+
reclaim redeliveries after any crash: both are the system working as intended.
|
package/build/package.json
CHANGED
|
@@ -575,6 +575,9 @@ class Hook extends activity_1.Activity {
|
|
|
575
575
|
this.context.metadata.jid = jobId;
|
|
576
576
|
this.context.metadata.gid = gId;
|
|
577
577
|
this.context.metadata.dad = dad;
|
|
578
|
+
//captured for the timeout disarm below: processEvent's state
|
|
579
|
+
//hydration replaces context.metadata before the disarm runs
|
|
580
|
+
const waiterJid = jobId;
|
|
578
581
|
// Inline retry for FORBIDDEN: Leg2 arrived in the window between
|
|
579
582
|
// setHookSignal (standalone) and Leg1 transaction.exec(). The 100B
|
|
580
583
|
// ledger digit is not yet visible. Leg1 needs only milliseconds to
|
|
@@ -589,6 +592,25 @@ class Hook extends activity_1.Activity {
|
|
|
589
592
|
await this.processEvent(status, code, 'hook');
|
|
590
593
|
if (code === 200) {
|
|
591
594
|
await taskService.deleteWebHookSignal(this.config.hook.topic, data);
|
|
595
|
+
//signal won the race: disarm the SLA timeout leg (the mirror
|
|
596
|
+
//of the timeout-won path, which deletes the signal). The
|
|
597
|
+
//armed timehook may not survive to fire against the settled
|
|
598
|
+
//wait. Addressed by the SIGNAL's jid + dimensional address —
|
|
599
|
+
//processEvent has since rehydrated this.context.
|
|
600
|
+
if (this.config.sleep) {
|
|
601
|
+
try {
|
|
602
|
+
await this.store.expireTimeHook?.(waiterJid, this.metadata.aid);
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
//best-effort: a surviving timer fires once and is
|
|
606
|
+
//consumed as an inactive-job event
|
|
607
|
+
this.logger.warn('hook-timeout-disarm-error', {
|
|
608
|
+
jid: waiterJid,
|
|
609
|
+
aid: this.metadata.aid,
|
|
610
|
+
error,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
592
614
|
//clean up orphan pending on the sibling signal topic
|
|
593
615
|
// wfs.wait delivered → remove wfs.signal pending
|
|
594
616
|
// wfs.signal delivered → remove wfs.wait pending
|
|
@@ -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 as a webhook message so the store can commit it
|
|
77
|
+
* INSIDE the resolve/cancel transaction — the wake becomes durable
|
|
78
|
+
* with the status change, closing the crash window between 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`,
|
|
@@ -122,10 +132,10 @@ export declare class EscalationClientService {
|
|
|
122
132
|
/**
|
|
123
133
|
* Cancels a pending escalation and delivers a cancellation signal to the
|
|
124
134
|
* waiting workflow so that `condition()` returns `null`. Terminal rows
|
|
125
|
-
* return `already-terminal`.
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
135
|
+
* return `already-terminal`. The cancellation wake commits inside the
|
|
136
|
+
* cancel transaction (same durability contract as `resolve()`); when no
|
|
137
|
+
* hook rule is deployed for any candidate topic, delivery falls back to
|
|
138
|
+
* a best-effort post-commit publish.
|
|
129
139
|
*/
|
|
130
140
|
cancel(id: string, namespace?: string): Promise<CancelEscalationResult>;
|
|
131
141
|
/**
|
|
@@ -197,6 +207,12 @@ export declare class EscalationClientService {
|
|
|
197
207
|
* (still-pending) row's GIN-indexed `metadata` in the single atomic UPDATE.
|
|
198
208
|
* See {@link resolve}.
|
|
199
209
|
*/
|
|
210
|
+
/**
|
|
211
|
+
* Bulk-resolves standalone escalations (rows with `signal_key = null`).
|
|
212
|
+
* Rows backing a live `condition()` waiter are excluded — they stay
|
|
213
|
+
* pending so a targeted `resolve()`/`cancel()` can deliver their wake;
|
|
214
|
+
* bulk resolution carries no wake and would strand the workflow.
|
|
215
|
+
*/
|
|
200
216
|
resolveMany(params: ResolveManyParams): Promise<EscalationEntry[]>;
|
|
201
217
|
/**
|
|
202
218
|
* Returns dashboard-ready escalation counts. `period` controls the window
|
|
@@ -5,6 +5,7 @@ const enums_1 = require("../../modules/enums");
|
|
|
5
5
|
const utils_1 = require("../../modules/utils");
|
|
6
6
|
const hotmesh_1 = require("../hotmesh");
|
|
7
7
|
const types_1 = require("../../types");
|
|
8
|
+
const stream_1 = require("../../types/stream");
|
|
8
9
|
const factory_1 = require("../durable/schemas/factory");
|
|
9
10
|
/**
|
|
10
11
|
* Standalone client for the `public.hmsh_escalations` signal-pause surface.
|
|
@@ -145,6 +146,43 @@ class EscalationClientService {
|
|
|
145
146
|
catch { }
|
|
146
147
|
return delivered;
|
|
147
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Builds the wake as a webhook message so the store can commit it
|
|
151
|
+
* INSIDE the resolve/cancel transaction — the wake becomes durable
|
|
152
|
+
* with the status change, closing the crash window between commit and
|
|
153
|
+
* post-commit signal delivery. Mirrors `_deliverEscalationSignal`'s
|
|
154
|
+
* topic fallback chain; returns null when no hook rule is deployed
|
|
155
|
+
* for any candidate topic (the caller then keeps post-commit
|
|
156
|
+
* delivery as the only path, preserving prior behavior).
|
|
157
|
+
*/
|
|
158
|
+
async _buildWakeCommand(ns, topic, signalKey, data) {
|
|
159
|
+
const hm = await this._engine(null, ns);
|
|
160
|
+
const engine = hm.engine;
|
|
161
|
+
const candidates = [topic, `${ns}.wfs.signal`, `${ns}.wfs.wait`].filter(Boolean);
|
|
162
|
+
for (const candidate of candidates) {
|
|
163
|
+
try {
|
|
164
|
+
const hookRule = await engine.taskService.getHookRule(candidate);
|
|
165
|
+
if (!hookRule)
|
|
166
|
+
continue;
|
|
167
|
+
const [aid] = await engine.getSchema(`.${hookRule.to}`);
|
|
168
|
+
const streamData = {
|
|
169
|
+
type: stream_1.StreamDataType.WEBHOOK,
|
|
170
|
+
status: types_1.StreamStatus.SUCCESS,
|
|
171
|
+
code: 200,
|
|
172
|
+
metadata: { guid: (0, utils_1.guid)(), aid, topic: candidate },
|
|
173
|
+
data: { id: signalKey, data },
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
forSignalKey: signalKey,
|
|
177
|
+
message: JSON.stringify(streamData),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
/* candidate not deployed — try the next topic */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
148
186
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
149
187
|
/**
|
|
150
188
|
* Returns all escalation rows matching the given filters. Each row includes
|
|
@@ -244,18 +282,27 @@ class EscalationClientService {
|
|
|
244
282
|
/**
|
|
245
283
|
* Cancels a pending escalation and delivers a cancellation signal to the
|
|
246
284
|
* waiting workflow so that `condition()` returns `null`. Terminal rows
|
|
247
|
-
* return `already-terminal`.
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
285
|
+
* return `already-terminal`. The cancellation wake commits inside the
|
|
286
|
+
* cancel transaction (same durability contract as `resolve()`); when no
|
|
287
|
+
* hook rule is deployed for any candidate topic, delivery falls back to
|
|
288
|
+
* a best-effort post-commit publish.
|
|
251
289
|
*/
|
|
252
290
|
async cancel(id, namespace) {
|
|
253
291
|
const ns = namespace ?? factory_1.APP_ID;
|
|
254
292
|
const hm = await this._engine(null, ns);
|
|
255
|
-
const
|
|
293
|
+
const store = hm.engine.store;
|
|
294
|
+
//pre-build the cancellation wake so it commits INSIDE the cancel
|
|
295
|
+
//transaction — condition() resumes with null even if the process
|
|
296
|
+
//dies the instant the cancel commits
|
|
297
|
+
let wakeCommand = null;
|
|
298
|
+
const preview = await store.getEscalation(id, namespace);
|
|
299
|
+
if (preview?.signal_key) {
|
|
300
|
+
wakeCommand = await this._buildWakeCommand(ns, preview.topic, preview.signal_key, { __escalation_cancelled: true });
|
|
301
|
+
}
|
|
302
|
+
const result = await store.cancelEscalation(id, namespace, wakeCommand ?? undefined);
|
|
256
303
|
if (result.ok === true) {
|
|
257
304
|
this._emit('cancelled', result.entry);
|
|
258
|
-
if (result.entry.signal_key) {
|
|
305
|
+
if (result.entry.signal_key && !result.wakeEnqueued) {
|
|
259
306
|
await this._deliverEscalationSignal(ns, result.entry.topic, {
|
|
260
307
|
id: result.entry.signal_key,
|
|
261
308
|
data: { __escalation_cancelled: true },
|
|
@@ -292,10 +339,19 @@ class EscalationClientService {
|
|
|
292
339
|
const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
|
|
293
340
|
const hm = await this._engine(null, ns);
|
|
294
341
|
const store = hm.engine.store;
|
|
295
|
-
|
|
342
|
+
//pre-build the wake so it commits INSIDE the resolve transaction; the
|
|
343
|
+
//row's signal routing (signal_key, topic) is immutable after creation
|
|
344
|
+
let wakeCommand = null;
|
|
345
|
+
const preview = await store.getEscalation(params.id, params.namespace);
|
|
346
|
+
if (preview?.signal_key) {
|
|
347
|
+
wakeCommand = await this._buildWakeCommand(ns, preview.topic, preview.signal_key, params.resolverPayload ?? {});
|
|
348
|
+
}
|
|
349
|
+
const dbResult = await store.resolveEscalation({ id: params.id, resolverPayload: params.resolverPayload, metadata: params.metadata }, wakeCommand ?? undefined);
|
|
296
350
|
if (!dbResult.ok)
|
|
297
351
|
return dbResult;
|
|
298
|
-
if (dbResult.signalKey) {
|
|
352
|
+
if (dbResult.signalKey && !dbResult.wakeEnqueued) {
|
|
353
|
+
//the wake was not part of the commit (no hook rule found, or the
|
|
354
|
+
//enqueue was rolled back to its savepoint) — deliver post-commit
|
|
299
355
|
await this._deliverEscalationSignal(ns, dbResult.topic, {
|
|
300
356
|
id: dbResult.signalKey,
|
|
301
357
|
data: params.resolverPayload ?? {},
|
|
@@ -316,10 +372,22 @@ class EscalationClientService {
|
|
|
316
372
|
const ns = (params.namespace ?? namespace) ?? factory_1.APP_ID;
|
|
317
373
|
const hm = await this._engine(null, ns);
|
|
318
374
|
const store = hm.engine.store;
|
|
319
|
-
|
|
375
|
+
//peek the row the resolve is expected to lock and pre-build its wake;
|
|
376
|
+
//the forSignalKey guard covers the race where a different row wins
|
|
377
|
+
let wakeCommand = null;
|
|
378
|
+
const preview = await store.peekEscalationByMetadata({
|
|
379
|
+
key: params.key,
|
|
380
|
+
value: params.value,
|
|
381
|
+
roles: params.roles,
|
|
382
|
+
namespace: params.namespace,
|
|
383
|
+
});
|
|
384
|
+
if (preview?.signalKey) {
|
|
385
|
+
wakeCommand = await this._buildWakeCommand(ns, preview.topic, preview.signalKey, params.resolverPayload ?? {});
|
|
386
|
+
}
|
|
387
|
+
const dbResult = await store.resolveEscalationByMetadata({ key: params.key, value: params.value, resolverPayload: params.resolverPayload, roles: params.roles, metadata: params.metadata }, wakeCommand ?? undefined);
|
|
320
388
|
if (!dbResult.ok)
|
|
321
389
|
return dbResult;
|
|
322
|
-
if (dbResult.signalKey) {
|
|
390
|
+
if (dbResult.signalKey && !dbResult.wakeEnqueued) {
|
|
323
391
|
await this._deliverEscalationSignal(ns, dbResult.topic, {
|
|
324
392
|
id: dbResult.signalKey,
|
|
325
393
|
data: params.resolverPayload ?? {},
|
|
@@ -381,6 +449,12 @@ class EscalationClientService {
|
|
|
381
449
|
* (still-pending) row's GIN-indexed `metadata` in the single atomic UPDATE.
|
|
382
450
|
* See {@link resolve}.
|
|
383
451
|
*/
|
|
452
|
+
/**
|
|
453
|
+
* Bulk-resolves standalone escalations (rows with `signal_key = null`).
|
|
454
|
+
* Rows backing a live `condition()` waiter are excluded — they stay
|
|
455
|
+
* pending so a targeted `resolve()`/`cancel()` can deliver their wake;
|
|
456
|
+
* bulk resolution carries no wake and would strand the workflow.
|
|
457
|
+
*/
|
|
384
458
|
async resolveMany(params) {
|
|
385
459
|
const hm = await this._engine(null, params.namespace);
|
|
386
460
|
const entries = await hm.engine.store.resolveManyEscalations(params);
|
|
@@ -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
|
}>;
|
|
@@ -159,6 +162,18 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
|
|
|
159
162
|
* picks it up — no intermediate table, no polling, fully transactional.
|
|
160
163
|
*/
|
|
161
164
|
registerTimeHook(jobId: string, gId: string, activityId: string, _type: WorkListTaskType, deletionTime: number, dad: string, transaction?: ProviderTransaction): Promise<void>;
|
|
165
|
+
/**
|
|
166
|
+
* Disarms a scheduled timehook (soft delete) for one activity of a
|
|
167
|
+
* job — the mirror of registerTimeHook, called when the SIGNAL wins
|
|
168
|
+
* an SLA-gated wait so the armed timeout cannot fire against the
|
|
169
|
+
* settled workflow. Scoped by the jid index, then narrowed to the
|
|
170
|
+
* activity via the message metadata. Dimensional addressing is
|
|
171
|
+
* deliberately NOT matched: a job's waits on one activity are
|
|
172
|
+
* sequential (cycle N settles before cycle N+1 arms), so at most one
|
|
173
|
+
* timer per (jid, aid) is armed at a time, and the signal composite's
|
|
174
|
+
* address can differ from the stored one at cycle offsets.
|
|
175
|
+
*/
|
|
176
|
+
expireTimeHook(jobId: string, activityId: string): Promise<number>;
|
|
162
177
|
getNextTask(_listKey?: string): Promise<[
|
|
163
178
|
listKey: string,
|
|
164
179
|
jobId: string,
|
|
@@ -270,15 +285,39 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
|
|
|
270
285
|
claimEscalation(params: import('../../../../types/hmsh_escalations').ClaimEscalationParams): Promise<import('../../../../types/hmsh_escalations').ClaimEscalationResult>;
|
|
271
286
|
claimEscalationByMetadata(params: import('../../../../types/hmsh_escalations').ClaimByMetadataParams): Promise<import('../../../../types/hmsh_escalations').ClaimByMetadataResult>;
|
|
272
287
|
releaseEscalation(params: import('../../../../types/hmsh_escalations').ReleaseEscalationParams): Promise<import('../../../../types/hmsh_escalations').ReleaseEscalationResult>;
|
|
273
|
-
|
|
288
|
+
/**
|
|
289
|
+
* Composes the wake as a data-modifying CTE appended to a settle
|
|
290
|
+
* statement (resolve/cancel). The INSERT fires iff the settle CTE
|
|
291
|
+
* (`fromCTE`) produced a row whose signal_key matches the wake — one
|
|
292
|
+
* atomic statement, safe on any connection type (Client or Pool),
|
|
293
|
+
* with no transaction window for unrelated queries to interleave
|
|
294
|
+
* into. Appends the wake params to `params` in place.
|
|
295
|
+
*/
|
|
296
|
+
private composeEscalationWakeCTE;
|
|
297
|
+
resolveEscalation(params: import('../../../../types/hmsh_escalations').ResolveEscalationParams, wakeCommand?: import('../../../../types/hmsh_escalations').EscalationWakeCommand): Promise<import('../../../../types/hmsh_escalations').ResolveEscalationResult & {
|
|
274
298
|
signalKey?: string | null;
|
|
275
299
|
topic?: string | null;
|
|
300
|
+
wakeEnqueued?: boolean;
|
|
276
301
|
}>;
|
|
277
|
-
|
|
302
|
+
/**
|
|
303
|
+
* Non-locking preview of the row `resolveEscalationByMetadata` would
|
|
304
|
+
* select — used to pre-build the wake command before the resolve
|
|
305
|
+
* transaction opens. The `forSignalKey` guard on the wake command
|
|
306
|
+
* handles the race where a different row wins the lock.
|
|
307
|
+
*/
|
|
308
|
+
peekEscalationByMetadata(params: import('../../../../types/hmsh_escalations').ResolveByMetadataParams): Promise<{
|
|
309
|
+
id: string;
|
|
310
|
+
signalKey: string | null;
|
|
311
|
+
topic: string | null;
|
|
312
|
+
} | null>;
|
|
313
|
+
resolveEscalationByMetadata(params: import('../../../../types/hmsh_escalations').ResolveByMetadataParams, wakeCommand?: import('../../../../types/hmsh_escalations').EscalationWakeCommand): Promise<import('../../../../types/hmsh_escalations').ResolveEscalationResult & {
|
|
278
314
|
signalKey?: string | null;
|
|
279
315
|
topic?: string | null;
|
|
316
|
+
wakeEnqueued?: boolean;
|
|
317
|
+
}>;
|
|
318
|
+
cancelEscalation(id: string, namespace?: string, wakeCommand?: import('../../../../types/hmsh_escalations').EscalationWakeCommand): Promise<import('../../../../types/hmsh_escalations').CancelEscalationResult & {
|
|
319
|
+
wakeEnqueued?: boolean;
|
|
280
320
|
}>;
|
|
281
|
-
cancelEscalation(id: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').CancelEscalationResult>;
|
|
282
321
|
escalateEscalationToRole(params: import('../../../../types/hmsh_escalations').EscalateToRoleParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
|
|
283
322
|
updateEscalation(params: import('../../../../types/hmsh_escalations').UpdateEscalationParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
|
|
284
323
|
appendEscalationMilestones(params: import('../../../../types/hmsh_escalations').AppendMilestonesParams): 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,54 @@ 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 statement:
|
|
818
|
+
//the pending wake becomes a durable engine stream message in
|
|
819
|
+
//the same atomic unit that destroys the marker, so the wake
|
|
820
|
+
//survives a crash at any instant. Single-statement composition
|
|
821
|
+
//is safe on any connection type (Client or Pool) and cannot
|
|
822
|
+
//interleave with unrelated queries. The consume is guarded on
|
|
823
|
+
//the value still being a pending marker, and the redelivered
|
|
824
|
+
//payload is read from the row itself — a marker refreshed by a
|
|
825
|
+
//newer signal between capture and consume delivers the NEWER
|
|
826
|
+
//payload rather than a stale copy.
|
|
827
|
+
const schemaName = this.kvsql().safeName(this.appId);
|
|
828
|
+
const res = await this.pgClient.query(`WITH prev AS (
|
|
829
|
+
SELECT value FROM ${tableName}
|
|
830
|
+
WHERE key = $1 FOR UPDATE
|
|
831
|
+
),
|
|
832
|
+
consumed AS (
|
|
833
|
+
UPDATE ${tableName} t
|
|
834
|
+
SET value = $2, expiry = NOW() + INTERVAL '${delay} seconds'
|
|
835
|
+
FROM prev
|
|
836
|
+
WHERE t.key = $1 AND prev.value LIKE '$pending::%'
|
|
837
|
+
RETURNING prev.value AS pending_value
|
|
838
|
+
)
|
|
839
|
+
INSERT INTO ${schemaName}.engine_streams
|
|
840
|
+
(stream_name, message, priority)
|
|
841
|
+
SELECT $3,
|
|
842
|
+
json_build_object(
|
|
843
|
+
'type', 'webhook',
|
|
844
|
+
'status', 'success',
|
|
845
|
+
'code', 200,
|
|
846
|
+
'metadata', json_build_object(
|
|
847
|
+
'guid', $4::text, 'aid', $5::text, 'topic', $6::text),
|
|
848
|
+
'data', substr(consumed.pending_value, 11)::json
|
|
849
|
+
)::text,
|
|
850
|
+
5
|
|
851
|
+
FROM consumed
|
|
852
|
+
RETURNING id`, [storedKey, jobId, this.appId, (0, utils_1.guid)(), redelivery.aid, redelivery.topic]);
|
|
853
|
+
if ((res.rowCount ?? 0) > 0) {
|
|
854
|
+
this.logger.warn('hook-signal-pending-redelivered', {
|
|
855
|
+
key: signalKey,
|
|
856
|
+
topic: redelivery.topic,
|
|
857
|
+
});
|
|
858
|
+
return { success: true };
|
|
859
|
+
}
|
|
860
|
+
//the marker vanished between capture and consume (a concurrent
|
|
861
|
+
//duplicate Leg1 consumed it) — idempotent no-op
|
|
862
|
+
return { success: false };
|
|
863
|
+
}
|
|
816
864
|
if (!wasInsert) {
|
|
817
865
|
//step 2: row existed — overwrite with hook value
|
|
818
866
|
await this.pgClient.query(`UPDATE ${tableName}
|
|
@@ -983,10 +1031,12 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
983
1031
|
metadata: { guid: (0, utils_1.guid)(), jid: jobId, gid: gId, dad: msgDad, aid },
|
|
984
1032
|
data: { timestamp: Date.now() },
|
|
985
1033
|
});
|
|
1034
|
+
//jid column stamped so job-scoped expiry (interrupt purge, timeout
|
|
1035
|
+
//disarm) can address armed timers through the partial jid index
|
|
986
1036
|
const sql = `INSERT INTO ${schemaName}.engine_streams
|
|
987
|
-
(stream_name, message, priority, visible_at)
|
|
988
|
-
VALUES ($1, $2, 5, NOW() + INTERVAL '${Math.max(delayMs, 0)} milliseconds')`;
|
|
989
|
-
const params = [this.appId, message];
|
|
1037
|
+
(stream_name, jid, message, priority, visible_at)
|
|
1038
|
+
VALUES ($1, $2, $3, 5, NOW() + INTERVAL '${Math.max(delayMs, 0)} milliseconds')`;
|
|
1039
|
+
const params = [this.appId, jobId, message];
|
|
990
1040
|
if (transaction && typeof transaction.addCommand === 'function') {
|
|
991
1041
|
transaction.addCommand(sql, params);
|
|
992
1042
|
}
|
|
@@ -994,6 +1044,26 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
994
1044
|
await this.pgClient.query(sql, params);
|
|
995
1045
|
}
|
|
996
1046
|
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Disarms a scheduled timehook (soft delete) for one activity of a
|
|
1049
|
+
* job — the mirror of registerTimeHook, called when the SIGNAL wins
|
|
1050
|
+
* an SLA-gated wait so the armed timeout cannot fire against the
|
|
1051
|
+
* settled workflow. Scoped by the jid index, then narrowed to the
|
|
1052
|
+
* activity via the message metadata. Dimensional addressing is
|
|
1053
|
+
* deliberately NOT matched: a job's waits on one activity are
|
|
1054
|
+
* sequential (cycle N settles before cycle N+1 arms), so at most one
|
|
1055
|
+
* timer per (jid, aid) is armed at a time, and the signal composite's
|
|
1056
|
+
* address can differ from the stored one at cycle offsets.
|
|
1057
|
+
*/
|
|
1058
|
+
async expireTimeHook(jobId, activityId) {
|
|
1059
|
+
const schemaName = this.kvsql().safeName(this.appId);
|
|
1060
|
+
const res = await this.pgClient.query(`UPDATE ${schemaName}.engine_streams
|
|
1061
|
+
SET expired_at = NOW()
|
|
1062
|
+
WHERE jid = $1 AND expired_at IS NULL AND visible_at > NOW()
|
|
1063
|
+
AND (message::jsonb ->> 'type') = 'timehook'
|
|
1064
|
+
AND (message::jsonb -> 'metadata' ->> 'aid') = $2`, [jobId, activityId]);
|
|
1065
|
+
return res.rowCount ?? 0;
|
|
1066
|
+
}
|
|
997
1067
|
async getNextTask(_listKey) {
|
|
998
1068
|
//no-op: time hooks are now engine_streams messages with future visible_at.
|
|
999
1069
|
//the engine's normal dequeue handles them when they become visible.
|
|
@@ -1792,101 +1862,183 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1792
1862
|
return { ok: false, reason: 'wrong-assignee' };
|
|
1793
1863
|
return { ok: true, entry: row.entry_json };
|
|
1794
1864
|
}
|
|
1795
|
-
|
|
1865
|
+
/**
|
|
1866
|
+
* Composes the wake as a data-modifying CTE appended to a settle
|
|
1867
|
+
* statement (resolve/cancel). The INSERT fires iff the settle CTE
|
|
1868
|
+
* (`fromCTE`) produced a row whose signal_key matches the wake — one
|
|
1869
|
+
* atomic statement, safe on any connection type (Client or Pool),
|
|
1870
|
+
* with no transaction window for unrelated queries to interleave
|
|
1871
|
+
* into. Appends the wake params to `params` in place.
|
|
1872
|
+
*/
|
|
1873
|
+
composeEscalationWakeCTE(wakeCommand, params, fromCTE) {
|
|
1874
|
+
if (!wakeCommand) {
|
|
1875
|
+
return { wakeCTE: '', wakeCount: '0::int AS wake_count' };
|
|
1876
|
+
}
|
|
1877
|
+
const schemaName = this.kvsql().safeName(this.appId);
|
|
1878
|
+
const base = params.length;
|
|
1879
|
+
params.push(this.appId, wakeCommand.message, wakeCommand.forSignalKey);
|
|
1880
|
+
return {
|
|
1881
|
+
wakeCTE: `,
|
|
1882
|
+
wake AS (
|
|
1883
|
+
INSERT INTO ${schemaName}.engine_streams (stream_name, message, priority)
|
|
1884
|
+
SELECT $${base + 1}, $${base + 2}, 5 FROM ${fromCTE}
|
|
1885
|
+
WHERE ${fromCTE}.signal_key = $${base + 3}
|
|
1886
|
+
RETURNING id
|
|
1887
|
+
)`,
|
|
1888
|
+
wakeCount: '(SELECT COUNT(*) FROM wake)::int AS wake_count',
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
async resolveEscalation(params, wakeCommand) {
|
|
1796
1892
|
const { id, namespace, resolverPayload, metadata } = params;
|
|
1797
1893
|
const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
|
|
1798
|
-
// metaJson is bound at a fixed index; the CASE no-ops when null so resolution
|
|
1799
|
-
// can merge into the GIN-indexed metadata without disturbing the param layout.
|
|
1800
1894
|
const metaJson = metadata ? JSON.stringify(metadata) : null;
|
|
1801
|
-
//
|
|
1802
|
-
//
|
|
1803
|
-
//
|
|
1804
|
-
//
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1895
|
+
//single statement: FOR UPDATE lock, TOCTOU-guarded UPDATE, and the
|
|
1896
|
+
//wake INSERT are one atomic unit. The wake commits WITH the
|
|
1897
|
+
//resolve — a crash after the statement leaves both the resolved row
|
|
1898
|
+
//and its wake message durable; before, neither. The engine_streams
|
|
1899
|
+
//INSERT trigger emits the delivery NOTIFY on commit. Safe on any
|
|
1900
|
+
//connection type; no window for unrelated queries to interleave.
|
|
1901
|
+
const sqlParams = namespace
|
|
1902
|
+
? [id, payloadJson, metaJson, namespace]
|
|
1903
|
+
: [id, payloadJson, metaJson];
|
|
1904
|
+
const { wakeCTE, wakeCount } = this.composeEscalationWakeCTE(wakeCommand, sqlParams, 'resolved');
|
|
1905
|
+
const result = await this.pgClient.query(`
|
|
1906
|
+
WITH target AS MATERIALIZED (
|
|
1907
|
+
SELECT id, signal_key, topic, status FROM public.hmsh_escalations
|
|
1908
|
+
WHERE id = $1 ${namespace ? 'AND namespace = $4' : ''}
|
|
1909
|
+
LIMIT 1 FOR UPDATE
|
|
1910
|
+
),
|
|
1911
|
+
resolved AS (
|
|
1912
|
+
UPDATE public.hmsh_escalations e
|
|
1913
|
+
SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
|
|
1914
|
+
metadata = CASE WHEN $3::jsonb IS NOT NULL
|
|
1915
|
+
THEN COALESCE(e.metadata, '{}'::jsonb) || $3::jsonb
|
|
1916
|
+
ELSE e.metadata END,
|
|
1917
|
+
updated_at = NOW()
|
|
1918
|
+
FROM target
|
|
1919
|
+
WHERE e.id = target.id AND target.status = 'pending'
|
|
1920
|
+
RETURNING e.*
|
|
1921
|
+
)${wakeCTE}
|
|
1922
|
+
SELECT t.id, t.status AS prior_status, t.signal_key, t.topic,
|
|
1923
|
+
CASE
|
|
1924
|
+
WHEN r.id IS NOT NULL THEN 'resolved'
|
|
1925
|
+
WHEN t.id IS NULL THEN 'not-found'
|
|
1926
|
+
ELSE 'blocked'
|
|
1927
|
+
END AS outcome,
|
|
1928
|
+
row_to_json(r.*) AS entry_json,
|
|
1929
|
+
${wakeCount}
|
|
1930
|
+
FROM (SELECT * FROM target) t
|
|
1931
|
+
FULL OUTER JOIN (SELECT * FROM resolved) r ON r.id = t.id
|
|
1932
|
+
`, sqlParams);
|
|
1933
|
+
const row = result.rows[0];
|
|
1934
|
+
if (!row || row.outcome === 'not-found')
|
|
1935
|
+
return { ok: false, reason: 'not-found' };
|
|
1936
|
+
if (row.outcome === 'blocked') {
|
|
1937
|
+
if (row.prior_status === 'cancelled')
|
|
1816
1938
|
return { ok: false, reason: 'already-cancelled' };
|
|
1817
|
-
|
|
1818
|
-
if (status === 'expired') {
|
|
1939
|
+
if (row.prior_status === 'expired') {
|
|
1819
1940
|
// The wait's SLA timer fired first and the workflow resumed with
|
|
1820
1941
|
// false — the resolver payload has nowhere to go. Name it, so the
|
|
1821
1942
|
// operator learns the deadline passed rather than believing the
|
|
1822
1943
|
// resolution landed.
|
|
1823
|
-
await this.pgClient.query('ROLLBACK');
|
|
1824
1944
|
return { ok: false, reason: 'already-expired' };
|
|
1825
1945
|
}
|
|
1826
|
-
|
|
1827
|
-
SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
|
|
1828
|
-
metadata = CASE WHEN $3::jsonb IS NOT NULL
|
|
1829
|
-
THEN COALESCE(metadata, '{}'::jsonb) || $3::jsonb
|
|
1830
|
-
ELSE metadata END,
|
|
1831
|
-
updated_at = NOW()
|
|
1832
|
-
WHERE id = $1 ${namespace ? 'AND namespace = $4' : ''} AND status = 'pending'
|
|
1833
|
-
RETURNING *`, namespace ? [id, payloadJson, metaJson, namespace] : [id, payloadJson, metaJson]);
|
|
1834
|
-
if (!updateResult.rows[0]) {
|
|
1835
|
-
await this.pgClient.query('ROLLBACK');
|
|
1836
|
-
return { ok: false, reason: 'already-resolved' };
|
|
1837
|
-
}
|
|
1838
|
-
await this.pgClient.query('COMMIT');
|
|
1839
|
-
return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic };
|
|
1840
|
-
}
|
|
1841
|
-
catch (e) {
|
|
1842
|
-
await this.pgClient.query('ROLLBACK');
|
|
1843
|
-
throw e;
|
|
1946
|
+
return { ok: false, reason: 'already-resolved' };
|
|
1844
1947
|
}
|
|
1948
|
+
return {
|
|
1949
|
+
ok: true,
|
|
1950
|
+
entry: row.entry_json,
|
|
1951
|
+
signalKey: row.signal_key,
|
|
1952
|
+
topic: row.topic,
|
|
1953
|
+
wakeEnqueued: row.wake_count > 0,
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Non-locking preview of the row `resolveEscalationByMetadata` would
|
|
1958
|
+
* select — used to pre-build the wake command before the resolve
|
|
1959
|
+
* transaction opens. The `forSignalKey` guard on the wake command
|
|
1960
|
+
* handles the race where a different row wins the lock.
|
|
1961
|
+
*/
|
|
1962
|
+
async peekEscalationByMetadata(params) {
|
|
1963
|
+
const { key, value, namespace, roles } = params;
|
|
1964
|
+
const filter = JSON.stringify({ [key]: value });
|
|
1965
|
+
const result = await this.pgClient.query(`SELECT id, signal_key, topic FROM public.hmsh_escalations
|
|
1966
|
+
WHERE ${namespace ? 'namespace = $3 AND' : ''}
|
|
1967
|
+
metadata @> $1::jsonb
|
|
1968
|
+
AND ($2::text[] IS NULL OR role = ANY($2::text[]))
|
|
1969
|
+
AND status IN ('pending', 'cancelled')
|
|
1970
|
+
ORDER BY priority ASC, created_at ASC
|
|
1971
|
+
LIMIT 1`, namespace ? [filter, roles ?? null, namespace] : [filter, roles ?? null]);
|
|
1972
|
+
const row = result.rows[0];
|
|
1973
|
+
if (!row)
|
|
1974
|
+
return null;
|
|
1975
|
+
return { id: row.id, signalKey: row.signal_key, topic: row.topic };
|
|
1845
1976
|
}
|
|
1846
|
-
async resolveEscalationByMetadata(params) {
|
|
1977
|
+
async resolveEscalationByMetadata(params, wakeCommand) {
|
|
1847
1978
|
const { key, value, namespace, resolverPayload, roles, metadata } = params;
|
|
1848
1979
|
const filter = JSON.stringify({ [key]: value });
|
|
1849
1980
|
const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
|
|
1850
1981
|
const metaJson = metadata ? JSON.stringify(metadata) : null;
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1982
|
+
//single statement — see resolveEscalation for the atomicity contract
|
|
1983
|
+
const sqlParams = namespace
|
|
1984
|
+
? [filter, roles ?? null, payloadJson, metaJson, namespace]
|
|
1985
|
+
: [filter, roles ?? null, payloadJson, metaJson];
|
|
1986
|
+
const { wakeCTE, wakeCount } = this.composeEscalationWakeCTE(wakeCommand, sqlParams, 'resolved');
|
|
1987
|
+
const result = await this.pgClient.query(`
|
|
1988
|
+
WITH target AS MATERIALIZED (
|
|
1989
|
+
SELECT id, signal_key, topic, status FROM public.hmsh_escalations
|
|
1990
|
+
WHERE ${namespace ? 'namespace = $5 AND' : ''}
|
|
1991
|
+
metadata @> $1::jsonb
|
|
1992
|
+
AND ($2::text[] IS NULL OR role = ANY($2::text[]))
|
|
1993
|
+
AND status IN ('pending', 'cancelled')
|
|
1994
|
+
ORDER BY priority ASC, created_at ASC
|
|
1995
|
+
LIMIT 1 FOR UPDATE
|
|
1996
|
+
),
|
|
1997
|
+
resolved AS (
|
|
1998
|
+
UPDATE public.hmsh_escalations e
|
|
1999
|
+
SET status = 'resolved', resolved_at = NOW(), resolver_payload = $3,
|
|
2000
|
+
metadata = CASE WHEN $4::jsonb IS NOT NULL
|
|
2001
|
+
THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb
|
|
2002
|
+
ELSE e.metadata END,
|
|
2003
|
+
updated_at = NOW()
|
|
2004
|
+
FROM target
|
|
2005
|
+
WHERE e.id = target.id AND target.status = 'pending'
|
|
2006
|
+
RETURNING e.*
|
|
2007
|
+
)${wakeCTE}
|
|
2008
|
+
SELECT t.id, t.status AS prior_status, t.signal_key, t.topic,
|
|
2009
|
+
CASE
|
|
2010
|
+
WHEN r.id IS NOT NULL THEN 'resolved'
|
|
2011
|
+
WHEN t.id IS NULL THEN 'not-found'
|
|
2012
|
+
ELSE 'blocked'
|
|
2013
|
+
END AS outcome,
|
|
2014
|
+
row_to_json(r.*) AS entry_json,
|
|
2015
|
+
${wakeCount}
|
|
2016
|
+
FROM (SELECT * FROM target) t
|
|
2017
|
+
FULL OUTER JOIN (SELECT * FROM resolved) r ON r.id = t.id
|
|
2018
|
+
`, sqlParams);
|
|
2019
|
+
const row = result.rows[0];
|
|
2020
|
+
if (!row || row.outcome === 'not-found')
|
|
2021
|
+
return { ok: false, reason: 'not-found' };
|
|
2022
|
+
if (row.outcome === 'blocked') {
|
|
2023
|
+
if (row.prior_status === 'cancelled')
|
|
1867
2024
|
return { ok: false, reason: 'already-cancelled' };
|
|
1868
|
-
}
|
|
1869
|
-
const updateResult = await this.pgClient.query(`UPDATE public.hmsh_escalations
|
|
1870
|
-
SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
|
|
1871
|
-
metadata = CASE WHEN $3::jsonb IS NOT NULL
|
|
1872
|
-
THEN COALESCE(metadata, '{}'::jsonb) || $3::jsonb
|
|
1873
|
-
ELSE metadata END,
|
|
1874
|
-
updated_at = NOW()
|
|
1875
|
-
WHERE id = $1 AND status = 'pending'
|
|
1876
|
-
RETURNING *`, [id, payloadJson, metaJson]);
|
|
1877
|
-
if (!updateResult.rows[0]) {
|
|
1878
|
-
await this.pgClient.query('ROLLBACK');
|
|
1879
|
-
return { ok: false, reason: 'already-resolved' };
|
|
1880
|
-
}
|
|
1881
|
-
await this.pgClient.query('COMMIT');
|
|
1882
|
-
return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic };
|
|
1883
|
-
}
|
|
1884
|
-
catch (e) {
|
|
1885
|
-
await this.pgClient.query('ROLLBACK');
|
|
1886
|
-
throw e;
|
|
2025
|
+
return { ok: false, reason: 'already-resolved' };
|
|
1887
2026
|
}
|
|
2027
|
+
return {
|
|
2028
|
+
ok: true,
|
|
2029
|
+
entry: row.entry_json,
|
|
2030
|
+
signalKey: row.signal_key,
|
|
2031
|
+
topic: row.topic,
|
|
2032
|
+
wakeEnqueued: row.wake_count > 0,
|
|
2033
|
+
};
|
|
1888
2034
|
}
|
|
1889
|
-
async cancelEscalation(id, namespace) {
|
|
2035
|
+
async cancelEscalation(id, namespace, wakeCommand) {
|
|
2036
|
+
//single statement: the cancellation wake is a data-modifying CTE
|
|
2037
|
+
//that fires iff the cancel lands AND the row's signal_key matches
|
|
2038
|
+
//the wake — atomic on any connection type (Client or Pool), with
|
|
2039
|
+
//no BEGIN/COMMIT window for unrelated queries to interleave into
|
|
2040
|
+
const params = namespace ? [id, namespace] : [id];
|
|
2041
|
+
const { wakeCTE, wakeCount } = this.composeEscalationWakeCTE(wakeCommand, params, 'cancelled');
|
|
1890
2042
|
const result = await this.pgClient.query(`
|
|
1891
2043
|
WITH target AS MATERIALIZED (
|
|
1892
2044
|
SELECT id, status FROM public.hmsh_escalations
|
|
@@ -1900,23 +2052,24 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1900
2052
|
WHERE public.hmsh_escalations.id = target.id
|
|
1901
2053
|
AND target.status = 'pending'
|
|
1902
2054
|
RETURNING public.hmsh_escalations.*
|
|
1903
|
-
)
|
|
2055
|
+
)${wakeCTE}
|
|
1904
2056
|
SELECT t.id, t.status AS prior_status,
|
|
1905
2057
|
CASE
|
|
1906
2058
|
WHEN c.id IS NOT NULL THEN 'cancelled'
|
|
1907
2059
|
WHEN t.id IS NULL THEN 'not-found'
|
|
1908
2060
|
ELSE 'already-terminal'
|
|
1909
2061
|
END AS outcome,
|
|
1910
|
-
row_to_json(c.*) AS entry_json
|
|
2062
|
+
row_to_json(c.*) AS entry_json,
|
|
2063
|
+
${wakeCount}
|
|
1911
2064
|
FROM (SELECT * FROM target) t
|
|
1912
2065
|
FULL OUTER JOIN (SELECT * FROM cancelled) c ON c.id = t.id
|
|
1913
|
-
`,
|
|
2066
|
+
`, params);
|
|
1914
2067
|
if (!result.rows[0] || result.rows[0].outcome === 'not-found')
|
|
1915
2068
|
return { ok: false, reason: 'not-found' };
|
|
1916
2069
|
if (result.rows[0].outcome === 'already-terminal')
|
|
1917
2070
|
return { ok: false, reason: 'already-terminal' };
|
|
1918
2071
|
const entry = result.rows[0].entry_json;
|
|
1919
|
-
return { ok: true, entry };
|
|
2072
|
+
return { ok: true, entry, wakeEnqueued: result.rows[0].wake_count > 0 };
|
|
1920
2073
|
}
|
|
1921
2074
|
async escalateEscalationToRole(params) {
|
|
1922
2075
|
const { id, targetRole, namespace } = params;
|
|
@@ -2065,6 +2218,7 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
2065
2218
|
WHERE id = ANY($2::uuid[])
|
|
2066
2219
|
${namespace ? 'AND namespace = $4' : ''}
|
|
2067
2220
|
AND status = 'pending'
|
|
2221
|
+
AND signal_key IS NULL
|
|
2068
2222
|
RETURNING *`, namespace ? [payloadJson, ids, metaJson, namespace] : [payloadJson, ids, metaJson]);
|
|
2069
2223
|
return result.rows;
|
|
2070
2224
|
}
|
|
@@ -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
|
|
|
@@ -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,18 @@ 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 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
|
+
*/
|
|
116
|
+
export interface EscalationWakeCommand {
|
|
117
|
+
forSignalKey: string;
|
|
118
|
+
message: string;
|
|
119
|
+
}
|
|
108
120
|
export type ReleaseEscalationResult = {
|
|
109
121
|
ok: true;
|
|
110
122
|
entry: EscalationEntry;
|
package/package.json
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
jid,reserved_at,retry_attempt,created_at,message
|
|
2
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm3,2026-07-06 20:19:07.953447+00,0,2026-07-06 20:18:07.945876+00,"{""metadata"":{""guid"":""HxfFFVmLVwtk37194ysP1qQ"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm3"",""gid"":""HAXgFtL8ZicUsOywHCKUauE"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""virtual-farm-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""printerBatch"",""arguments"":[{""machineId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm3"",""operatorId"":""0dfa0000-0000-4000-8000-000000000004"",""printSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""runsSoFar"":6,""plan"":[],""advertId"":""0a4af7d1-2dc4-4e06-9d43-791acf3c49e5""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm3-$printerBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm3-$printerBatch$1-5"",""workflowTopic"":""virtual-farm-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
3
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm4,2026-07-06 20:19:10.889217+00,0,2026-07-06 20:18:10.880925+00,"{""metadata"":{""guid"":""HXZnTUGkTix4uF7q14PuP0f"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm4"",""gid"":""HO9SAZEoZGiycaugLuwuUXv"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""virtual-farm-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""printerBatch"",""arguments"":[{""machineId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm4"",""operatorId"":""0dfa0000-0000-4000-8000-000000000005"",""printSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""runsSoFar"":6,""plan"":[],""advertId"":""a1d6d90a-a2cf-4621-a74f-e0ed1013cbc6""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm4-$printerBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm4-$printerBatch$1-5"",""workflowTopic"":""virtual-farm-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
4
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm5,2026-07-06 20:19:16.082786+00,0,2026-07-06 20:18:16.072927+00,"{""metadata"":{""guid"":""HE7nFSVKoh2oFe6-lYtCJWV"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm5"",""gid"":""H697gK8YvdS-_waP4NLiL-L"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""virtual-farm-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""printerBatch"",""arguments"":[{""machineId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm5"",""operatorId"":""0dfa0000-0000-4000-8000-000000000006"",""printSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""runsSoFar"":4,""plan"":[],""advertId"":""df441cd6-1811-41f0-b646-3c794ebf71d4""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm5-$printerBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm5-$printerBatch$1-5"",""workflowTopic"":""virtual-farm-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
5
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm1,2026-07-06 20:19:05.227375+00,0,2026-07-06 20:18:20.216145+00,"{""metadata"":{""guid"":""HTFIOwV5q0HYi8z04GwX9qj"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm1"",""gid"":""HSR3wD1ryGQ6f5vzzGcO8eV"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""virtual-farm-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""printerBatch"",""arguments"":[{""machineId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm1"",""operatorId"":""0dfa0000-0000-4000-8000-000000000002"",""printSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""runsSoFar"":13,""plan"":[],""advertId"":""e8ee662b-6de6-4a57-b8d6-ee9ed76e1f99""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm1-$printerBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm1-$printerBatch$1-5"",""workflowTopic"":""virtual-farm-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
6
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm0,2026-07-06 20:19:09.068044+00,0,2026-07-06 20:18:39.059327+00,"{""metadata"":{""guid"":""HcFhO_I7Fqn_5ftBd4uUTuZ"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm0"",""gid"":""HyVUxkj0OkvEO2r0sdgtTan"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""virtual-farm-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""printerBatch"",""arguments"":[{""machineId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm0"",""operatorId"":""0dfa0000-0000-4000-8000-000000000001"",""printSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""runsSoFar"":12,""plan"":[],""advertId"":""53bd1b3a-8ec3-4c35-a839-da93d9e23bc0""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm0-$printerBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm0-$printerBatch$1-5"",""workflowTopic"":""virtual-farm-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
7
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm2,2026-07-06 20:19:18.195319+00,0,2026-07-06 20:19:18.191286+00,"{""metadata"":{""guid"":""HWpXdbtZ-Bx5iqINxvAa6wf"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm2"",""gid"":""HWzNcP3ToTg27Qc5u5MljTT"",""dad"":"",0,16,0,0"",""aid"":""proxyer"",""topic"":""virtual-farm-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""printerBatch"",""arguments"":[{""machineId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm2"",""operatorId"":""0dfa0000-0000-4000-8000-000000000003"",""printSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""runsSoFar"":8,""plan"":[],""advertId"":""a0a84553-5031-456d-ae02-5d9746b606fc""}],""workflowDimension"":""$1"",""index"":6,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm2-$printerBatch$1-6"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-farm-vm2-$printerBatch$1-6"",""workflowTopic"":""virtual-farm-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
8
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-manager,2026-07-06 20:19:14.76557+00,0,2026-07-06 20:18:29.759824+00,"{""metadata"":{""guid"":""HzkL-DZTtXyIyDV8oZNZJxI"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-manager"",""gid"":""HCGavoqirvD8FwdwQOWgcov"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""managerBatch"",""arguments"":[{""laneId"":""9a93b820-dde3-425b-a320-260b91c08ec2"",""reviewSeconds"":3,""moveSeconds"":3,""idleTickSeconds"":3,""maxIterations"":25,""actorEmail"":""sim.manager.marco@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-manager-$managerBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-manager-$managerBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
9
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-0,2026-07-06 20:19:12.094467+00,0,2026-07-06 20:18:42.089308+00,"{""metadata"":{""guid"":""H2MwGXqBeP8-8nPX-T6ndnU"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-0"",""gid"":""H7nf5o8VvELz6NHWGWGNsXe"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""NEEDS_QUALITY_CONTROL"",""role"":""quality-inspector"",""toStatus"":""NEEDS_SHIPPING"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":7,""unparkedRejects"":[],""troubledOrders"":[""931a1e99-2db6-4280-b034-57e3c42a9aba""],""actorEmail"":""sim.inspector.ines@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-0-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-0-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
10
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-1,2026-07-06 20:19:12.096894+00,0,2026-07-06 20:18:42.093836+00,"{""metadata"":{""guid"":""H4pQ4lScxaW8wUEiP8BQh8E"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-1"",""gid"":""HQUqSMYqzsvODyjMvRNPtvl"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""NEEDS_QUALITY_CONTROL"",""role"":""quality-inspector"",""toStatus"":""NEEDS_SHIPPING"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":7,""unparkedRejects"":[],""troubledOrders"":[""931a1e99-2db6-4280-b034-57e3c42a9aba""],""actorEmail"":""sim.inspector.tomas@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-1-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_quality_control-1-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
11
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-draft,2026-07-06 20:19:16.150935+00,0,2026-07-06 20:18:46.146153+00,"{""metadata"":{""guid"":""HgtJMxOb1oYv6jJb2uErF0_"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-draft"",""gid"":""HN6b23imRu-wgDHA-DpxzLV"",""dad"":"",0,14,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""DRAFT"",""role"":""insole-designer"",""toStatus"":""NEEDS_MANUFACTURING"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":24,""unparkedRejects"":[],""troubledOrders"":[""2b294913-f8ca-4639-9928-3abd53ea9616""],""actorEmail"":""sim.designer.dana@sim.hike.local"",""worklistStation"":""design-approval""}],""workflowDimension"":""$1"",""index"":4,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-draft-$associateBatch$1-4"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-draft-$associateBatch$1-4"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
12
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-1,2026-07-06 20:19:04.938838+00,0,2026-07-06 20:18:49.932504+00,"{""metadata"":{""guid"":""HQD-nuRyxBmH431uJ8noMBS"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-1"",""gid"":""HWM3gVxjxz9_K-EnQpCeFjY"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""NEEDS_FINISHING"",""role"":""finisher"",""toStatus"":""NEEDS_QUALITY_CONTROL"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":9,""unparkedRejects"":[],""troubledOrders"":[],""actorEmail"":""sim.finisher.kofi@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-1-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-1-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
13
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-0,2026-07-06 20:19:14.016307+00,0,2026-07-06 20:18:59.008759+00,"{""metadata"":{""guid"":""HHBHdHpX2Wear_cVJim70Ps"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-0"",""gid"":""HkZyt5-JhLoCNX26Q4cyqbq"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""NEEDS_FINISHING"",""role"":""finisher"",""toStatus"":""NEEDS_QUALITY_CONTROL"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":11,""unparkedRejects"":[],""troubledOrders"":[],""actorEmail"":""sim.finisher.ada@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-0-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_finishing-0-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
14
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_shipping,2026-07-06 20:19:15.654293+00,0,2026-07-06 20:19:00.646061+00,"{""metadata"":{""guid"":""HffnP49JTwmHAwk3BNRn22l"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_shipping"",""gid"":""HuK-iSfDdYXXBJWo2WwhCDF"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""NEEDS_SHIPPING"",""role"":""shipping-associate"",""toStatus"":""COMPLETED"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":12,""unparkedRejects"":[],""troubledOrders"":[],""actorEmail"":""sim.shipper.june@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_shipping-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_shipping-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
15
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-1,2026-07-06 20:19:05.381804+00,0,2026-07-06 20:19:05.3792+00,"{""metadata"":{""guid"":""Hx82JQ7g0Y4XT7nqPFK4km9"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-1"",""gid"":""HoT3fZZg04_z7BKimsTs-Ei"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""NEEDS_GLUING"",""role"":""gluer"",""toStatus"":""NEEDS_FINISHING"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":13,""unparkedRejects"":[],""troubledOrders"":[],""actorEmail"":""sim.gluer.dev@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-1-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-1-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
16
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-0,2026-07-06 20:19:05.386589+00,0,2026-07-06 20:19:05.384446+00,"{""metadata"":{""guid"":""HvNFncRHNbqR_OSOOiQ0M12"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-0"",""gid"":""Hk7Wy8HO98wh45Clgp5-sUo"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""NEEDS_GLUING"",""role"":""gluer"",""toStatus"":""NEEDS_FINISHING"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":13,""unparkedRejects"":[],""troubledOrders"":[],""actorEmail"":""sim.gluer.rosa@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-0-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-needs_gluing-0-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
17
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-1,2026-07-06 20:19:06.832852+00,0,2026-07-06 20:19:06.829652+00,"{""metadata"":{""guid"":""Hg1xKYket-ULnS-HdHaNuiD"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-1"",""gid"":""HLjxCrhPCUyqBaWbwSE0pxp"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""POST_PRINT_QA"",""role"":""post-print-qa"",""toStatus"":""NEEDS_GLUING"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":14,""rejectRate"":{""pct"":15,""targetStation"":""PRINTING"",""reasons"":[""Layer separation on the arch — reprint"",""Surface finish below spec"",""Edge delamination at the heel cup"",""Warping on the medial side""],""leftQuantity"":1,""rightQuantity"":0},""unparkedRejects"":[],""troubledOrders"":[],""actorEmail"":""sim.qa.elena@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-1-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-1-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
18
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-0,2026-07-06 20:19:12.746444+00,0,2026-07-06 20:19:12.744474+00,"{""metadata"":{""guid"":""HTl4tkkOMhgso2QMlQlKYvn"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-0"",""gid"":""HY9mpKnlDbC3a1o3B4R-53h"",""dad"":"",0,15,0,0"",""aid"":""proxyer"",""topic"":""actor-crew-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""associateBatch"",""arguments"":[{""station"":""POST_PRINT_QA"",""role"":""post-print-qa"",""toStatus"":""NEEDS_GLUING"",""dwellSeconds"":4,""betweenSeconds"":2,""idleTickSeconds"":3,""maxIterations"":25,""touchesSoFar"":16,""rejectRate"":{""pct"":15,""targetStation"":""PRINTING"",""reasons"":[""Layer separation on the arch — reprint"",""Surface finish below spec"",""Edge delamination at the heel cup"",""Warping on the medial side""],""leftQuantity"":1,""rightQuantity"":0},""unparkedRejects"":[],""troubledOrders"":[],""actorEmail"":""sim.qa.marcus@sim.hike.local""}],""workflowDimension"":""$1"",""index"":5,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-0-$associateBatch$1-5"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-crew-post_print_qa-0-$associateBatch$1-5"",""workflowTopic"":""actor-crew-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|
|
19
|
+
simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-design,2026-07-06 20:19:10.594054+00,0,2026-07-06 20:18:55.585455+00,"{""metadata"":{""guid"":""HeSdgdXIms_1Lhh2nNgc4EP"",""jid"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-design"",""gid"":""HU01R82t5rnplNLKWryLJPz"",""dad"":"",0,40,0,0"",""aid"":""proxyer"",""topic"":""simulation-activity"",""wfn"":""durable.execute""},""data"":{""activityName"":""designTwinBatch"",""arguments"":[{""user"":""sim.taika.iris@sim.hike.local"",""designSeconds"":2,""idleTickSeconds"":3,""maxIterations"":10,""concurrency"":4,""designedOrders"":[""0f596ab9-f449-485d-b06d-e74864750ae7"",""a5cedbc2-f726-4411-9ac3-10fd113532d2"",""c1a09bd7-bd4b-4eb1-bd3e-91d6b45b479f"",""94d74e0e-e4ce-4db7-91f3-02c1c081c2ba"",""0ed99be7-83b6-4a91-9aa5-560d5c110c6b"",""33b987bb-9253-483f-baa4-37d4ffd0903c"",""2c4bc240-1335-423d-96af-e5855b6ccc0f"",""0e7f24bb-3fba-40e5-a5a8-666f5c91fd49"",""ad0c05b6-4528-4f2a-9569-d06c76589b54"",""de2a02e2-33ca-4c0c-843c-5ad422c7c517"",""c008abd0-8b3b-44f7-9f1c-7d314f72f587"",""04a28a34-20f3-4d10-a636-3cb8f504f56e"",""52295fa8-452b-48ca-862b-64cf3ff8cae6"",""cb1dd956-639d-4ff8-9f56-c12f9c5278e8"",""fca4a3f0-fc49-47cd-8285-bf7e11c60dbe"",""bcd42ec0-f047-4acc-8e70-33575ae7535a"",""3b73fb95-2366-4498-ac9a-3f2d9eb4d1c0"",""3c9d174f-7928-42e7-9edb-2f3b38ba0ddc"",""1e61d445-77ba-4013-af89-1a6de07ff407"",""0b066160-06ab-4681-af20-8c36866c9987"",""fa9f996c-2327-4f30-8fdc-e63239483f23"",""ff31ec18-c7e3-4907-85f0-5c4eda42841f"",""f17a825c-8905-4166-b687-54015ddeae6c"",""931a1e99-2db6-4280-b034-57e3c42a9aba"",""2b294913-f8ca-4639-9928-3abd53ea9616"",""04caa323-662a-4c28-8de0-3655d435f206"",""b2eac688-d074-45d9-8a60-e87719b18c81"",""a11092fe-31e5-4cfe-815c-230a09f2165e"",""176e3d03-0813-4816-83fd-a785798a83bb"",""e1d887bd-1ad4-4436-b728-d75e7df2c39a"",""c8a0ffdc-91b0-443a-a8f9-c01c2e3935c0"",""83cbe6eb-ea9b-492e-8657-4f138e3df05e"",""e8e56685-82a3-4f88-a804-b6a8c21f0a4c"",""89d231bf-600b-40ed-8161-e58dbcc7157c"",""6904b329-7a63-465f-aa6a-82a1095638fb"",""9ca2abe9-ae5f-459a-a003-9ea4ad5cafc0"",""cae10b57-439a-4e98-b0d0-1bb172a9d79f"",""f90af7cb-401b-4e68-9f22-a45d34581d64"",""86a1d1b2-7287-48ab-94b3-544a743fda41"",""76793c77-67a0-4148-b83b-fa50f900111c""]}],""workflowDimension"":""$3"",""index"":8,""originJobId"":""simulationDirector-H5zQndzkcfIGHhUM-QXFzcG"",""parentWorkflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-design-$designTwinBatch$3-8"",""workflowId"":""-simulationDirector-H5zQndzkcfIGHhUM-QXFzcG-design-$designTwinBatch$3-8"",""workflowTopic"":""simulation-activity"",""expire"":2592000,""backoffCoefficient"":5,""initialInterval"":1,""maximumAttempts"":3,""maximumInterval"":120}}"
|