@dbos-inc/dbos-sdk 4.27.4-preview → 4.27.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.
@@ -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();
@@ -1722,7 +1740,8 @@ class SystemDatabase {
1722
1740
  * under the polling limiter so it counts against the same concurrency budget
1723
1741
  * as the rest of the loop's reads.
1724
1742
  */
1725
- async #checkIfCanceledLimited(workflowID) {
1743
+ /** Cancellation check for polling waits: goes through the limiter so readers cannot starve the pool. */
1744
+ async checkIfCanceledLimited(workflowID) {
1726
1745
  await this.#pollWithLimiter(() => this.#checkIfCanceled(this.pool, workflowID));
1727
1746
  }
1728
1747
  // A missing row normally means the workflow has not been inserted yet, so
@@ -1741,7 +1760,7 @@ class SystemDatabase {
1741
1760
  }
1742
1761
  while (true) {
1743
1762
  if (callerID)
1744
- await this.#checkIfCanceledLimited(callerID);
1763
+ await this.checkIfCanceledLimited(callerID);
1745
1764
  let rows;
1746
1765
  try {
1747
1766
  ({ rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT status, output, error, serialization FROM "${this.schemaName}".workflow_status
@@ -1786,7 +1805,7 @@ class SystemDatabase {
1786
1805
  const pollIntervalMs = pollingIntervalMs ?? this.dbPollingIntervalResultMs;
1787
1806
  while (true) {
1788
1807
  if (callerID)
1789
- await this.#checkIfCanceledLimited(callerID);
1808
+ await this.checkIfCanceledLimited(callerID);
1790
1809
  const { rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT workflow_uuid FROM "${this.schemaName}".workflow_status
1791
1810
  WHERE workflow_uuid IN (${placeholders})
1792
1811
  AND status NOT IN ('${workflow_1.StatusString.PENDING}', '${workflow_1.StatusString.ENQUEUED}', '${workflow_1.StatusString.DELAYED}')
@@ -1803,7 +1822,7 @@ class SystemDatabase {
1803
1822
  while (remainingWorkflowIds.size > 0) {
1804
1823
  const currentWorkflowIds = [...remainingWorkflowIds];
1805
1824
  if (callerID)
1806
- await this.#checkIfCanceledLimited(callerID);
1825
+ await this.checkIfCanceledLimited(callerID);
1807
1826
  const { rows } = await this.#pollWithLimiter(() => this.pool.query(`SELECT workflow_uuid FROM "${this.schemaName}".workflow_status
1808
1827
  WHERE workflow_uuid = ANY($1::text[])
1809
1828
  AND status NOT IN ('${workflow_1.StatusString.PENDING}', '${workflow_1.StatusString.ENQUEUED}', '${workflow_1.StatusString.DELAYED}')`, [currentWorkflowIds]));
@@ -1910,7 +1929,7 @@ class SystemDatabase {
1910
1929
  const payload = `${workflowID}::${topic}`;
1911
1930
  const cbr = this.notificationsMap.registerCallback(payload, resolveNotification);
1912
1931
  try {
1913
- await this.#checkIfCanceledLimited(workflowID);
1932
+ await this.checkIfCanceledLimited(workflowID);
1914
1933
  // Check if the key is already in the DB, then wait for the notification if it isn't.
1915
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;
1916
1935
  if (initRecvRows.length !== 0)
@@ -2045,7 +2064,7 @@ class SystemDatabase {
2045
2064
  const cbr = this.workflowEventsMap.registerCallback(payloadKey, resolveNotification);
2046
2065
  try {
2047
2066
  if (callerWorkflow?.workflowID)
2048
- await this.#checkIfCanceledLimited(callerWorkflow?.workflowID);
2067
+ await this.checkIfCanceledLimited(callerWorkflow?.workflowID);
2049
2068
  // Check if the key is already in the DB, then wait for the notification if it isn't.
2050
2069
  const initRecvRows = (await this.#pollWithLimiter(() => this.pool.query(`SELECT key, value, serialization
2051
2070
  FROM "${this.schemaName}".workflow_events
@@ -2124,67 +2143,79 @@ class SystemDatabase {
2124
2143
  }
2125
2144
  // ==================== Streams ====================
2126
2145
  async writeStreamFromStep(workflowID, functionID, key, serializedValue, serialization) {
2127
- const client = await this.#connect();
2128
- try {
2129
- await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
2130
- // Find the maximum offset for this workflow_uuid and key combination
2131
- const maxOffsetResult = await client.query(`SELECT MAX("offset") FROM "${this.schemaName}".streams
2132
- WHERE workflow_uuid = $1 AND key = $2`, [workflowID, key]);
2133
- // Next offset is max + 1, or 0 if no records exist
2134
- const maxOffset = maxOffsetResult.rows[0].max;
2135
- const nextOffset = maxOffset !== null ? maxOffset + 1 : 0;
2136
- // Insert the new stream entry
2137
- await client.query(`INSERT INTO "${this.schemaName}".streams (workflow_uuid, key, value, "offset", function_id, serialization)
2138
- VALUES ($1, $2, $3, $4, $5, $6)`, [workflowID, key, serializedValue, nextOffset, functionID, serialization]);
2139
- 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
+ }
2140
2164
  // Notify only after commit, so a woken reader sees the value.
2141
2165
  this.#signalNotification(exports.DBOS_STREAMS_CHANNEL, `${workflowID}::${key}`);
2142
- }
2143
- catch (e) {
2144
- this.logger.error(e);
2145
- await client.query('ROLLBACK');
2146
- throw e;
2147
- }
2148
- finally {
2149
- client.release();
2166
+ return;
2150
2167
  }
2151
2168
  }
2152
2169
  async writeStreamFromWorkflow(workflowID, functionID, key, serializedValue, serialization, functionName) {
2153
2170
  const client = await this.#connect();
2154
2171
  try {
2155
- await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
2156
- // Only a real insert (not a replay) should wake readers.
2157
- let didWrite = false;
2158
- await this.#runAndRecordResult(client, functionName, workflowID, functionID, async () => {
2159
- // Find the maximum offset for this workflow_uuid and key combination
2160
- const maxOffsetResult = await client.query(`SELECT MAX("offset") FROM "${this.schemaName}".streams
2161
- WHERE workflow_uuid = $1 AND key = $2`, [workflowID, key]);
2162
- // Next offset is max + 1, or 0 if no records exist
2163
- const maxOffset = maxOffsetResult.rows[0].max;
2164
- const nextOffset = maxOffset !== null ? maxOffset + 1 : 0;
2165
- // Insert the new stream entry
2166
- await client.query(`INSERT INTO "${this.schemaName}".streams (workflow_uuid, key, value, "offset", function_id, serialization)
2167
- VALUES ($1, $2, $3, $4, $5, $6)`, [workflowID, key, serializedValue, nextOffset, functionID, serialization]);
2168
- didWrite = true;
2169
- return undefined;
2170
- });
2171
- await client.query('COMMIT');
2172
- // Notify only after commit, so a woken reader sees the value.
2173
- if (didWrite) {
2174
- 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;
2175
2208
  }
2176
2209
  }
2177
- catch (e) {
2178
- this.logger.error(e);
2179
- await client.query('ROLLBACK');
2180
- throw e;
2181
- }
2182
2210
  finally {
2183
2211
  client.release();
2184
2212
  }
2185
2213
  }
2186
- async closeStream(workflowID, functionID, key) {
2187
- 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());
2188
2219
  }
2189
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.
2190
2221
  async readStreamValue(workflowID, key, offset) {
@@ -2325,15 +2356,20 @@ class SystemDatabase {
2325
2356
  WHERE workflow_uuid = $1
2326
2357
  ORDER BY key, "offset"`, [workflowID]);
2327
2358
  const streams = {};
2359
+ const closed = new Set();
2328
2360
  for (const row of result.rows) {
2329
- const value = await (0, serialization_1.safeParse)(this.serializer, row.value, row.serialization);
2330
- if (value === exports.DBOS_STREAM_CLOSED_SENTINEL) {
2361
+ if (closed.has(row.key)) {
2331
2362
  continue;
2332
2363
  }
2333
- if (!streams[row.key]) {
2334
- 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;
2335
2371
  }
2336
- streams[row.key].push(value);
2372
+ (streams[row.key] ??= []).push(value);
2337
2373
  }
2338
2374
  return streams;
2339
2375
  }