@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.
- package/dist/src/client.d.ts +1 -1
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +29 -27
- package/dist/src/client.js.map +1 -1
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +6 -0
- package/dist/src/config.js.map +1 -1
- package/dist/src/dbos-executor.d.ts +25 -1
- package/dist/src/dbos-executor.d.ts.map +1 -1
- package/dist/src/dbos-executor.js +52 -1
- package/dist/src/dbos-executor.js.map +1 -1
- package/dist/src/dbos.d.ts.map +1 -1
- package/dist/src/dbos.js +26 -24
- package/dist/src/dbos.js.map +1 -1
- package/dist/src/error.d.ts +4 -0
- package/dist/src/error.d.ts.map +1 -1
- package/dist/src/error.js +9 -1
- package/dist/src/error.js.map +1 -1
- package/dist/src/eventreceiver.d.ts +57 -0
- package/dist/src/eventreceiver.d.ts.map +1 -0
- package/dist/src/eventreceiver.js +75 -0
- package/dist/src/eventreceiver.js.map +1 -0
- package/dist/src/sysdb_migrations/internal/migrations.d.ts.map +1 -1
- package/dist/src/sysdb_migrations/internal/migrations.js +28 -13
- package/dist/src/sysdb_migrations/internal/migrations.js.map +1 -1
- package/dist/src/system_database.d.ts +27 -6
- package/dist/src/system_database.d.ts.map +1 -1
- package/dist/src/system_database.js +296 -25
- package/dist/src/system_database.js.map +1 -1
- package/dist/src/wfqueue.d.ts +5 -0
- package/dist/src/wfqueue.d.ts.map +1 -1
- package/dist/src/wfqueue.js +17 -1
- package/dist/src/wfqueue.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +12 -2
|
@@ -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
|
-
|
|
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,
|
|
1584
|
+
async send(workflowID, functionID, destinationID, message, topic, serialization, idempotencyKey) {
|
|
1413
1585
|
topic = topic ?? this.nullTopic;
|
|
1414
|
-
messageUUID =
|
|
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,
|
|
1613
|
+
async sendDirect(destinationID, message, topic, serialization, idempotencyKey) {
|
|
1442
1614
|
topic = topic ?? this.nullTopic;
|
|
1443
|
-
|
|
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
|
-
|
|
1746
|
-
|
|
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
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
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
|
-
|
|
1758
|
-
|
|
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(
|
|
3045
|
-
await client.query(
|
|
3046
|
-
await client.query(
|
|
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 ===
|
|
3342
|
+
if (msg.channel === exports.DBOS_NOTIFICATIONS_CHANNEL && msg.payload) {
|
|
3072
3343
|
this.notificationsMap.callCallbacks(msg.payload);
|
|
3073
3344
|
}
|
|
3074
|
-
else if (msg.channel ===
|
|
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 ===
|
|
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, "
|
|
3513
|
+
], SystemDatabase.prototype, "readStreamValue", null);
|
|
3243
3514
|
__decorate([
|
|
3244
3515
|
dbRetry(),
|
|
3245
3516
|
__metadata("design:type", Function),
|