@dbos-inc/dbos-sdk 5.1.6-preview → 5.1.8-preview
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/dist/schemas/system_db_schema.d.ts +1 -0
- package/dist/schemas/system_db_schema.d.ts.map +1 -1
- package/dist/schemas/system_db_schema.js.map +1 -1
- package/dist/src/client.d.ts +17 -0
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +15 -0
- package/dist/src/client.js.map +1 -1
- package/dist/src/conductor/conductor.d.ts.map +1 -1
- package/dist/src/conductor/conductor.js +18 -0
- package/dist/src/conductor/conductor.js.map +1 -1
- package/dist/src/conductor/protocol.d.ts +17 -1
- package/dist/src/conductor/protocol.d.ts.map +1 -1
- package/dist/src/conductor/protocol.js +10 -1
- package/dist/src/conductor/protocol.js.map +1 -1
- package/dist/src/context.d.ts +12 -1
- package/dist/src/context.d.ts.map +1 -1
- package/dist/src/context.js +5 -1
- package/dist/src/context.js.map +1 -1
- package/dist/src/datasource.d.ts +6 -0
- package/dist/src/datasource.d.ts.map +1 -1
- package/dist/src/datasource.js.map +1 -1
- package/dist/src/dbos-executor.d.ts +10 -0
- package/dist/src/dbos-executor.d.ts.map +1 -1
- package/dist/src/dbos-executor.js +75 -43
- package/dist/src/dbos-executor.js.map +1 -1
- package/dist/src/dbos.d.ts +25 -0
- package/dist/src/dbos.d.ts.map +1 -1
- package/dist/src/dbos.js +27 -0
- package/dist/src/dbos.js.map +1 -1
- package/dist/src/step.d.ts +1 -0
- package/dist/src/step.d.ts.map +1 -1
- package/dist/src/step.js.map +1 -1
- package/dist/src/sysdb_migrations/internal/migrations.js +6 -0
- package/dist/src/sysdb_migrations/internal/migrations.js.map +1 -1
- package/dist/src/system_database.d.ts +32 -0
- package/dist/src/system_database.d.ts.map +1 -1
- package/dist/src/system_database.js +187 -2
- package/dist/src/system_database.js.map +1 -1
- package/dist/src/workflow_management.d.ts +15 -0
- package/dist/src/workflow_management.d.ts.map +1 -1
- package/dist/src/workflow_management.js +45 -1
- package/dist/src/workflow_management.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
|
@@ -15,6 +15,7 @@ const pg_1 = require("pg");
|
|
|
15
15
|
const error_1 = require("./error");
|
|
16
16
|
const workflow_1 = require("./workflow");
|
|
17
17
|
const utils_1 = require("./utils");
|
|
18
|
+
const async_hooks_1 = require("async_hooks");
|
|
18
19
|
const crypto_1 = require("crypto");
|
|
19
20
|
const utils_2 = require("./utils");
|
|
20
21
|
const database_utils_1 = require("./database_utils");
|
|
@@ -564,6 +565,7 @@ class SystemDatabase {
|
|
|
564
565
|
dbPollingIntervalResultMs = 1000;
|
|
565
566
|
dbPollingIntervalEventMs = 10000;
|
|
566
567
|
dbPollingIntervalStreamMs = 1000;
|
|
568
|
+
dbPollingIntervalCancelMs = 1000;
|
|
567
569
|
shouldUseDBNotifications = true;
|
|
568
570
|
notificationsMap = new NotificationMap();
|
|
569
571
|
workflowEventsMap = new NotificationMap();
|
|
@@ -587,6 +589,11 @@ class SystemDatabase {
|
|
|
587
589
|
*/
|
|
588
590
|
pollLimiter;
|
|
589
591
|
runningWorkflowMap = new Map(); // Map from workflowID to workflow promise, queue name and partition key
|
|
592
|
+
#cancelWatchers = new Map();
|
|
593
|
+
#cancelPollerLoop = undefined;
|
|
594
|
+
#cancelPollerWake = null;
|
|
595
|
+
// Runs the lazily started cancel poller outside the async context of the step that happens to start it
|
|
596
|
+
#cancelPollerScope = new async_hooks_1.AsyncResource('DBOSCancelPoller');
|
|
590
597
|
// Per-partition-key created_at cursors: keep per-key queue order monotonic across batches
|
|
591
598
|
#batchCreatedAtCursors = new Map();
|
|
592
599
|
// Set by destroy(), so polling waits end instead of running on against a pool that outlives this handle.
|
|
@@ -773,6 +780,10 @@ class SystemDatabase {
|
|
|
773
780
|
await this.#notifierLoop;
|
|
774
781
|
this.#notifierLoop = undefined;
|
|
775
782
|
}
|
|
783
|
+
this.#cancelPollerWake?.();
|
|
784
|
+
if (this.#cancelPollerLoop) {
|
|
785
|
+
await this.#cancelPollerLoop;
|
|
786
|
+
}
|
|
776
787
|
if (this.notificationsClient) {
|
|
777
788
|
this.#retireNotificationsClient(this.notificationsClient);
|
|
778
789
|
}
|
|
@@ -1493,6 +1504,106 @@ class SystemDatabase {
|
|
|
1493
1504
|
this.runningWorkflowMap.delete(wfid);
|
|
1494
1505
|
}
|
|
1495
1506
|
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Drop a workflow's history from `startStep` onwards and re-enqueue it under the same
|
|
1509
|
+
* workflow ID, so a replay re-executes everything from that step.
|
|
1510
|
+
*
|
|
1511
|
+
* Unlike forkWorkflow this writes no new workflow: peers keep addressing the same ID,
|
|
1512
|
+
* and the workflow's mailbox, events, and streams are not copied anywhere. Unlike
|
|
1513
|
+
* resumeWorkflows it applies only to workflows in a terminal state.
|
|
1514
|
+
*
|
|
1515
|
+
* Events published at or past the cut are rolled back to the last value published
|
|
1516
|
+
* below it, using workflow_events_history as an undo log; a key that only the
|
|
1517
|
+
* discarded run ever published is unpublished outright.
|
|
1518
|
+
*
|
|
1519
|
+
* Messages the discarded run consumed or received after the rewind point are deleted.
|
|
1520
|
+
*
|
|
1521
|
+
* Stream entries written by the discarded run remain in place, with the exception of
|
|
1522
|
+
* the close sentinel, which has to go so new entries can be appended.
|
|
1523
|
+
*/
|
|
1524
|
+
async rewindWorkflow(workflowID, startStep, options = {}) {
|
|
1525
|
+
// Function IDs start at 0 in this SDK, so 0 is the whole history, not an error.
|
|
1526
|
+
if (startStep < 0) {
|
|
1527
|
+
throw new error_1.DBOSError(`startStep must be >= 0, got ${startStep}`);
|
|
1528
|
+
}
|
|
1529
|
+
const schema = this.schemaName;
|
|
1530
|
+
const client = await this.#connect();
|
|
1531
|
+
try {
|
|
1532
|
+
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
|
|
1533
|
+
const { rows: statusRows } = await client.query(`SELECT status FROM "${schema}".workflow_status WHERE workflow_uuid = $1`, [workflowID]);
|
|
1534
|
+
if (statusRows.length === 0) {
|
|
1535
|
+
throw new error_1.DBOSNonExistentWorkflowError(`Workflow ${workflowID} does not exist`);
|
|
1536
|
+
}
|
|
1537
|
+
const status = statusRows[0].status;
|
|
1538
|
+
if (status === workflow_1.StatusString.PENDING || status === workflow_1.StatusString.ENQUEUED || status === workflow_1.StatusString.DELAYED) {
|
|
1539
|
+
throw new error_1.DBOSError(`Cannot rewind ${workflowID} (${status}): only a workflow in a terminal state can be rewound, so cancel it first`);
|
|
1540
|
+
}
|
|
1541
|
+
// Roll workflow_events back to the last value published before the cut, using
|
|
1542
|
+
// workflow_events_history as an undo log, then rewind that history too.
|
|
1543
|
+
// `publishedPastCut` is the correlated EXISTS both halves key off.
|
|
1544
|
+
const publishedPastCut = `EXISTS (
|
|
1545
|
+
SELECT 1 FROM "${schema}".workflow_events_history discarded
|
|
1546
|
+
WHERE discarded.workflow_uuid = $1 AND discarded.key = %KEY% AND discarded.function_id >= $2
|
|
1547
|
+
)`;
|
|
1548
|
+
// First unpublish everything the discarded run touched.
|
|
1549
|
+
await client.query(`DELETE FROM "${schema}".workflow_events
|
|
1550
|
+
WHERE workflow_uuid = $1 AND ${publishedPastCut.replace('%KEY%', '"' + schema + '".workflow_events.key')}`, [workflowID, startStep]);
|
|
1551
|
+
// Then restore those keys, if any, to the value published below the cut.
|
|
1552
|
+
await client.query(`INSERT INTO "${schema}".workflow_events (workflow_uuid, key, value, serialization)
|
|
1553
|
+
SELECT surviving.workflow_uuid, surviving.key, surviving.value, surviving.serialization
|
|
1554
|
+
FROM (
|
|
1555
|
+
SELECT weh.workflow_uuid, weh.key, weh.value, weh.serialization,
|
|
1556
|
+
ROW_NUMBER() OVER (PARTITION BY weh.key ORDER BY weh.function_id DESC) AS rn
|
|
1557
|
+
FROM "${schema}".workflow_events_history weh
|
|
1558
|
+
WHERE weh.workflow_uuid = $1 AND weh.function_id < $2
|
|
1559
|
+
AND ${publishedPastCut.replace('%KEY%', 'weh.key')}
|
|
1560
|
+
) surviving
|
|
1561
|
+
WHERE surviving.rn = 1`, [workflowID, startStep]);
|
|
1562
|
+
// Clear the streams' close sentinels so the replay can append again.
|
|
1563
|
+
await client.query(`DELETE FROM "${schema}".streams
|
|
1564
|
+
WHERE workflow_uuid = $1 AND function_id >= $2 AND value = $3 AND serialization = $4`, [workflowID, startStep, exports.DBOS_STREAM_CLOSED_SENTINEL_SERIALIZED, serialization_1.DBOSPortableJSON.name()]);
|
|
1565
|
+
// Discard the steps and the events history past the cut.
|
|
1566
|
+
for (const table of ['operation_outputs', 'workflow_events_history']) {
|
|
1567
|
+
await client.query(`DELETE FROM "${schema}".${table} WHERE workflow_uuid = $1 AND function_id >= $2`, [
|
|
1568
|
+
workflowID,
|
|
1569
|
+
startStep,
|
|
1570
|
+
]);
|
|
1571
|
+
}
|
|
1572
|
+
// Delete messages consumed or received after the rewind point
|
|
1573
|
+
await client.query(`DELETE FROM "${schema}".notifications WHERE destination_uuid = $1 AND (consumed_by_function_id >= $2 OR consumed = false)`, [workflowID, startStep]);
|
|
1574
|
+
await client.query(`DELETE FROM "${schema}".workflow_output WHERE workflow_uuid = $1`, [workflowID]);
|
|
1575
|
+
// Re-enqueue the workflow. Re-asserting the status we read keeps a workflow that
|
|
1576
|
+
// moved on underneath us from being resurrected.
|
|
1577
|
+
const setVersion = options.applicationVersion !== undefined ? ', application_version = $6' : '';
|
|
1578
|
+
const params = [
|
|
1579
|
+
workflow_1.StatusString.ENQUEUED,
|
|
1580
|
+
options.queueName ?? utils_1.INTERNAL_QUEUE_NAME,
|
|
1581
|
+
options.queuePartitionKey ?? null,
|
|
1582
|
+
workflowID,
|
|
1583
|
+
status,
|
|
1584
|
+
];
|
|
1585
|
+
if (options.applicationVersion !== undefined) {
|
|
1586
|
+
params.push(options.applicationVersion);
|
|
1587
|
+
}
|
|
1588
|
+
const { rowCount } = await client.query(`UPDATE "${schema}".workflow_status
|
|
1589
|
+
SET status = $1, queue_name = $2, queue_partition_key = $3, recovery_attempts = 0,
|
|
1590
|
+
workflow_deadline_epoch_ms = NULL, deduplication_id = NULL, started_at_epoch_ms = NULL,
|
|
1591
|
+
completed_at = NULL, output = NULL, error = NULL,
|
|
1592
|
+
updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint${setVersion}
|
|
1593
|
+
WHERE workflow_uuid = $4 AND status = $5`, params);
|
|
1594
|
+
if (rowCount !== 1) {
|
|
1595
|
+
throw new error_1.DBOSError(`Workflow ${workflowID} changed status while being rewound; retry the rewind`);
|
|
1596
|
+
}
|
|
1597
|
+
await client.query('COMMIT');
|
|
1598
|
+
}
|
|
1599
|
+
catch (e) {
|
|
1600
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
1601
|
+
throw e;
|
|
1602
|
+
}
|
|
1603
|
+
finally {
|
|
1604
|
+
client.release();
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1496
1607
|
async forkWorkflow(workflowID, startStep, options = {}) {
|
|
1497
1608
|
const newWorkflowID = options.newWorkflowID ?? (0, crypto_1.randomUUID)();
|
|
1498
1609
|
const result = await this.bulkForkWorkflows([workflowID], [newWorkflowID], [startStep], options);
|
|
@@ -1990,6 +2101,74 @@ class SystemDatabase {
|
|
|
1990
2101
|
async checkIfCanceledLimited(workflowID) {
|
|
1991
2102
|
await this.#pollWithLimiter(() => this.#checkIfCanceled(this.pool, workflowID));
|
|
1992
2103
|
}
|
|
2104
|
+
watchForCancellation(workflowID) {
|
|
2105
|
+
const controller = new AbortController();
|
|
2106
|
+
let state = 'idle';
|
|
2107
|
+
const start = () => {
|
|
2108
|
+
state = 'watching';
|
|
2109
|
+
if (this.#destroyed)
|
|
2110
|
+
return;
|
|
2111
|
+
let watchers = this.#cancelWatchers.get(workflowID);
|
|
2112
|
+
if (watchers === undefined) {
|
|
2113
|
+
watchers = new Set();
|
|
2114
|
+
this.#cancelWatchers.set(workflowID, watchers);
|
|
2115
|
+
}
|
|
2116
|
+
watchers.add(controller);
|
|
2117
|
+
this.#cancelPollerLoop ??= this.#cancelPollerScope.runInAsyncScope(() => this.#runCancelPoller());
|
|
2118
|
+
};
|
|
2119
|
+
const stop = () => {
|
|
2120
|
+
const watchers = this.#cancelWatchers.get(workflowID);
|
|
2121
|
+
if (watchers?.delete(controller) && watchers.size === 0) {
|
|
2122
|
+
this.#cancelWatchers.delete(workflowID);
|
|
2123
|
+
if (this.#cancelWatchers.size === 0)
|
|
2124
|
+
this.#cancelPollerWake?.();
|
|
2125
|
+
}
|
|
2126
|
+
};
|
|
2127
|
+
return {
|
|
2128
|
+
get signal() {
|
|
2129
|
+
if (state === 'idle')
|
|
2130
|
+
start();
|
|
2131
|
+
return controller.signal;
|
|
2132
|
+
},
|
|
2133
|
+
get cancelled() {
|
|
2134
|
+
return controller.signal.aborted;
|
|
2135
|
+
},
|
|
2136
|
+
release: () => {
|
|
2137
|
+
if (state === 'watching')
|
|
2138
|
+
stop();
|
|
2139
|
+
state = 'released';
|
|
2140
|
+
},
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
async #runCancelPoller() {
|
|
2144
|
+
while (this.#cancelWatchers.size > 0 && !this.#destroyed) {
|
|
2145
|
+
const { promise, cancel } = (0, utils_1.cancellableSleep)(this.dbPollingIntervalCancelMs);
|
|
2146
|
+
this.#cancelPollerWake = cancel;
|
|
2147
|
+
await promise;
|
|
2148
|
+
this.#cancelPollerWake = null;
|
|
2149
|
+
if (this.#cancelWatchers.size === 0 || this.#destroyed)
|
|
2150
|
+
break;
|
|
2151
|
+
try {
|
|
2152
|
+
await this.#abortCancelledWatchers();
|
|
2153
|
+
}
|
|
2154
|
+
catch (e) {
|
|
2155
|
+
this.logger.debug(`Workflow cancellation poll failed: ${String(e)}`);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
this.#cancelPollerLoop = undefined;
|
|
2159
|
+
}
|
|
2160
|
+
async #abortCancelledWatchers() {
|
|
2161
|
+
const { rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT workflow_uuid FROM "${this.schemaName}".workflow_status WHERE workflow_uuid = ANY($1) AND status = $2`, [Array.from(this.#cancelWatchers.keys()), workflow_1.StatusString.CANCELLED]));
|
|
2162
|
+
for (const { workflow_uuid: workflowID } of rows) {
|
|
2163
|
+
const watchers = this.#cancelWatchers.get(workflowID);
|
|
2164
|
+
if (watchers === undefined)
|
|
2165
|
+
continue;
|
|
2166
|
+
this.#cancelWatchers.delete(workflowID);
|
|
2167
|
+
const reason = new error_1.DBOSWorkflowCancelledError(workflowID);
|
|
2168
|
+
for (const controller of watchers)
|
|
2169
|
+
controller.abort(reason);
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
1993
2172
|
// A missing row normally means the workflow has not been inserted yet, so
|
|
1994
2173
|
// polling for it is correct. Callers that know the row must already exist
|
|
1995
2174
|
// (e.g. a run parking on an outcome it just failed to write) pass
|
|
@@ -2208,7 +2387,7 @@ class SystemDatabase {
|
|
|
2208
2387
|
try {
|
|
2209
2388
|
await client.query(`BEGIN ISOLATION LEVEL READ COMMITTED`);
|
|
2210
2389
|
const finalRecvRows = (await client.query(`UPDATE "${this.schemaName}".notifications
|
|
2211
|
-
SET consumed = true
|
|
2390
|
+
SET consumed = true, consumed_by_function_id = $3
|
|
2212
2391
|
WHERE destination_uuid = $1
|
|
2213
2392
|
AND topic = $2
|
|
2214
2393
|
AND consumed = false
|
|
@@ -2221,7 +2400,7 @@ class SystemDatabase {
|
|
|
2221
2400
|
ORDER BY created_at_epoch_ms ASC
|
|
2222
2401
|
LIMIT 1
|
|
2223
2402
|
)
|
|
2224
|
-
RETURNING notifications.message, notifications.serialization;`, [workflowID, topic])).rows;
|
|
2403
|
+
RETURNING notifications.message, notifications.serialization;`, [workflowID, topic, functionID])).rows;
|
|
2225
2404
|
if (finalRecvRows.length > 0) {
|
|
2226
2405
|
message = finalRecvRows[0].message;
|
|
2227
2406
|
serialization = finalRecvRows[0].serialization;
|
|
@@ -4691,6 +4870,12 @@ __decorate([
|
|
|
4691
4870
|
__metadata("design:paramtypes", [Object]),
|
|
4692
4871
|
__metadata("design:returntype", Promise)
|
|
4693
4872
|
], SystemDatabase.prototype, "debounceDelayedWorkflowStandalone", null);
|
|
4873
|
+
__decorate([
|
|
4874
|
+
dbRetry(),
|
|
4875
|
+
__metadata("design:type", Function),
|
|
4876
|
+
__metadata("design:paramtypes", [String, Number, Object]),
|
|
4877
|
+
__metadata("design:returntype", Promise)
|
|
4878
|
+
], SystemDatabase.prototype, "rewindWorkflow", null);
|
|
4694
4879
|
__decorate([
|
|
4695
4880
|
dbRetry()
|
|
4696
4881
|
// A missing row normally means the workflow has not been inserted yet, so
|