@hotmeshio/hotmesh 0.25.5 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OPTIMIZATIONS.md +128 -0
- package/RELEASE-0.26.0.md +111 -0
- package/build/package.json +1 -1
- package/build/services/activities/hook.js +22 -0
- package/build/services/durable/workflow/condition.d.ts +20 -3
- package/build/services/durable/workflow/condition.js +20 -3
- package/build/services/durable/workflow/proxyActivities.d.ts +14 -0
- package/build/services/durable/workflow/proxyActivities.js +14 -0
- package/build/services/escalations/client.d.ts +26 -8
- package/build/services/escalations/client.js +43 -17
- package/build/services/store/providers/postgres/postgres.d.ts +35 -7
- package/build/services/store/providers/postgres/postgres.js +244 -142
- package/build/services/stream/providers/postgres/kvtables.js +76 -14
- package/build/services/stream/providers/postgres/messages.js +6 -3
- package/build/services/stream/providers/postgres/procedures.js +6 -4
- package/build/types/hmsh_escalations.d.ts +30 -8
- package/package.json +1 -1
- package/ISSUE.md +0 -53
|
@@ -162,6 +162,18 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
|
|
|
162
162
|
* picks it up — no intermediate table, no polling, fully transactional.
|
|
163
163
|
*/
|
|
164
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>;
|
|
165
177
|
getNextTask(_listKey?: string): Promise<[
|
|
166
178
|
listKey: string,
|
|
167
179
|
jobId: string,
|
|
@@ -274,13 +286,14 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
|
|
|
274
286
|
claimEscalationByMetadata(params: import('../../../../types/hmsh_escalations').ClaimByMetadataParams): Promise<import('../../../../types/hmsh_escalations').ClaimByMetadataResult>;
|
|
275
287
|
releaseEscalation(params: import('../../../../types/hmsh_escalations').ReleaseEscalationParams): Promise<import('../../../../types/hmsh_escalations').ReleaseEscalationResult>;
|
|
276
288
|
/**
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
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.
|
|
282
295
|
*/
|
|
283
|
-
private
|
|
296
|
+
private composeEscalationWakeCTE;
|
|
284
297
|
resolveEscalation(params: import('../../../../types/hmsh_escalations').ResolveEscalationParams, wakeCommand?: import('../../../../types/hmsh_escalations').EscalationWakeCommand): Promise<import('../../../../types/hmsh_escalations').ResolveEscalationResult & {
|
|
285
298
|
signalKey?: string | null;
|
|
286
299
|
topic?: string | null;
|
|
@@ -302,7 +315,9 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
|
|
|
302
315
|
topic?: string | null;
|
|
303
316
|
wakeEnqueued?: boolean;
|
|
304
317
|
}>;
|
|
305
|
-
cancelEscalation(id: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').CancelEscalationResult
|
|
318
|
+
cancelEscalation(id: string, namespace?: string, wakeCommand?: import('../../../../types/hmsh_escalations').EscalationWakeCommand): Promise<import('../../../../types/hmsh_escalations').CancelEscalationResult & {
|
|
319
|
+
wakeEnqueued?: boolean;
|
|
320
|
+
}>;
|
|
306
321
|
escalateEscalationToRole(params: import('../../../../types/hmsh_escalations').EscalateToRoleParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
|
|
307
322
|
updateEscalation(params: import('../../../../types/hmsh_escalations').UpdateEscalationParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
|
|
308
323
|
appendEscalationMilestones(params: import('../../../../types/hmsh_escalations').AppendMilestonesParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
|
|
@@ -316,5 +331,18 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
|
|
|
316
331
|
escalationStats(params?: import('../../../../types/hmsh_escalations').StatsEscalationsParams): Promise<import('../../../../types/hmsh_escalations').EscalationStats>;
|
|
317
332
|
listDistinctEscalationTypes(namespace?: string): Promise<string[]>;
|
|
318
333
|
releaseExpiredEscalations(_namespace?: string): Promise<number>;
|
|
334
|
+
/**
|
|
335
|
+
* Deletes terminal escalation rows (`resolved`/`cancelled`/`expired`) whose
|
|
336
|
+
* `updated_at` is older than the given horizon. Terminal rows are inert —
|
|
337
|
+
* every state transition guards `status = 'pending'` — so pruning them is
|
|
338
|
+
* the engine-blessed retention path for the audit backlog.
|
|
339
|
+
*
|
|
340
|
+
* Single statement: the candidate SELECT (`FOR UPDATE SKIP LOCKED`) and the
|
|
341
|
+
* DELETE are one atomic unit, so concurrent pruners and readers never block
|
|
342
|
+
* each other and a row is either fully gone or fully present. `limit` bounds
|
|
343
|
+
* rows per call to keep vacuum pressure flat; callers loop until `deleted`
|
|
344
|
+
* returns 0.
|
|
345
|
+
*/
|
|
346
|
+
pruneEscalations(params: import('../../../../types/hmsh_escalations').PruneEscalationsParams): Promise<import('../../../../types/hmsh_escalations').PruneEscalationsResult>;
|
|
319
347
|
}
|
|
320
348
|
export { PostgresStoreService };
|
|
@@ -814,43 +814,52 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
814
814
|
const wasInsert = lockResult.rows[0]?.inserted;
|
|
815
815
|
const isPending = captured?.startsWith('$pending::');
|
|
816
816
|
if (isPending && redelivery) {
|
|
817
|
-
//consume the marker and commit its redelivery as ONE
|
|
818
|
-
//pending wake becomes a durable engine stream message in
|
|
819
|
-
//same
|
|
820
|
-
//survives a crash at any instant.
|
|
821
|
-
//
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
metadata: {
|
|
828
|
-
guid: (0, utils_1.guid)(),
|
|
829
|
-
aid: redelivery.aid,
|
|
830
|
-
topic: redelivery.topic,
|
|
831
|
-
},
|
|
832
|
-
data: JSON.parse(pendingData),
|
|
833
|
-
});
|
|
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.
|
|
834
827
|
const schemaName = this.kvsql().safeName(this.appId);
|
|
835
|
-
await this.pgClient.query(
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
|
841
840
|
(stream_name, message, priority)
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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 };
|
|
848
859
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
});
|
|
853
|
-
return { success: true };
|
|
860
|
+
//the marker vanished between capture and consume (a concurrent
|
|
861
|
+
//duplicate Leg1 consumed it) — idempotent no-op
|
|
862
|
+
return { success: false };
|
|
854
863
|
}
|
|
855
864
|
if (!wasInsert) {
|
|
856
865
|
//step 2: row existed — overwrite with hook value
|
|
@@ -1022,10 +1031,12 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1022
1031
|
metadata: { guid: (0, utils_1.guid)(), jid: jobId, gid: gId, dad: msgDad, aid },
|
|
1023
1032
|
data: { timestamp: Date.now() },
|
|
1024
1033
|
});
|
|
1034
|
+
//jid column stamped so job-scoped expiry (interrupt purge, timeout
|
|
1035
|
+
//disarm) can address armed timers through the partial jid index
|
|
1025
1036
|
const sql = `INSERT INTO ${schemaName}.engine_streams
|
|
1026
|
-
(stream_name, message, priority, visible_at)
|
|
1027
|
-
VALUES ($1, $2, 5, NOW() + INTERVAL '${Math.max(delayMs, 0)} milliseconds')`;
|
|
1028
|
-
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];
|
|
1029
1040
|
if (transaction && typeof transaction.addCommand === 'function') {
|
|
1030
1041
|
transaction.addCommand(sql, params);
|
|
1031
1042
|
}
|
|
@@ -1033,6 +1044,26 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1033
1044
|
await this.pgClient.query(sql, params);
|
|
1034
1045
|
}
|
|
1035
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
|
+
}
|
|
1036
1067
|
async getNextTask(_listKey) {
|
|
1037
1068
|
//no-op: time hooks are now engine_streams messages with future visible_at.
|
|
1038
1069
|
//the engine's normal dequeue handles them when they become visible.
|
|
@@ -1832,85 +1863,95 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1832
1863
|
return { ok: true, entry: row.entry_json };
|
|
1833
1864
|
}
|
|
1834
1865
|
/**
|
|
1835
|
-
*
|
|
1836
|
-
*
|
|
1837
|
-
*
|
|
1838
|
-
*
|
|
1839
|
-
*
|
|
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.
|
|
1840
1872
|
*/
|
|
1841
|
-
|
|
1842
|
-
if (!wakeCommand
|
|
1843
|
-
return
|
|
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;
|
|
1873
|
+
composeEscalationWakeCTE(wakeCommand, params, fromCTE) {
|
|
1874
|
+
if (!wakeCommand) {
|
|
1875
|
+
return { wakeCTE: '', wakeCount: '0::int AS wake_count' };
|
|
1857
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
|
+
};
|
|
1858
1890
|
}
|
|
1859
1891
|
async resolveEscalation(params, wakeCommand) {
|
|
1860
1892
|
const { id, namespace, resolverPayload, metadata } = params;
|
|
1861
1893
|
const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
|
|
1862
|
-
// metaJson is bound at a fixed index; the CASE no-ops when null so resolution
|
|
1863
|
-
// can merge into the GIN-indexed metadata without disturbing the param layout.
|
|
1864
1894
|
const metaJson = metadata ? JSON.stringify(metadata) : null;
|
|
1865
|
-
//
|
|
1866
|
-
//
|
|
1867
|
-
//
|
|
1868
|
-
//
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
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')
|
|
1880
1938
|
return { ok: false, reason: 'already-cancelled' };
|
|
1881
|
-
|
|
1882
|
-
if (status === 'expired') {
|
|
1939
|
+
if (row.prior_status === 'expired') {
|
|
1883
1940
|
// The wait's SLA timer fired first and the workflow resumed with
|
|
1884
1941
|
// false — the resolver payload has nowhere to go. Name it, so the
|
|
1885
1942
|
// operator learns the deadline passed rather than believing the
|
|
1886
1943
|
// resolution landed.
|
|
1887
|
-
await this.pgClient.query('ROLLBACK');
|
|
1888
1944
|
return { ok: false, reason: 'already-expired' };
|
|
1889
1945
|
}
|
|
1890
|
-
|
|
1891
|
-
SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
|
|
1892
|
-
metadata = CASE WHEN $3::jsonb IS NOT NULL
|
|
1893
|
-
THEN COALESCE(metadata, '{}'::jsonb) || $3::jsonb
|
|
1894
|
-
ELSE metadata END,
|
|
1895
|
-
updated_at = NOW()
|
|
1896
|
-
WHERE id = $1 ${namespace ? 'AND namespace = $4' : ''} AND status = 'pending'
|
|
1897
|
-
RETURNING *`, namespace ? [id, payloadJson, metaJson, namespace] : [id, payloadJson, metaJson]);
|
|
1898
|
-
if (!updateResult.rows[0]) {
|
|
1899
|
-
await this.pgClient.query('ROLLBACK');
|
|
1900
|
-
return { ok: false, reason: 'already-resolved' };
|
|
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);
|
|
1907
|
-
await this.pgClient.query('COMMIT');
|
|
1908
|
-
return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic, wakeEnqueued };
|
|
1909
|
-
}
|
|
1910
|
-
catch (e) {
|
|
1911
|
-
await this.pgClient.query('ROLLBACK');
|
|
1912
|
-
throw e;
|
|
1946
|
+
return { ok: false, reason: 'already-resolved' };
|
|
1913
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
|
+
};
|
|
1914
1955
|
}
|
|
1915
1956
|
/**
|
|
1916
1957
|
* Non-locking preview of the row `resolveEscalationByMetadata` would
|
|
@@ -1938,46 +1979,66 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1938
1979
|
const filter = JSON.stringify({ [key]: value });
|
|
1939
1980
|
const payloadJson = resolverPayload ? JSON.stringify(resolverPayload) : null;
|
|
1940
1981
|
const metaJson = metadata ? JSON.stringify(metadata) : null;
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
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')
|
|
1957
2024
|
return { ok: false, reason: 'already-cancelled' };
|
|
1958
|
-
}
|
|
1959
|
-
const updateResult = await this.pgClient.query(`UPDATE public.hmsh_escalations
|
|
1960
|
-
SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
|
|
1961
|
-
metadata = CASE WHEN $3::jsonb IS NOT NULL
|
|
1962
|
-
THEN COALESCE(metadata, '{}'::jsonb) || $3::jsonb
|
|
1963
|
-
ELSE metadata END,
|
|
1964
|
-
updated_at = NOW()
|
|
1965
|
-
WHERE id = $1 AND status = 'pending'
|
|
1966
|
-
RETURNING *`, [id, payloadJson, metaJson]);
|
|
1967
|
-
if (!updateResult.rows[0]) {
|
|
1968
|
-
await this.pgClient.query('ROLLBACK');
|
|
1969
|
-
return { ok: false, reason: 'already-resolved' };
|
|
1970
|
-
}
|
|
1971
|
-
const wakeEnqueued = await this.enqueueEscalationWake(wakeCommand, signal_key, id);
|
|
1972
|
-
await this.pgClient.query('COMMIT');
|
|
1973
|
-
return { ok: true, entry: updateResult.rows[0], signalKey: signal_key, topic, wakeEnqueued };
|
|
1974
|
-
}
|
|
1975
|
-
catch (e) {
|
|
1976
|
-
await this.pgClient.query('ROLLBACK');
|
|
1977
|
-
throw e;
|
|
2025
|
+
return { ok: false, reason: 'already-resolved' };
|
|
1978
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
|
+
};
|
|
1979
2034
|
}
|
|
1980
|
-
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');
|
|
1981
2042
|
const result = await this.pgClient.query(`
|
|
1982
2043
|
WITH target AS MATERIALIZED (
|
|
1983
2044
|
SELECT id, status FROM public.hmsh_escalations
|
|
@@ -1991,23 +2052,24 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
1991
2052
|
WHERE public.hmsh_escalations.id = target.id
|
|
1992
2053
|
AND target.status = 'pending'
|
|
1993
2054
|
RETURNING public.hmsh_escalations.*
|
|
1994
|
-
)
|
|
2055
|
+
)${wakeCTE}
|
|
1995
2056
|
SELECT t.id, t.status AS prior_status,
|
|
1996
2057
|
CASE
|
|
1997
2058
|
WHEN c.id IS NOT NULL THEN 'cancelled'
|
|
1998
2059
|
WHEN t.id IS NULL THEN 'not-found'
|
|
1999
2060
|
ELSE 'already-terminal'
|
|
2000
2061
|
END AS outcome,
|
|
2001
|
-
row_to_json(c.*) AS entry_json
|
|
2062
|
+
row_to_json(c.*) AS entry_json,
|
|
2063
|
+
${wakeCount}
|
|
2002
2064
|
FROM (SELECT * FROM target) t
|
|
2003
2065
|
FULL OUTER JOIN (SELECT * FROM cancelled) c ON c.id = t.id
|
|
2004
|
-
`,
|
|
2066
|
+
`, params);
|
|
2005
2067
|
if (!result.rows[0] || result.rows[0].outcome === 'not-found')
|
|
2006
2068
|
return { ok: false, reason: 'not-found' };
|
|
2007
2069
|
if (result.rows[0].outcome === 'already-terminal')
|
|
2008
2070
|
return { ok: false, reason: 'already-terminal' };
|
|
2009
2071
|
const entry = result.rows[0].entry_json;
|
|
2010
|
-
return { ok: true, entry };
|
|
2072
|
+
return { ok: true, entry, wakeEnqueued: result.rows[0].wake_count > 0 };
|
|
2011
2073
|
}
|
|
2012
2074
|
async escalateEscalationToRole(params) {
|
|
2013
2075
|
const { id, targetRole, namespace } = params;
|
|
@@ -2156,6 +2218,7 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
2156
2218
|
WHERE id = ANY($2::uuid[])
|
|
2157
2219
|
${namespace ? 'AND namespace = $4' : ''}
|
|
2158
2220
|
AND status = 'pending'
|
|
2221
|
+
AND signal_key IS NULL
|
|
2159
2222
|
RETURNING *`, namespace ? [payloadJson, ids, metaJson, namespace] : [payloadJson, ids, metaJson]);
|
|
2160
2223
|
return result.rows;
|
|
2161
2224
|
}
|
|
@@ -2255,5 +2318,44 @@ class PostgresStoreService extends __1.StoreService {
|
|
|
2255
2318
|
// assigned_until, so no background sweep is needed to release expired claims.
|
|
2256
2319
|
return 0;
|
|
2257
2320
|
}
|
|
2321
|
+
/**
|
|
2322
|
+
* Deletes terminal escalation rows (`resolved`/`cancelled`/`expired`) whose
|
|
2323
|
+
* `updated_at` is older than the given horizon. Terminal rows are inert —
|
|
2324
|
+
* every state transition guards `status = 'pending'` — so pruning them is
|
|
2325
|
+
* the engine-blessed retention path for the audit backlog.
|
|
2326
|
+
*
|
|
2327
|
+
* Single statement: the candidate SELECT (`FOR UPDATE SKIP LOCKED`) and the
|
|
2328
|
+
* DELETE are one atomic unit, so concurrent pruners and readers never block
|
|
2329
|
+
* each other and a row is either fully gone or fully present. `limit` bounds
|
|
2330
|
+
* rows per call to keep vacuum pressure flat; callers loop until `deleted`
|
|
2331
|
+
* returns 0.
|
|
2332
|
+
*/
|
|
2333
|
+
async pruneEscalations(params) {
|
|
2334
|
+
const TERMINAL = ['resolved', 'cancelled', 'expired'];
|
|
2335
|
+
const statuses = params.statuses?.length
|
|
2336
|
+
? params.statuses.filter((s) => TERMINAL.includes(s))
|
|
2337
|
+
: [...TERMINAL];
|
|
2338
|
+
if (!statuses.length)
|
|
2339
|
+
return { deleted: 0 };
|
|
2340
|
+
const limit = Math.max(1, Math.min(params.limit ?? 10000, 100000));
|
|
2341
|
+
const values = [statuses, params.olderThan, limit];
|
|
2342
|
+
let nsClause = '';
|
|
2343
|
+
if (params.namespace) {
|
|
2344
|
+
values.push(params.namespace);
|
|
2345
|
+
nsClause = `AND namespace = $${values.length}`;
|
|
2346
|
+
}
|
|
2347
|
+
const result = await this.pgClient.query(`WITH doomed AS (
|
|
2348
|
+
SELECT ctid FROM public.hmsh_escalations
|
|
2349
|
+
WHERE status = ANY($1::text[])
|
|
2350
|
+
AND status IN ('resolved', 'cancelled', 'expired')
|
|
2351
|
+
AND updated_at < NOW() - $2::interval
|
|
2352
|
+
${nsClause}
|
|
2353
|
+
LIMIT $3
|
|
2354
|
+
FOR UPDATE SKIP LOCKED
|
|
2355
|
+
)
|
|
2356
|
+
DELETE FROM public.hmsh_escalations e
|
|
2357
|
+
USING doomed WHERE e.ctid = doomed.ctid`, values);
|
|
2358
|
+
return { deleted: result.rowCount ?? 0 };
|
|
2359
|
+
}
|
|
2258
2360
|
}
|
|
2259
2361
|
exports.PostgresStoreService = PostgresStoreService;
|