@dbos-inc/dbos-sdk 4.27.3-preview → 4.27.5-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.
@@ -9,7 +9,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.SystemDatabase = exports.verifySystemDatabase = exports.ensureSystemDatabase = exports.grantDbosSchemaPermissions = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_RENAME_BATCH_SIZE = exports.DEFAULT_NOTIFICATION_COALESCE_MS = exports.DBOS_STREAMS_CHANNEL = exports.DBOS_WORKFLOW_EVENTS_CHANNEL = exports.DBOS_NOTIFICATIONS_CHANNEL = exports.DBOS_STREAM_CLOSED_SENTINEL = exports.DEFAULT_POOL_SIZE = exports.DBOS_FUNCNAME_CLOSESTREAM = exports.DBOS_FUNCNAME_WRITESTREAM = exports.DBOS_FUNCNAME_GETSTATUS = exports.DBOS_FUNCNAME_SLEEP = exports.DBOS_FUNCNAME_GETEVENT = exports.DBOS_FUNCNAME_SETEVENT = exports.DBOS_FUNCNAME_RECV = exports.DBOS_FUNCNAME_SEND = void 0;
12
+ exports.SystemDatabase = exports.verifySystemDatabase = exports.ensureSystemDatabase = exports.grantDbosSchemaPermissions = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_RENAME_BATCH_SIZE = exports.DEFAULT_NOTIFICATION_COALESCE_MS = exports.DBOS_STREAMS_CHANNEL = exports.DBOS_WORKFLOW_EVENTS_CHANNEL = exports.DBOS_NOTIFICATIONS_CHANNEL = exports.isLegacyClosedSentinel = exports.isStreamClosedSentinel = exports.DBOS_STREAM_CLOSED_SENTINEL_SERIALIZED = exports.DBOS_STREAM_CLOSED_SENTINEL = exports.DEFAULT_POOL_SIZE = exports.DBOS_FUNCNAME_READSTREAMOFFSET = exports.DBOS_FUNCNAME_READSTREAM = exports.DBOS_FUNCNAME_CLOSESTREAM = exports.DBOS_FUNCNAME_WRITESTREAM = exports.DBOS_FUNCNAME_GETSTATUS = exports.DBOS_FUNCNAME_SLEEP = exports.DBOS_FUNCNAME_GETEVENT = exports.DBOS_FUNCNAME_SETEVENT = exports.DBOS_FUNCNAME_RECV = exports.DBOS_FUNCNAME_SEND = void 0;
13
13
  const dbos_executor_1 = require("./dbos-executor");
14
14
  const pg_1 = require("pg");
15
15
  const error_1 = require("./error");
@@ -31,8 +31,25 @@ exports.DBOS_FUNCNAME_SLEEP = 'DBOS.sleep';
31
31
  exports.DBOS_FUNCNAME_GETSTATUS = 'getStatus';
32
32
  exports.DBOS_FUNCNAME_WRITESTREAM = 'DBOS.writeStream';
33
33
  exports.DBOS_FUNCNAME_CLOSESTREAM = 'DBOS.closeStream';
34
+ exports.DBOS_FUNCNAME_READSTREAM = 'DBOS.readStream';
35
+ exports.DBOS_FUNCNAME_READSTREAMOFFSET = 'DBOS.readStreamOffset';
34
36
  exports.DEFAULT_POOL_SIZE = 10;
35
37
  exports.DBOS_STREAM_CLOSED_SENTINEL = '__DBOS_STREAM_CLOSED__';
38
+ // The sentinel as it is stored: portable JSON, the same bytes every language writes and reads.
39
+ exports.DBOS_STREAM_CLOSED_SENTINEL_SERIALIZED = serialization_1.DBOSPortableJSON.stringify(exports.DBOS_STREAM_CLOSED_SENTINEL);
40
+ /** Whether a stream value is the marker a closed stream ends with. Takes the deserialized value. */
41
+ function isStreamClosedSentinel(value) {
42
+ return typeof value === 'string' && value === exports.DBOS_STREAM_CLOSED_SENTINEL;
43
+ }
44
+ exports.isStreamClosedSentinel = isStreamClosedSentinel;
45
+ /**
46
+ * Whether a stored value is the marker as releases before the portable form wrote it: unserialized,
47
+ * so no deserializer parses it. Callers must test this before deserializing.
48
+ */
49
+ function isLegacyClosedSentinel(serializedValue) {
50
+ return serializedValue === exports.DBOS_STREAM_CLOSED_SENTINEL;
51
+ }
52
+ exports.isLegacyClosedSentinel = isLegacyClosedSentinel;
36
53
  // LISTEN/NOTIFY channels. Streams and workflow_events are pushed by the notifier loop off the write path; notifications fires from an in-transaction DB trigger so recv is never woken before its row commits.
