@dbos-inc/dbos-sdk 4.24.11-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
@@ -397,12 +412,15 @@ class SystemDatabase {
397
412
  */
398
413
  pollLimiter;
399
414
  runningWorkflowMap = new Map(); // Map from workflowID to workflow promise, queue name and partition key
400
- constructor(systemDatabaseUrl, logger, serializer, sysDbPoolSize = exports.DEFAULT_POOL_SIZE, systemDatabasePool, schemaName = 'dbos', useListenNotify = true, pollingConcurrency) {
415
+ // Per-partition-key created_at cursors: keep per-key queue order monotonic across batches
416
+ #batchCreatedAtCursors = new Map();
417
+ constructor(systemDatabaseUrl, logger, serializer, sysDbPoolSize = exports.DEFAULT_POOL_SIZE, systemDatabasePool, schemaName = 'dbos', useListenNotify = true, pollingConcurrency, notificationCoalesceMs = exports.DEFAULT_NOTIFICATION_COALESCE_MS) {
401
418
  this.systemDatabaseUrl = systemDatabaseUrl;
402
419
  this.logger = logger;
403
420
  this.serializer = serializer;
404
421
  this.schemaName = schemaName;
405
422
  this.shouldUseDBNotifications = useListenNotify;
423
+ this.notificationCoalesceMs = notificationCoalesceMs;
406
424
  if (systemDatabasePool) {
407
425
  this.pool = systemDatabasePool;
408
426
  this.customPool = true;
@@ -437,12 +455,22 @@ class SystemDatabase {
437
455
  await ensureSystemDatabase(this.systemDatabaseUrl, this.logger, this.customPool ? this.pool : undefined, this.schemaName, this.shouldUseDBNotifications);
438
456
  if (this.shouldUseDBNotifications) {
439
457
  await this.#listenForNotifications();
458
+ // Push coalesced stream and event notifications off the write path.
459
+ this.#notifierActive = true;
460
+ this.#notifierLoop = this.#runNotifier();
440
461
  }
441
462
  }
442
463
  async destroy() {
443
464
  if (this.reconnectTimeout) {
444
465
  clearTimeout(this.reconnectTimeout);
445
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
+ }
446
474
  if (this.notificationsClient) {
447
475
  try {
448
476
  this.notificationsClient.release(true);
@@ -527,6 +555,150 @@ class SystemDatabase {
527
555
  }
528
556
  }
529
557
  }
558
+ /** Highest created_at among still-active rows per partition key, used to seed the in-memory cursor. */
559
+ async #maxPartitionKeyCreatedAt(keys) {
560
+ const maxima = new Map();
561
+ if (keys.length === 0)
562
+ return maxima;
563
+ const { rows } = await this.pool.query(`SELECT queue_partition_key, MAX(created_at) AS max_created_at
564
+ FROM "${this.schemaName}".workflow_status
565
+ WHERE queue_partition_key = ANY($1) AND status = ANY($2)
566
+ GROUP BY queue_partition_key`, [keys, [workflow_1.StatusString.ENQUEUED, workflow_1.StatusString.PENDING]]);
567
+ for (const row of rows) {
568
+ if (row.max_created_at !== null) {
569
+ maxima.set(row.queue_partition_key, Number(row.max_created_at));
570
+ }
571
+ }
572
+ return maxima;
573
+ }
574
+ /**
575
+ * Stamp created_at monotonic within each partition key so per-key order holds across batches.
576
+ * Unordered rows (no partition key) get wall-clock time and never touch the cursors.
577
+ */
578
+ async #assignBatchCreatedAt(statuses) {
579
+ const nowMS = Date.now();
580
+ const batchKeys = new Set();
581
+ for (const status of statuses) {
582
+ if (status.queuePartitionKey !== undefined) {
583
+ batchKeys.add(status.queuePartitionKey);
584
+ }
585
+ }
586
+ // On first sight of a key, seed its cursor from the DB high-water mark so per-key order
587
+ // survives a restart or rebalance instead of resetting to wall-clock.
588
+ const unseen = Array.from(batchKeys).filter((key) => !this.#batchCreatedAtCursors.has(key));
589
+ const seeds = await this.#maxPartitionKeyCreatedAt(unseen);
590
+ // Synchronous from here, so a concurrent batch cannot interleave with these cursor updates.
591
+ for (const [key, seededMax] of seeds) {
592
+ // max() guards against a concurrent batch that already advanced this key.
593
+ this.#batchCreatedAtCursors.set(key, Math.max(this.#batchCreatedAtCursors.get(key) ?? 0, seededMax + 1));
594
+ }
595
+ const createdAts = [];
596
+ const nextForKey = new Map();
597
+ for (const status of statuses) {
598
+ const key = status.queuePartitionKey;
599
+ if (key === undefined) {
600
+ createdAts.push(nowMS);
601
+ continue;
602
+ }
603
+ const value = nextForKey.get(key) ?? Math.max(nowMS, this.#batchCreatedAtCursors.get(key) ?? 0);
604
+ createdAts.push(value);
605
+ nextForKey.set(key, value + 1);
606
+ }
607
+ for (const [key, next] of nextForKey) {
608
+ this.#batchCreatedAtCursors.set(key, next);
609
+ }
610
+ return createdAts;
611
+ }
612
+ /**
613
+ * Batch-insert ENQUEUED workflow status rows in a single transaction.
614
+ *
615
+ * Rows whose workflow_uuid already exists are skipped rather than updated, making this
616
+ * idempotent under redelivery (e.g. Kafka). Returns the IDs of the rows actually inserted.
617
+ *
618
+ * Deliberately not `@dbRetry()`-decorated, unlike its neighbours: that loop is unabortable, so a
619
+ * connection outage would trap the caller in it rather than let it back off and observe a
620
+ * shutdown. Callers retry this themselves.
621
+ */
622
+ async enqueueWorkflows(statuses) {
623
+ const inserted = new Set();
624
+ if (statuses.length === 0)
625
+ return inserted;
626
+ for (const status of statuses) {
627
+ if (status.status !== workflow_1.StatusString.ENQUEUED) {
628
+ throw new error_1.DBOSError(`enqueueWorkflows only accepts ${workflow_1.StatusString.ENQUEUED} workflows, but ${status.workflowUUID} is ${status.status}`);
629
+ }
630
+ if (status.deduplicationID !== undefined) {
631
+ throw new error_1.DBOSError(`enqueueWorkflows does not support deduplication IDs, but ${status.workflowUUID} has one`);
632
+ }
633
+ }
634
+ const createdAts = await this.#assignBatchCreatedAt(statuses);
635
+ const columns = [
636
+ 'workflow_uuid',
637
+ 'status',
638
+ 'name',
639
+ 'class_name',
640
+ 'config_name',
641
+ 'queue_name',
642
+ 'authenticated_user',
643
+ 'assumed_role',
644
+ 'authenticated_roles',
645
+ 'request',
646
+ 'executor_id',
647
+ 'application_version',
648
+ 'application_id',
649
+ 'created_at',
650
+ 'recovery_attempts',
651
+ 'updated_at',
652
+ 'workflow_timeout_ms',
653
+ 'workflow_deadline_epoch_ms',
654
+ 'inputs',
655
+ 'deduplication_id',
656
+ 'priority',
657
+ 'queue_partition_key',
658
+ 'parent_workflow_id',
659
+ 'serialization',
660
+ 'owner_xid',
661
+ 'delay_until_epoch_ms',
662
+ 'attributes',
663
+ 'schedule_name',
664
+ ];
665
+ const client = await this.pool.connect();
666
+ try {
667
+ await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
668
+ // Chunk to stay well under the bind-parameter limit.
669
+ const chunkSize = 500;
670
+ for (let start = 0; start < statuses.length; start += chunkSize) {
671
+ const chunk = statuses.slice(start, start + chunkSize);
672
+ const tuples = [];
673
+ const params = [];
674
+ let paramIdx = 1;
675
+ for (let i = 0; i < chunk.length; i++) {
676
+ const status = chunk[i];
677
+ const createdAt = createdAts[start + i];
678
+ tuples.push(`(${columns.map(() => `$${paramIdx++}`).join(', ')})`);
679
+ params.push(status.workflowUUID, status.status, status.workflowName,
680
+ // For cross-language compatibility, these MUST be NULL in the database when not set
681
+ status.workflowClassName === '' ? null : status.workflowClassName, status.workflowConfigName === '' ? null : status.workflowConfigName, status.queueName ?? null, status.authenticatedUser, status.assumedRole, JSON.stringify(status.authenticatedRoles), JSON.stringify(status.request), status.executorId, status.applicationVersion ?? null, status.applicationID, createdAt, 0, createdAt, status.timeoutMS ?? null, status.deadlineEpochMS ?? null, status.input, null, status.priority, status.queuePartitionKey ?? null, status.parentWorkflowID ?? null, status.serialization, null, status.delayUntilEpochMS ?? null, status.attributes ? JSON.stringify(status.attributes) : null, status.scheduleName ?? null);
682
+ }
683
+ const { rows } = await client.query(`INSERT INTO "${this.schemaName}".workflow_status (${columns.join(', ')})
684
+ VALUES ${tuples.join(', ')}
685
+ ON CONFLICT (workflow_uuid) DO NOTHING
686
+ RETURNING workflow_uuid`, params);
687
+ for (const row of rows) {
688
+ inserted.add(row.workflow_uuid);
689
+ }
690
+ }
691
+ await client.query('COMMIT');
692
+ }
693
+ catch (e) {
694
+ await client.query('ROLLBACK');
695
+ throw e;
696
+ }
697
+ finally {
698
+ client.release();
699
+ }
700
+ return inserted;
701
+ }
530
702
  async recordWorkflowOutput(workflowID, status) {
531
703
  const client = await this.pool.connect();
532
704
  try {
@@ -1409,9 +1581,9 @@ class SystemDatabase {
1409
1581
  }
1410
1582
  // ==================== Messaging ====================
1411
1583
  nullTopic = '__null__topic__';
1412
- async send(workflowID, functionID, destinationID, message, topic, serialization, messageUUID) {
1584
+ async send(workflowID, functionID, destinationID, message, topic, serialization, idempotencyKey) {
1413
1585
  topic = topic ?? this.nullTopic;
1414
- messageUUID = messageUUID ?? (0, crypto_1.randomUUID)();
1586
+ const messageUUID = idempotencyKey ? `${idempotencyKey}::${destinationID}` : (0, crypto_1.randomUUID)();
1415
1587
  const client = await this.pool.connect();
1416
1588
  try {
1417
1589
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
@@ -1438,9 +1610,10 @@ class SystemDatabase {
1438
1610
  client.release();
1439
1611
  }
1440
1612
  }
1441
- async sendDirect(destinationID, message, topic, serialization, messageUUID) {
1613
+ async sendDirect(destinationID, message, topic, serialization, idempotencyKey) {
1442
1614
  topic = topic ?? this.nullTopic;
1443
- messageUUID = messageUUID ?? (0, crypto_1.randomUUID)();
1615
+ // Same per-destination scoping as send() above.
1616
+ const messageUUID = idempotencyKey ? `${idempotencyKey}::${destinationID}` : (0, crypto_1.randomUUID)();
1444
1617
  try {
1445
1618
  await this.pool.query(`INSERT INTO "${this.schemaName}".notifications (destination_uuid, topic, message, serialization, message_uuid)
1446
1619
  VALUES ($1, $2, $3, $4, $5)
@@ -1551,6 +1724,8 @@ class SystemDatabase {
1551
1724
  const client = await this.pool.connect();
1552
1725
  try {
1553
1726
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1727
+ // Only a real write (not a replay) should wake readers.
1728
+ let didWrite = false;
1554
1729
  await this.#runAndRecordResult(client, exports.DBOS_FUNCNAME_SETEVENT, workflowID, functionID, async () => {
1555
1730
  await client.query(`INSERT INTO "${this.schemaName}".workflow_events (workflow_uuid, key, value, serialization)
1556
1731
  VALUES ($1, $2, $3, $4)
@@ -1562,9 +1737,14 @@ class SystemDatabase {
1562
1737
  VALUES ($1, $2, $3, $4, $5)
1563
1738
  ON CONFLICT (workflow_uuid, function_id, key)
1564
1739
  DO UPDATE SET value = $4, serialization = $5;`, [workflowID, functionID, key, message, serialization]);
1740
+ didWrite = true;
1565
1741
  return undefined;
1566
1742
  });
1567
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
+ }
1568
1748
  }
1569
1749
  catch (e) {
1570
1750
  this.logger.error(e);
@@ -1702,6 +1882,8 @@ class SystemDatabase {
1702
1882
  await client.query(`INSERT INTO "${this.schemaName}".streams (workflow_uuid, key, value, "offset", function_id, serialization)
1703
1883
  VALUES ($1, $2, $3, $4, $5, $6)`, [workflowID, key, serializedValue, nextOffset, functionID, serialization]);
1704
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}`);
1705
1887
  }
1706
1888
  catch (e) {
1707
1889
  this.logger.error(e);
@@ -1716,9 +1898,11 @@ class SystemDatabase {
1716
1898
  const client = await this.pool.connect();
1717
1899
  try {
1718
1900
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1901
+ // Only a real insert (not a replay) should wake readers.
1902
+ let didWrite = false;
1719
1903
  await this.#runAndRecordResult(client, functionName, workflowID, functionID, async () => {
1720
1904
  // Find the maximum offset for this workflow_uuid and key combination
1721
- 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
1722
1906
  WHERE workflow_uuid = $1 AND key = $2`, [workflowID, key]);
1723
1907
  // Next offset is max + 1, or 0 if no records exist
1724
1908
  const maxOffset = maxOffsetResult.rows[0].max;
@@ -1726,9 +1910,14 @@ class SystemDatabase {
1726
1910
  // Insert the new stream entry
1727
1911
  await client.query(`INSERT INTO "${this.schemaName}".streams (workflow_uuid, key, value, "offset", function_id, serialization)
1728
1912
  VALUES ($1, $2, $3, $4, $5, $6)`, [workflowID, key, serializedValue, nextOffset, functionID, serialization]);
1913
+ didWrite = true;
1729
1914
  return undefined;
1730
1915
  });
1731
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
+ }
1732
1921
  }
1733
1922
  catch (e) {
1734
1923
  this.logger.error(e);
@@ -1742,20 +1931,102 @@ class SystemDatabase {
1742
1931
  async closeStream(workflowID, functionID, key) {
1743
1932
  await this.writeStreamFromWorkflow(workflowID, functionID, key, exports.DBOS_STREAM_CLOSED_SENTINEL, 'portable_json', exports.DBOS_FUNCNAME_CLOSESTREAM);
1744
1933
  }
1745
- async readStream(workflowID, key, offset) {
1746
- 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.
1747
1989
  try {
1748
- const result = await client.query(`SELECT value, serialization FROM "${this.schemaName}".streams
1749
- WHERE workflow_uuid = $1 AND key = $2 AND "offset" = $3`, [workflowID, key, offset]);
1750
- if (result.rows.length === 0) {
1751
- 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;
1752
2003
  }
1753
- // Deserialize the value before returning
1754
- const row = result.rows[0];
1755
- return { serializedValue: row.value, serialization: row.serialization };
1756
2004
  }
1757
- finally {
1758
- 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
+ }
1759
2030
  }
1760
2031
  }
1761
2032
  // ==================== Observability: Workflow Communications ====================
@@ -3041,9 +3312,9 @@ class SystemDatabase {
3041
3312
  let client = null;
3042
3313
  try {
3043
3314
  client = await this.pool.connect();
3044
- await client.query('LISTEN dbos_notifications_channel;');
3045
- await client.query('LISTEN dbos_workflow_events_channel;');
3046
- 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};`);
3047
3318
  // Self-test: verify LISTEN actually works by sending a NOTIFY and checking it arrives.
3048
3319
  // If a transaction-mode pooler (e.g. PgBouncer pool_mode=transaction) is in the path,
3049
3320
  // LISTEN succeeds but the subscription is silently lost when the backend is released.
@@ -3068,13 +3339,13 @@ class SystemDatabase {
3068
3339
  const handler = (msg) => {
3069
3340
  if (!this.shouldUseDBNotifications)
3070
3341
  return;
3071
- if (msg.channel === 'dbos_notifications_channel' && msg.payload) {
3342
+ if (msg.channel === exports.DBOS_NOTIFICATIONS_CHANNEL && msg.payload) {
3072
3343
  this.notificationsMap.callCallbacks(msg.payload);
3073
3344
  }
3074
- else if (msg.channel === 'dbos_workflow_events_channel' && msg.payload) {
3345
+ else if (msg.channel === exports.DBOS_WORKFLOW_EVENTS_CHANNEL && msg.payload) {
3075
3346
  this.workflowEventsMap.callCallbacks(msg.payload);
3076
3347
  }
3077
- else if (msg.channel === 'dbos_streams_channel' && msg.payload) {
3348
+ else if (msg.channel === exports.DBOS_STREAMS_CHANNEL && msg.payload) {
3078
3349
  this.streamsMap.callCallbacks(msg.payload);
3079
3350
  }
3080
3351
  };
@@ -3239,7 +3510,7 @@ __decorate([
3239
3510
  __metadata("design:type", Function),
3240
3511
  __metadata("design:paramtypes", [String, String, Number]),
3241
3512
  __metadata("design:returntype", Promise)
3242
- ], SystemDatabase.prototype, "readStream", null);
3513
+ ], SystemDatabase.prototype, "readStreamValue", null);
3243
3514
  __decorate([
3244
3515
  dbRetry(),
3245
3516
  __metadata("design:type", Function),