@dbos-inc/dbos-sdk 4.24.13-preview → 4.24.14-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.ensureSystemDatabase = exports.grantDbosSchemaPermissions = 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.ensureSystemDatabase = exports.grantDbosSchemaPermissions = 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;
13
13
  const dbos_executor_1 = require("./dbos-executor");
14
14
  const pg_1 = require("pg");
15
15
  const error_1 = require("./error");
@@ -32,6 +32,12 @@ exports.DBOS_FUNCNAME_WRITESTREAM = 'DBOS.writeStream';
32
32
  exports.DBOS_FUNCNAME_CLOSESTREAM = 'DBOS.closeStream';
33
33
  exports.DEFAULT_POOL_SIZE = 10;
34
34
  exports.DBOS_STREAM_CLOSED_SENTINEL = '__DBOS_STREAM_CLOSED__';
35
+ // 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.
36
+ exports.DBOS_NOTIFICATIONS_CHANNEL = 'dbos_notifications_channel';
37
+ exports.DBOS_WORKFLOW_EVENTS_CHANNEL = 'dbos_workflow_events_channel';
38
+ exports.DBOS_STREAMS_CHANNEL = 'dbos_streams_channel';
39
+ // Interval for coalescing LISTEN/NOTIFY notifications off the write path; caps the rate of notifying commits regardless of write throughput.
40
+ exports.DEFAULT_NOTIFICATION_COALESCE_MS = 10;
35
41
  const QUEUE_COLUMN_BY_FIELD = {
36
42
  concurrency: 'concurrency',
37
43
  workerConcurrency: 'worker_concurrency',
@@ -390,6 +396,15 @@ class SystemDatabase {
390
396
  workflowEventsMap = new NotificationMap();
391
397
  streamsMap = new NotificationMap();
392
398
  customPool = false;
399
+ // Interval for coalescing LISTEN/NOTIFY notifications pushed off the write path (Postgres + L/N only).
400
+ notificationCoalesceMs = exports.DEFAULT_NOTIFICATION_COALESCE_MS;
401
+ // Coalesced NOTIFY payloads keyed by channel, flushed by the notifier loop; soft-private so tests can drive a flush.
402
+ pendingNotifications = new Map();
403
+ #notifierActive = false;
404
+ // Wakes the notifier out of its coalescing sleep so shutdown flushes promptly.
405
+ #notifierWake = null;
406
+ // The notifier loop's completion, awaited on destroy so a final flush precedes closing the pool.
407
+ #notifierLoop = undefined;
393
408
  /**
394
409
  * Caps how many DB-backed polling reads (from wait operations) may run
395
410
  * concurrently against the pool, so a polling storm cannot check out every
@@ -399,12 +414,13 @@ class SystemDatabase {
399
414
  runningWorkflowMap = new Map(); // Map from workflowID to workflow promise, queue name and partition key
400
415
  // Per-partition-key created_at cursors: keep per-key queue order monotonic across batches
401
416
  #batchCreatedAtCursors = new Map();
402
- constructor(systemDatabaseUrl, logger, serializer, sysDbPoolSize = exports.DEFAULT_POOL_SIZE, systemDatabasePool, schemaName = 'dbos', useListenNotify = true, pollingConcurrency) {
417
+ constructor(systemDatabaseUrl, logger, serializer, sysDbPoolSize = exports.DEFAULT_POOL_SIZE, systemDatabasePool, schemaName = 'dbos', useListenNotify = true, pollingConcurrency, notificationCoalesceMs = exports.DEFAULT_NOTIFICATION_COALESCE_MS) {
403
418
  this.systemDatabaseUrl = systemDatabaseUrl;
404
419
  this.logger = logger;
405
420
  this.serializer = serializer;
406
421
  this.schemaName = schemaName;
407
422
  this.shouldUseDBNotifications = useListenNotify;
423
+ this.notificationCoalesceMs = notificationCoalesceMs;
408
424
  if (systemDatabasePool) {
409
425
  this.pool = systemDatabasePool;
410
426
  this.customPool = true;
@@ -439,12 +455,22 @@ class SystemDatabase {
439
455
  await ensureSystemDatabase(this.systemDatabaseUrl, this.logger, this.customPool ? this.pool : undefined, this.schemaName, this.shouldUseDBNotifications);
440
456
  if (this.shouldUseDBNotifications) {
441
457
  await this.#listenForNotifications();
458
+ // Push coalesced stream and event notifications off the write path.
459
+ this.#notifierActive = true;
460
+ this.#notifierLoop = this.#runNotifier();
442
461
  }
443
462
  }
444
463
  async destroy() {
445
464
  if (this.reconnectTimeout) {
446
465
  clearTimeout(this.reconnectTimeout);
447
466
  }
467
+ // Stop the notifier and await its final flush before the pool closes.
468
+ this.#notifierActive = false;
469
+ this.#notifierWake?.();
470
+ if (this.#notifierLoop) {
471
+ await this.#notifierLoop;
472
+ this.#notifierLoop = undefined;
473
+ }
448
474
  if (this.notificationsClient) {
449
475
  try {
450
476
  this.notificationsClient.release(true);
@@ -1698,6 +1724,8 @@ class SystemDatabase {
1698
1724
  const client = await this.pool.connect();
1699
1725
  try {
1700
1726
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1727
+ // Only a real write (not a replay) should wake readers.
1728
+ let didWrite = false;
1701
1729
  await this.#runAndRecordResult(client, exports.DBOS_FUNCNAME_SETEVENT, workflowID, functionID, async () => {
1702
1730
  await client.query(`INSERT INTO "${this.schemaName}".workflow_events (workflow_uuid, key, value, serialization)
1703
1731
  VALUES ($1, $2, $3, $4)
@@ -1709,9 +1737,14 @@ class SystemDatabase {
1709
1737
  VALUES ($1, $2, $3, $4, $5)
1710
1738
  ON CONFLICT (workflow_uuid, function_id, key)
1711
1739
  DO UPDATE SET value = $4, serialization = $5;`, [workflowID, functionID, key, message, serialization]);
1740
+ didWrite = true;
1712
1741
  return undefined;
1713
1742
  });
1714
1743
  await client.query('COMMIT');
1744
+ // Notify only after commit, so a woken getEvent sees the value.
1745
+ if (didWrite) {
1746
+ this.#signalNotification(exports.DBOS_WORKFLOW_EVENTS_CHANNEL, `${workflowID}::${key}`);
1747
+ }
1715
1748
  }
1716
1749
  catch (e) {
1717
1750
  this.logger.error(e);
@@ -1849,6 +1882,8 @@ class SystemDatabase {
1849
1882
  await client.query(`INSERT INTO "${this.schemaName}".streams (workflow_uuid, key, value, "offset", function_id, serialization)
1850
1883
  VALUES ($1, $2, $3, $4, $5, $6)`, [workflowID, key, serializedValue, nextOffset, functionID, serialization]);
1851
1884
  await client.query('COMMIT');
1885
+ // Notify only after commit, so a woken reader sees the value.
1886
+ this.#signalNotification(exports.DBOS_STREAMS_CHANNEL, `${workflowID}::${key}`);
1852
1887
  }
1853
1888
  catch (e) {
1854
1889
  this.logger.error(e);
@@ -1863,9 +1898,11 @@ class SystemDatabase {
1863
1898
  const client = await this.pool.connect();
1864
1899
  try {
1865
1900
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1901
+ // Only a real insert (not a replay) should wake readers.
1902
+ let didWrite = false;
1866
1903
  await this.#runAndRecordResult(client, functionName, workflowID, functionID, async () => {
1867
1904
  // Find the maximum offset for this workflow_uuid and key combination
1868
- const maxOffsetResult = await client.query(`SELECT MAX("offset") FROM "${this.schemaName}".streams
1905
+ const maxOffsetResult = await client.query(`SELECT MAX("offset") FROM "${this.schemaName}".streams
1869
1906
  WHERE workflow_uuid = $1 AND key = $2`, [workflowID, key]);
1870
1907
  // Next offset is max + 1, or 0 if no records exist
1871
1908
  const maxOffset = maxOffsetResult.rows[0].max;
@@ -1873,9 +1910,14 @@ class SystemDatabase {
1873
1910
  // Insert the new stream entry
1874
1911
  await client.query(`INSERT INTO "${this.schemaName}".streams (workflow_uuid, key, value, "offset", function_id, serialization)
1875
1912
  VALUES ($1, $2, $3, $4, $5, $6)`, [workflowID, key, serializedValue, nextOffset, functionID, serialization]);
1913
+ didWrite = true;
1876
1914
  return undefined;
1877
1915
  });
1878
1916
  await client.query('COMMIT');
1917
+ // Notify only after commit, so a woken reader sees the value.
1918
+ if (didWrite) {
1919
+ this.#signalNotification(exports.DBOS_STREAMS_CHANNEL, `${workflowID}::${key}`);
1920
+ }
1879
1921
  }
1880
1922
  catch (e) {
1881
1923
  this.logger.error(e);
@@ -1889,20 +1931,102 @@ class SystemDatabase {
1889
1931
  async closeStream(workflowID, functionID, key) {
1890
1932
  await this.writeStreamFromWorkflow(workflowID, functionID, key, exports.DBOS_STREAM_CLOSED_SENTINEL, 'portable_json', exports.DBOS_FUNCNAME_CLOSESTREAM);
1891
1933
  }
1892
- async readStream(workflowID, key, offset) {
1893
- const client = await this.pool.connect();
1934
+ // Read the value at `offset` and the workflow's status in one query: status null = no such workflow, value undefined = nothing at that offset.
1935
+ async readStreamValue(workflowID, key, offset) {
1936
+ // LEFT JOIN so a workflow with nothing at offset still reports its status (single PK lookup); under the poll limiter, inside @dbRetry so the permit frees across backoff.
1937
+ const result = await this.#pollWithLimiter(() => this.pool.query(
1938
+ // "offset" is a reserved word, so alias it (stream_offset) to read it back plainly.
1939
+ `SELECT ws.status AS status, s.value AS value, s.serialization AS serialization, s."offset" AS stream_offset
1940
+ FROM "${this.schemaName}".workflow_status ws
1941
+ LEFT OUTER JOIN "${this.schemaName}".streams s
1942
+ ON s.workflow_uuid = ws.workflow_uuid AND s.key = $2 AND s."offset" = $3
1943
+ WHERE ws.workflow_uuid = $1`, [workflowID, key, offset]));
1944
+ if (result.rows.length === 0) {
1945
+ return { status: null, value: undefined };
1946
+ }
1947
+ const row = result.rows[0];
1948
+ // streams.offset is non-nullable, so a NULL here means the join matched nothing at offset.
1949
+ if (row.stream_offset === null) {
1950
+ return { status: row.status, value: undefined };
1951
+ }
1952
+ return { status: row.status, value: { serializedValue: row.value, serialization: row.serialization } };
1953
+ }
1954
+ #signalNotification(channel, payload) {
1955
+ // Coalesce a wakeup on `channel` for `payload`; no-op without LISTEN/NOTIFY (clients, CockroachDB), which poll.
1956
+ if (!this.shouldUseDBNotifications) {
1957
+ return;
1958
+ }
1959
+ let batch = this.pendingNotifications.get(channel);
1960
+ if (batch === undefined) {
1961
+ batch = new Set();
1962
+ this.pendingNotifications.set(channel, batch);
1963
+ }
1964
+ batch.add(payload);
1965
+ }
1966
+ // Periodically flush coalesced notifications across all channels, keeping the notifying commit off the write path.
1967
+ async #runNotifier() {
1968
+ while (this.#notifierActive) {
1969
+ const { promise, cancel } = (0, utils_1.cancellableSleep)(this.notificationCoalesceMs);
1970
+ this.#notifierWake = cancel;
1971
+ await promise;
1972
+ this.#notifierWake = null;
1973
+ if (!this.#notifierActive) {
1974
+ break;
1975
+ }
1976
+ try {
1977
+ await this.flushNotifications();
1978
+ }
1979
+ catch (e) {
1980
+ // Last resort: the flush drops its own failed batch, so this catches only unexpected errors that must not kill the push path.
1981
+ if (this.#notifierActive) {
1982
+ this.logger.warn(`Notifier error: ${String(e)}`);
1983
+ const { promise: backoff } = (0, utils_1.cancellableSleep)(1000);
1984
+ await backoff;
1985
+ }
1986
+ }
1987
+ }
1988
+ // Final flush so values written just before shutdown still wake readers promptly.
1894
1989
  try {
1895
- const result = await client.query(`SELECT value, serialization FROM "${this.schemaName}".streams
1896
- WHERE workflow_uuid = $1 AND key = $2 AND "offset" = $3`, [workflowID, key, offset]);
1897
- if (result.rows.length === 0) {
1898
- throw new Error(`No value found for workflow_uuid=${workflowID}, key=${key}, offset=${offset}`);
1990
+ await this.flushNotifications();
1991
+ }
1992
+ catch (e) {
1993
+ this.logger.warn(`Notifier final flush error: ${String(e)}`);
1994
+ }
1995
+ }
1996
+ // Emit one notifying transaction per channel for all pending payloads; drop a channel's batch on failure. Soft-private so tests can drive it.
1997
+ async flushNotifications() {
1998
+ let hasPending = false;
1999
+ for (const batch of this.pendingNotifications.values()) {
2000
+ if (batch.size > 0) {
2001
+ hasPending = true;
2002
+ break;
1899
2003
  }
1900
- // Deserialize the value before returning
1901
- const row = result.rows[0];
1902
- return { serializedValue: row.value, serialization: row.serialization };
1903
2004
  }
1904
- finally {
1905
- client.release();
2005
+ if (!hasPending) {
2006
+ return;
2007
+ }
2008
+ // Grab and clear atomically (no await between), so writes during the flush start the next batch.
2009
+ const batches = this.pendingNotifications;
2010
+ this.pendingNotifications = new Map();
2011
+ // One transaction per channel so an unsendable payload on one channel drops only its own batch.
2012
+ for (const [channel, batch] of batches) {
2013
+ if (batch.size === 0) {
2014
+ continue;
2015
+ }
2016
+ try {
2017
+ // One statement: one round trip, one async-notify queue-lock acquisition; unnest emits one notification per payload.
2018
+ const client = await this.pool.connect();
2019
+ try {
2020
+ await client.query(`SELECT pg_notify($1, p) FROM unnest($2::text[]) AS p`, [channel, Array.from(batch)]);
2021
+ }
2022
+ finally {
2023
+ client.release();
2024
+ }
2025
+ }
2026
+ catch (e) {
2027
+ // Drop the batch (don't requeue) on failure, e.g. a payload over pg_notify's 8000-byte limit; polling still delivers those values.
2028
+ this.logger.warn(`Notifier flush error on ${channel}: ${String(e)}`);
2029
+ }
1906
2030
  }
1907
2031
  }
1908
2032
  // ==================== Observability: Workflow Communications ====================
@@ -3188,9 +3312,9 @@ class SystemDatabase {
3188
3312
  let client = null;
3189
3313
  try {
3190
3314
  client = await this.pool.connect();
3191
- await client.query('LISTEN dbos_notifications_channel;');
3192
- await client.query('LISTEN dbos_workflow_events_channel;');
3193
- await client.query('LISTEN dbos_streams_channel;');
3315
+ await client.query(`LISTEN ${exports.DBOS_NOTIFICATIONS_CHANNEL};`);
3316
+ await client.query(`LISTEN ${exports.DBOS_WORKFLOW_EVENTS_CHANNEL};`);
3317
+ await client.query(`LISTEN ${exports.DBOS_STREAMS_CHANNEL};`);
3194
3318
  // Self-test: verify LISTEN actually works by sending a NOTIFY and checking it arrives.
3195
3319
  // If a transaction-mode pooler (e.g. PgBouncer pool_mode=transaction) is in the path,
3196
3320
  // LISTEN succeeds but the subscription is silently lost when the backend is released.
@@ -3215,13 +3339,13 @@ class SystemDatabase {
3215
3339
  const handler = (msg) => {
3216
3340
  if (!this.shouldUseDBNotifications)
3217
3341
  return;
3218
- if (msg.channel === 'dbos_notifications_channel' && msg.payload) {
3342
+ if (msg.channel === exports.DBOS_NOTIFICATIONS_CHANNEL && msg.payload) {
3219
3343
  this.notificationsMap.callCallbacks(msg.payload);
3220
3344
  }
3221
- else if (msg.channel === 'dbos_workflow_events_channel' && msg.payload) {
3345
+ else if (msg.channel === exports.DBOS_WORKFLOW_EVENTS_CHANNEL && msg.payload) {
3222
3346
  this.workflowEventsMap.callCallbacks(msg.payload);
3223
3347
  }
3224
- else if (msg.channel === 'dbos_streams_channel' && msg.payload) {
3348
+ else if (msg.channel === exports.DBOS_STREAMS_CHANNEL && msg.payload) {
3225
3349
  this.streamsMap.callCallbacks(msg.payload);
3226
3350
  }
3227
3351
  };
@@ -3386,7 +3510,7 @@ __decorate([
3386
3510
  __metadata("design:type", Function),
3387
3511
  __metadata("design:paramtypes", [String, String, Number]),
3388
3512
  __metadata("design:returntype", Promise)
3389
- ], SystemDatabase.prototype, "readStream", null);
3513
+ ], SystemDatabase.prototype, "readStreamValue", null);
3390
3514
  __decorate([
3391
3515
  dbRetry(),
3392
3516
  __metadata("design:type", Function),