37
54
  exports.DBOS_NOTIFICATIONS_CHANNEL = 'dbos_notifications_channel';
38
55
  exports.DBOS_WORKFLOW_EVENTS_CHANNEL = 'dbos_workflow_events_channel';
@@ -474,6 +491,7 @@ class SystemDatabase {
474
491
  notificationsClient = null;
475
492
  dbPollingIntervalResultMs = 1000;
476
493
  dbPollingIntervalEventMs = 10000;
494
+ dbPollingIntervalStreamMs = 1000;
477
495
  shouldUseDBNotifications = true;
478
496
  notificationsMap = new NotificationMap();
479
497
  workflowEventsMap = new NotificationMap();
@@ -1629,7 +1647,8 @@ class SystemDatabase {
1629
1647
  // Need to await for the workflow and capture errors.
1630
1648
  const awaitWorkflowPromise = workflowPromise
1631
1649
  .catch((error) => {
1632
- this.logger.debug('Captured error in awaitWorkflowPromise: ' + error);
1650
+ const outcome = this.#destroyed ? 'was abandoned by shutdown' : 'failed';
1651
+ this.logger.debug(`Workflow ${workflowID} ${outcome}: ${error}`);
1633
1652
  })
1634
1653
  .finally(() => {
1635
1654
  onSettled();
@@ -1664,10 +1683,35 @@ class SystemDatabase {
1664
1683
  }
1665
1684
  return count;
1666
1685
  }
1667
- async awaitRunningWorkflows() {
1686
+ /** Wait up to `timeoutMS` for locally-running workflows to finish. Without a timeout, do not wait at all. */
1687
+ async awaitRunningWorkflows(timeoutMS) {
1688
+ if (timeoutMS !== undefined && timeoutMS > 0) {
1689
+ const deadline = Date.now() + timeoutMS;
1690
+ if (this.runningWorkflowMap.size > 0) {
1691
+ this.logger.info('Waiting for pending workflows to finish.');
1692
+ }
1693
+ // Each pass picks up workflows a draining workflow started, and awaits any given run only once.
1694
+ const awaited = new Set();
1695
+ for (;;) {
1696
+ const pending = Array.from(this.runningWorkflowMap.values(), (entry) => entry.promise).filter((promise) => !awaited.has(promise));
1697
+ if (pending.length === 0)
1698
+ break;
1699
+ for (const promise of pending)
1700
+ awaited.add(promise);
1701
+ let timer;
1702
+ const timedOut = await Promise.race([
1703
+ Promise.allSettled(pending).then(() => false),
1704
+ new Promise((resolve) => {
1705
+ timer = setTimeout(() => resolve(true), Math.max(0, deadline - Date.now()));
1706
+ }),
1707
+ ]);
1708
+ clearTimeout(timer);
1709
+ if (timedOut)
1710
+ break;
1711
+ }
1712
+ }
1668
1713
  if (this.runningWorkflowMap.size > 0) {
1669
- this.logger.info('Waiting for pending workflows to finish.');
1670
- await Promise.allSettled(Array.from(this.runningWorkflowMap.values(), (entry) => entry.promise));
1714
+ this.logger.warn(`Shutting down while ${this.runningWorkflowMap.size} workflows are still running: ${Array.from(this.runningWorkflowMap.keys()).join(', ')}`);
1671
1715
  }
1672
1716
  if (this.workflowEventsMap.map.size > 0) {
1673
1717
  this.logger.warn('Workflow events map is not empty - shutdown is not clean.');
@@ -1696,7 +1740,8 @@ class SystemDatabase {
1696
1740
  * under the polling limiter so it counts against the same concurrency budget
1697
1741
  * as the rest of the loop's reads.
1698
1742
  */
1699
- async #checkIfCanceledLimited(workflowID) {
1743
+ /** Cancellation check for polling waits: goes through the limiter so readers cannot starve the pool. */
1744
+ async checkIfCanceledLimited(workflowID) {
1700
1745
  await this.#pollWithLimiter(() => this.#checkIfCanceled(this.pool, workflowID));
1701
1746
  }
1702
1747
  // A missing row normally means the workflow has not been inserted yet, so
@@ -1715,7 +1760,7 @@ class SystemDatabase {
1715
1760
  }
1716
1761
  while (true) {
1717
1762
  if (callerID)
1718
- await this.#checkIfCanceledLimited(callerID);
1763
+ await this.checkIfCanceledLimited(callerID);
1719
1764
  let rows;
1720
1765
  try {
1721
1766
  ({ rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT status, output, error, serialization FROM "${this.schemaName}".workflow_status
@@ -1760,7 +1805,7 @@ class SystemDatabase {
1760
1805
  const pollIntervalMs = pollingIntervalMs ?? this.dbPollingIntervalResultMs;
1761
1806
  while (true) {
1762
1807
  if (callerID)
1763
- await this.#checkIfCanceledLimited(callerID);
1808
+ await this.checkIfCanceledLimited(callerID);
1764
1809
  const { rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT workflow_uuid FROM "${this.schemaName}".workflow_status
1765
1810
  WHERE workflow_uuid IN (${placeholders})
1766
1811
  AND status NOT IN ('${workflow_1.StatusString.PENDING}', '${workflow_1.StatusString.ENQUEUED}', '${workflow_1.StatusString.DELAYED}')
@@ -1777,7 +1822,7 @@ class SystemDatabase {
1777
1822
  while (remainingWorkflowIds.size > 0) {
1778
1823
  const currentWorkflowIds = [...remainingWorkflowIds];
1779
1824
  if (callerID)
1780
- await this.#checkIfCanceledLimited(callerID);
1825
+ await this.checkIfCanceledLimited(callerID);
1781
1826
  const { rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT workflow_uuid FROM "${this.schemaName}".workflow_status
1782
1827
  WHERE workflow_uuid = ANY($1::text[])
1783
1828
  AND status NOT IN ('${workflow_1.StatusString.PENDING}', '${workflow_1.StatusString.ENQUEUED}', '${workflow_1.StatusString.DELAYED}')`, [currentWorkflowIds]));
@@ -1884,7 +1929,7 @@ class SystemDatabase {
1884
1929
  const payload = `${workflowID}::${topic}`;
1885
1930
  const cbr = this.notificationsMap.registerCallback(payload, resolveNotification);
1886
1931
  try {
1887
- await this.#checkIfCanceledLimited(workflowID);
1932
+ await this.checkIfCanceledLimited(workflowID);
1888
1933
  // Check if the key is already in the DB, then wait for the notification if it isn't.
1889
1934
  const initRecvRows = (await this.#pollWithLimiter(() => this.pool.query(`SELECT topic FROM "${this.schemaName}".notifications WHERE destination_uuid=$1 AND topic=$2 AND consumed = false;`, [workflowID, topic]))).rows;
1890
1935
  if (initRecvRows.length !== 0)
@@ -2019,7 +2064,7 @@ class SystemDatabase {
2019
2064
  const cbr = this.workflowEventsMap.registerCallback(payloadKey, resolveNotification);
2020
2065
  try {
2021
2066
  if (callerWorkflow?.workflowID)
2022
- await this.#checkIfCanceledLimited(callerWorkflow?.workflowID);
2067
+ await this.checkIfCanceledLimited(callerWorkflow?.workflowID);
2023
2068
  // Check if the key is already in the DB, then wait for the notification if it isn't.
2024
2069
  const initRecvRows = (await this.#pollWithLimiter(() => this.pool.query(`SELECT key, value, serialization
2025
2070
  FROM "${this.schemaName}".workflow_events
@@ -2098,67 +2143,79 @@ class SystemDatabase {
2098
2143
  }
2099
2144
  // ==================== Streams ====================
2100
2145
  async writeStreamFromStep(workflowID, functionID, key, serializedValue, serialization) {
2101
- const client = await this.#connect();
2102
- try {
2103
- await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
2104
- // Find the maximum offset for this workflow_uuid and key combination
2105
- const maxOffsetResult = await client.query(`SELECT MAX("offset") FROM "${this.schemaName}".streams
2106
- WHERE workflow_uuid = $1 AND key = $2`, [workflowID, key]);
2107
- // Next offset is max + 1, or 0 if no records exist
2108
- const maxOffset = maxOffsetResult.rows[0].max;
2109
- const nextOffset = maxOffset !== null ? maxOffset + 1 : 0;
2110
- // Insert the new stream entry
2111
- await client.query(`INSERT INTO "${this.schemaName}".streams (workflow_uuid, key, value, "offset", function_id, serialization)
2112
- VALUES ($1, $2, $3, $4, $5, $6)`, [workflowID, key, serializedValue, nextOffset, functionID, serialization]);
2113
- await client.query('COMMIT');
2146
+ while (true) {
2147
+ try {
2148
+ // Derives the first unused offset inside the insert; two writers can still pick the same one.
2149
+ await this.pool.query(`INSERT INTO "${this.schemaName}".streams (workflow_uuid, key, value, "offset", function_id, serialization)
2150
+ SELECT $1::text, $2::text, $3::text, COALESCE(MAX(s."offset"), -1) + 1, $4::int, $5::text
2151
+ FROM "${this.schemaName}".streams s
2152
+ WHERE s.workflow_uuid = $1 AND s.key = $2`, [workflowID, key, serializedValue, functionID, serialization]);
2153
+ }
2154
+ catch (e) {
2155
+ // Only an offset conflict resolves on retry; anything else would spin forever.
2156
+ if (e instanceof pg_1.DatabaseError && e.code === '23505') {
2157
+ this.logger.warn(`Stream offset conflict for workflow ${workflowID}, key ${key}; retrying`);
2158
+ await (0, utils_1.sleepms)(100);
2159
+ continue;
2160
+ }
2161
+ this.logger.error(e);
2162
+ throw e;
2163
+ }
2114
2164
  // Notify only after commit, so a woken reader sees the value.
2115
2165
  this.#signalNotification(exports.DBOS_STREAMS_CHANNEL, `${workflowID}::${key}`);
2116
- }
2117
- catch (e) {
2118
- this.logger.error(e);
2119
- await client.query('ROLLBACK');
2120
- throw e;
2121
- }
2122
- finally {
2123
- client.release();
2166
+ return;
2124
2167
  }
2125
2168
  }
2126
2169
  async writeStreamFromWorkflow(workflowID, functionID, key, serializedValue, serialization, functionName) {
2127
2170
  const client = await this.#connect();
2128
2171
  try {
2129
- await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
2130
- // Only a real insert (not a replay) should wake readers.
2131
- let didWrite = false;
2132
- await this.#runAndRecordResult(client, functionName, workflowID, functionID, async () => {
2133
- // Find the maximum offset for this workflow_uuid and key combination
2134
- const maxOffsetResult = await client.query(`SELECT MAX("offset") FROM "${this.schemaName}".streams
2135
- WHERE workflow_uuid = $1 AND key = $2`, [workflowID, key]);
2136
- // Next offset is max + 1, or 0 if no records exist
2137
- const maxOffset = maxOffsetResult.rows[0].max;
2138
- const nextOffset = maxOffset !== null ? maxOffset + 1 : 0;
2139
- // Insert the new stream entry
2140
- await client.query(`INSERT INTO "${this.schemaName}".streams (workflow_uuid, key, value, "offset", function_id, serialization)
2141
- VALUES ($1, $2, $3, $4, $5, $6)`, [workflowID, key, serializedValue, nextOffset, functionID, serialization]);
2142
- didWrite = true;
2143
- return undefined;
2144
- });
2145
- await client.query('COMMIT');
2146
- // Notify only after commit, so a woken reader sees the value.
2147
- if (didWrite) {
2148
- this.#signalNotification(exports.DBOS_STREAMS_CHANNEL, `${workflowID}::${key}`);
2172
+ while (true) {
2173
+ // Only a real insert (not a replay) should wake readers.
2174
+ let didWrite = false;
2175
+ try {
2176
+ await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
2177
+ await this.#runAndRecordResult(client, functionName, workflowID, functionID, async () => {
2178
+ // Derives the first unused offset inside the insert; two writers can still pick the same one.
2179
+ await client.query(`INSERT INTO "${this.schemaName}".streams (workflow_uuid, key, value, "offset", function_id, serialization)
2180
+ SELECT $1::text, $2::text, $3::text, COALESCE(MAX(s."offset"), -1) + 1, $4::int, $5::text
2181
+ FROM "${this.schemaName}".streams s
2182
+ WHERE s.workflow_uuid = $1 AND s.key = $2`, [workflowID, key, serializedValue, functionID, serialization]);
2183
+ didWrite = true;
2184
+ return undefined;
2185
+ });
2186
+ await client.query('COMMIT');
2187
+ }
2188
+ catch (e) {
2189
+ // Only an offset conflict resolves on retry; anything else would spin forever.
2190
+ const offsetConflict = e instanceof pg_1.DatabaseError && e.code === '23505';
2191
+ // Log before touching the connection again: a failing ROLLBACK is what would propagate.
2192
+ if (!offsetConflict)
2193
+ this.logger.error(e);
2194
+ // Roll back before waiting, so a retry does not hold an aborted transaction open.
2195
+ await client.query('ROLLBACK');
2196
+ if (offsetConflict) {
2197
+ this.logger.warn(`Stream offset conflict for workflow ${workflowID}, key ${key}; retrying`);
2198
+ await (0, utils_1.sleepms)(100);
2199
+ continue;
2200
+ }
2201
+ throw e;
2202
+ }
2203
+ // Notify only after commit, so a woken reader sees the value.
2204
+ if (didWrite) {
2205
+ this.#signalNotification(exports.DBOS_STREAMS_CHANNEL, `${workflowID}::${key}`);
2206
+ }
2207
+ return;
2149
2208
  }
2150
2209
  }
2151
- catch (e) {
2152
- this.logger.error(e);
2153
- await client.query('ROLLBACK');
2154
- throw e;
2155
- }
2156
2210
  finally {
2157
2211
  client.release();
2158
2212
  }
2159
2213
  }
2160
- async closeStream(workflowID, functionID, key) {
2161
- await this.writeStreamFromWorkflow(workflowID, functionID, key, exports.DBOS_STREAM_CLOSED_SENTINEL, 'portable_json', exports.DBOS_FUNCNAME_CLOSESTREAM);
2214
+ async closeStreamFromWorkflow(workflowID, functionID, key) {
2215
+ await this.writeStreamFromWorkflow(workflowID, functionID, key, exports.DBOS_STREAM_CLOSED_SENTINEL_SERIALIZED, serialization_1.DBOSPortableJSON.name(), exports.DBOS_FUNCNAME_CLOSESTREAM);
2216
+ }
2217
+ async closeStreamFromStep(workflowID, stepID, key) {
2218
+ await this.writeStreamFromStep(workflowID, stepID, key, exports.DBOS_STREAM_CLOSED_SENTINEL_SERIALIZED, serialization_1.DBOSPortableJSON.name());
2162
2219
  }
2163
2220
  // Read the value at `offset` and the workflow's status in one query: status null = no such workflow, value undefined = nothing at that offset.
2164
2221
  async readStreamValue(workflowID, key, offset) {
@@ -2299,15 +2356,20 @@ class SystemDatabase {
2299
2356
  WHERE workflow_uuid = $1
2300
2357
  ORDER BY key, "offset"`, [workflowID]);
2301
2358
  const streams = {};
2359
+ const closed = new Set();
2302
2360
  for (const row of result.rows) {
2303
- const value = await (0, serialization_1.safeParse)(this.serializer, row.value, row.serialization);
2304
- if (value === exports.DBOS_STREAM_CLOSED_SENTINEL) {
2361
+ if (closed.has(row.key)) {
2305
2362
  continue;
2306
2363
  }
2307
- if (!streams[row.key]) {
2308
- streams[row.key] = [];
2364
+ // safeParse yields the raw string for the legacy unserialized marker, which does not parse.
2365
+ const value = await (0, serialization_1.safeParse)(this.serializer, row.value, row.serialization);
2366
+ if (isStreamClosedSentinel(value)) {
2367
+ // End the stream where readStream does, so the two never disagree.
2368
+ closed.add(row.key);
2369
+ streams[row.key] ??= [];
2370
+ continue;
2309
2371
  }
2310
- streams[row.key].push(value);
2372
+ (streams[row.key] ??= []).push(value);
2311
2373
  }
2312
2374
  return streams;
2313
2375
  }