@dbos-inc/dbos-sdk 5.2.3-preview → 5.2.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.
Files changed (40) hide show
  1. package/dist/schemas/system_db_schema.d.ts +2 -1
  2. package/dist/schemas/system_db_schema.d.ts.map +1 -1
  3. package/dist/schemas/system_db_schema.js.map +1 -1
  4. package/dist/src/client.d.ts +3 -1
  5. package/dist/src/client.d.ts.map +1 -1
  6. package/dist/src/client.js +5 -4
  7. package/dist/src/client.js.map +1 -1
  8. package/dist/src/config.d.ts.map +1 -1
  9. package/dist/src/config.js +2 -0
  10. package/dist/src/config.js.map +1 -1
  11. package/dist/src/context.d.ts +3 -0
  12. package/dist/src/context.d.ts.map +1 -1
  13. package/dist/src/context.js +13 -1
  14. package/dist/src/context.js.map +1 -1
  15. package/dist/src/dbos-executor.d.ts +15 -2
  16. package/dist/src/dbos-executor.d.ts.map +1 -1
  17. package/dist/src/dbos-executor.js +33 -32
  18. package/dist/src/dbos-executor.js.map +1 -1
  19. package/dist/src/dbos.d.ts.map +1 -1
  20. package/dist/src/dbos.js +5 -0
  21. package/dist/src/dbos.js.map +1 -1
  22. package/dist/src/error.d.ts +8 -0
  23. package/dist/src/error.d.ts.map +1 -1
  24. package/dist/src/error.js +17 -2
  25. package/dist/src/error.js.map +1 -1
  26. package/dist/src/index.d.ts +1 -1
  27. package/dist/src/index.d.ts.map +1 -1
  28. package/dist/src/index.js +2 -1
  29. package/dist/src/index.js.map +1 -1
  30. package/dist/src/sysdb_migrations/internal/migrations.js +5 -0
  31. package/dist/src/sysdb_migrations/internal/migrations.js.map +1 -1
  32. package/dist/src/system_database.d.ts +30 -14
  33. package/dist/src/system_database.d.ts.map +1 -1
  34. package/dist/src/system_database.js +291 -131
  35. package/dist/src/system_database.js.map +1 -1
  36. package/dist/src/wfqueue.d.ts.map +1 -1
  37. package/dist/src/wfqueue.js +12 -8
  38. package/dist/src/wfqueue.js.map +1 -1
  39. package/dist/tsconfig.tsbuildinfo +1 -1
  40. package/package.json +1 -1
@@ -9,12 +9,13 @@ 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.retentionLockKey = exports.grantDbosSchemaPermissions = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_GC_BATCH_SIZE = exports.DEFAULT_RENAME_BATCH_SIZE = exports.validateObservabilityQueryTimeoutMs = exports.DEFAULT_OBSERVABILITY_QUERY_TIMEOUT_MS = 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;
12
+ exports.SystemDatabase = exports.retriablePostgresException = exports.verifySystemDatabase = exports.ensureSystemDatabase = exports.retentionLockKey = exports.grantDbosSchemaPermissions = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_GC_BATCH_SIZE = exports.DEFAULT_RENAME_BATCH_SIZE = exports.validateIdleTransactionTimeoutMs = exports.DEFAULT_IDLE_TRANSACTION_TIMEOUT_MS = exports.validateObservabilityQueryTimeoutMs = exports.DEFAULT_OBSERVABILITY_QUERY_TIMEOUT_MS = 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");
16
16
  const workflow_1 = require("./workflow");
17
17
  const utils_1 = require("./utils");
18
+ const context_1 = require("./context");
18
19
  const async_hooks_1 = require("async_hooks");
19
20
  const crypto_1 = require("crypto");
20
21
  const utils_2 = require("./utils");
@@ -67,6 +68,24 @@ function validateObservabilityQueryTimeoutMs(value) {
67
68
  }
68
69
  }
69
70
  exports.validateObservabilityQueryTimeoutMs = validateObservabilityQueryTimeoutMs;
71
+ // Bounds how long a frozen or unreachable client can hold system database locks.
72
+ exports.DEFAULT_IDLE_TRANSACTION_TIMEOUT_MS = 60_000;
73
+ /** The timeout is rendered into a `SET idle_in_transaction_session_timeout`, which takes only an in-range integer. */
74
+ function validateIdleTransactionTimeoutMs(value) {
75
+ if (value !== undefined && (!Number.isFinite(value) || value > MAX_STATEMENT_TIMEOUT_MS)) {
76
+ throw new Error(`systemDatabaseIdleTransactionTimeoutMs must be a finite number no greater than ${MAX_STATEMENT_TIMEOUT_MS}, got ${value}`);
77
+ }
78
+ }
79
+ exports.validateIdleTransactionTimeoutMs = validateIdleTransactionTimeoutMs;
80
+ /** Whether the connection string already sets idle_in_transaction_session_timeout through libpq options. */
81
+ function setsIdleTransactionTimeout(databaseUrl) {
82
+ try {
83
+ return new URL(databaseUrl).searchParams.get('options')?.includes('idle_in_transaction_session_timeout') ?? false;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
70
89
  // Workflows re-owned per transaction by a rename.
71
90
  exports.DEFAULT_RENAME_BATCH_SIZE = 10_000;
72
91
  // Rows deleted per transaction by garbage collection.
@@ -344,10 +363,12 @@ const RETRY_SQLSTATE_PREFIXES = new Set([
344
363
  ]);
345
364
  const RETRY_SQLSTATE_CODES = new Set([
346
365
  '40003', // statement_completion_unknown
366
+ '25P03', // idle_in_transaction_session_timeout, when the kill lands on an in-flight query; an idle kill surfaces as a message match below
367
+ '40P01', // deadlock_detected: the victim's transaction rolled back, so rerunning it is safe
347
368
  ]);
348
369
  /**
349
- * Kept out of the sets above: those feed `dbRetry`, which retries forever, and the step-recording
350
- * path maps 40001 to a workflow conflict. Only bulk maintenance work retries on these.
370
+ * Bulk maintenance retries these a bounded number of times. 40001 is kept out of the sets above,
371
+ * which feed `dbRetry` and so retry forever; every other path runs READ COMMITTED and never sees it.
351
372
  */
352
373
  const SERIALIZATION_SQLSTATE_CODES = new Set([
353
374
  '40001', // serialization_failure (MVCC conflict)
@@ -442,6 +463,7 @@ function retriablePostgresException(err) {
442
463
  }
443
464
  return false;
444
465
  }
466
+ exports.retriablePostgresException = retriablePostgresException;
445
467
  /** 57014 is query_canceled, which is how statement_timeout cancels a query. */
446
468
  function isStatementTimeout(err) {
447
469
  return !!err && typeof err === 'object' && err.code === '57014';
@@ -575,6 +597,8 @@ class SystemDatabase {
575
597
  notificationCoalesceMs = exports.DEFAULT_NOTIFICATION_COALESCE_MS;
576
598
  // Statement timeout for observability reads, in ms; undefined disables the cap.
577
599
  observabilityQueryTimeoutMs;
600
+ // idle_in_transaction_session_timeout set on each connection of a pool we own, in ms; undefined leaves the server's.
601
+ idleTransactionTimeoutMs;
578
602
  // Coalesced NOTIFY payloads keyed by channel, flushed by the notifier loop; soft-private so tests can drive a flush.
579
603
  pendingNotifications = new Map();
580
604
  #notifierActive = false;
@@ -588,7 +612,8 @@ class SystemDatabase {
588
612
  * client and starve control-plane operations. See {@link #pollWithLimiter}.
589
613
  */
590
614
  pollLimiter;
591
- runningWorkflowMap = new Map(); // Map from workflowID to workflow promise, queue name and partition key
615
+ /** Executions running in this process, by workflow ID; a re-dispatch runs alongside its stale execution, which parks at its next write. */
616
+ runningWorkflowMap = new Map();
592
617
  #cancelWatchers = new Map();
593
618
  #cancelPollerLoop = undefined;
594
619
  #cancelPollerWake = null;
@@ -605,7 +630,7 @@ class SystemDatabase {
605
630
  #retentionClients = new Set();
606
631
  constructor(systemDatabaseUrl, logger, serializer, sysDbPoolSize = exports.DEFAULT_POOL_SIZE, systemDatabasePool, schemaName = 'dbos', useListenNotify = true, pollingConcurrency, notificationCoalesceMs = exports.DEFAULT_NOTIFICATION_COALESCE_MS,
607
632
  // The application this handle acts for; undefined writes unclaimed rows.
608
- appName, observabilityQueryTimeoutMs = exports.DEFAULT_OBSERVABILITY_QUERY_TIMEOUT_MS) {
633
+ appName, observabilityQueryTimeoutMs = exports.DEFAULT_OBSERVABILITY_QUERY_TIMEOUT_MS, idleTransactionTimeoutMs = exports.DEFAULT_IDLE_TRANSACTION_TIMEOUT_MS) {
609
634
  this.systemDatabaseUrl = systemDatabaseUrl;
610
635
  this.logger = logger;
611
636
  this.serializer = serializer;
@@ -614,6 +639,12 @@ class SystemDatabase {
614
639
  this.shouldUseDBNotifications = useListenNotify;
615
640
  this.notificationCoalesceMs = notificationCoalesceMs;
616
641
  validateObservabilityQueryTimeoutMs(observabilityQueryTimeoutMs);
642
+ validateIdleTransactionTimeoutMs(idleTransactionTimeoutMs);
643
+ // Floor at 1ms: PostgreSQL reads 0 as "no timeout".
644
+ this.idleTransactionTimeoutMs =
645
+ idleTransactionTimeoutMs > 0 && !systemDatabasePool && !setsIdleTransactionTimeout(systemDatabaseUrl)
646
+ ? Math.max(1, Math.round(idleTransactionTimeoutMs))
647
+ : undefined;
617
648
  // Floor at 1ms: PostgreSQL reads 0 as "no timeout", the loosest cap rather than the tightest.
618
649
  this.observabilityQueryTimeoutMs =
619
650
  observabilityQueryTimeoutMs > 0 ? Math.max(1, Math.round(observabilityQueryTimeoutMs)) : undefined;
@@ -641,6 +672,15 @@ class SystemDatabase {
641
672
  this.pool.on('error', (err) => {
642
673
  this.logger.warn(`Unexpected error in pool: ${err}`);
643
674
  });
675
+ const timeoutMs = this.idleTransactionTimeoutMs;
676
+ if (timeoutMs !== undefined) {
677
+ // Queued ahead of the borrower's first query; outside any transaction, so it persists for the session.
678
+ this.pool.on('connect', (client) => {
679
+ client.query(`SET idle_in_transaction_session_timeout = ${timeoutMs}`).catch((err) => {
680
+ this.logger.warn(`Could not set idle_in_transaction_session_timeout on a system database connection: ${err}`);
681
+ });
682
+ });
683
+ }
644
684
  }
645
685
  }
646
686
  #onClientError = (err) => {
@@ -808,18 +848,18 @@ class SystemDatabase {
808
848
  }
809
849
  // ==================== Workflow Status ====================
810
850
  /** Runs on `client` if given, joining its transaction; otherwise in its own retried transaction. */
811
- async initWorkflowStatus(initStatus, ownerXid, client, reusePolicy = 'return-existing') {
851
+ async initWorkflowStatus(initStatus, creatorXid, client, reusePolicy = 'return-existing') {
812
852
  if (client !== undefined) {
813
- return await this.#initWorkflowStatusInternal(client, initStatus, ownerXid, reusePolicy);
853
+ return await this.#initWorkflowStatusInternal(client, initStatus, creatorXid, reusePolicy);
814
854
  }
815
- return await this.initWorkflowStatusStandalone(initStatus, ownerXid, reusePolicy);
855
+ return await this.initWorkflowStatusStandalone(initStatus, creatorXid, reusePolicy);
816
856
  }
817
- async initWorkflowStatusStandalone(initStatus, ownerXid, reusePolicy) {
857
+ async initWorkflowStatusStandalone(initStatus, creatorXid, reusePolicy) {
818
858
  const client = await this.#connect();
819
859
  let shouldCommit = false;
820
860
  try {
821
861
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
822
- const result = await this.#initWorkflowStatusInternal(client, initStatus, ownerXid, reusePolicy);
862
+ const result = await this.#initWorkflowStatusInternal(client, initStatus, creatorXid, reusePolicy);
823
863
  // If there is an existing DB record and we aren't here to recover it, leave it be.
824
864
  shouldCommit = result.shouldExecuteOnThisExecutor;
825
865
  return result;
@@ -840,13 +880,17 @@ class SystemDatabase {
840
880
  }
841
881
  }
842
882
  /** Insert a child workflow's row and record it as the parent's step in one transaction, so a crash leaves both or neither. */
843
- async initChildWorkflowStatus(initStatus, ownerXid, parentWorkflowID, parentFunctionID, startTimeEpochMs, endTimeEpochMs, reusePolicy = 'return-existing') {
883
+ async initChildWorkflowStatus(initStatus, creatorXid, parentWorkflowID, parentFunctionID, startTimeEpochMs, endTimeEpochMs, reusePolicy = 'return-existing') {
844
884
  const client = await this.#connect();
845
885
  let shouldCommit = false;
846
886
  try {
847
887
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
848
- const result = await this.#initWorkflowStatusInternal(client, initStatus, ownerXid, reusePolicy);
849
- await this.recordOperationResultInternal(client, parentWorkflowID, parentFunctionID, initStatus.workflowName, true, startTimeEpochMs, endTimeEpochMs, { childWorkflowID: initStatus.workflowUUID });
888
+ const parentOwnerXid = (0, context_1.currentOwnerXid)(parentWorkflowID);
889
+ if (parentOwnerXid !== undefined) {
890
+ await this.#checkOwner(client, parentWorkflowID, parentOwnerXid);
891
+ }
892
+ const result = await this.#initWorkflowStatusInternal(client, initStatus, creatorXid, reusePolicy);
893
+ await this.recordOperationResultInternal(client, parentWorkflowID, parentFunctionID, initStatus.workflowName, true, startTimeEpochMs, endTimeEpochMs, { childWorkflowID: initStatus.workflowUUID, ownerChecked: true });
850
894
  shouldCommit = true;
851
895
  return result;
852
896
  }
@@ -865,9 +909,9 @@ class SystemDatabase {
865
909
  }
866
910
  }
867
911
  }
868
- async #initWorkflowStatusInternal(client, initStatus, ownerXid, reusePolicy) {
869
- const resRow = await this.insertWorkflowStatus(client, initStatus, ownerXid);
870
- if (reusePolicy === 'reject' && (ownerXid === null || resRow.owner_xid !== ownerXid)) {
912
+ async #initWorkflowStatusInternal(client, initStatus, creatorXid, reusePolicy) {
913
+ const resRow = await this.insertWorkflowStatus(client, initStatus, creatorXid);
914
+ if (reusePolicy === 'reject' && (creatorXid === null || resRow.creator_xid !== creatorXid)) {
871
915
  throw new error_1.DBOSWorkflowIDInUseError(initStatus.workflowUUID, resRow.status, resRow.name);
872
916
  }
873
917
  if (resRow.name !== initStatus.workflowName) {
@@ -889,7 +933,7 @@ class SystemDatabase {
889
933
  // The upsert above already set executor assignment for a row we own.
890
934
  return {
891
935
  status: resRow.status,
892
- shouldExecuteOnThisExecutor: ownerXid === resRow.owner_xid,
936
+ shouldExecuteOnThisExecutor: creatorXid === resRow.creator_xid,
893
937
  serialization: resRow.serialization,
894
938
  };
895
939
  }
@@ -899,6 +943,7 @@ class SystemDatabase {
899
943
  return;
900
944
  await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
901
945
  SET status = $1,
946
+ owner_xid = NULL,
902
947
  deduplication_id = NULL,
903
948
  started_at_epoch_ms = NULL,
904
949
  queue_name = NULL,
@@ -1007,7 +1052,7 @@ class SystemDatabase {
1007
1052
  'queue_partition_key',
1008
1053
  'parent_workflow_id',
1009
1054
  'serialization',
1010
- 'owner_xid',
1055
+ 'creator_xid',
1011
1056
  'delay_until_epoch_ms',
1012
1057
  'attributes',
1013
1058
  'schedule_name',
@@ -1065,42 +1110,42 @@ class SystemDatabase {
1065
1110
  }
1066
1111
  return inserted;
1067
1112
  }
1068
- async recordWorkflowOutput(workflowID, status) {
1113
+ async recordWorkflowOutput(workflowID, status, ownerXid) {
1069
1114
  const client = await this.#connect();
1070
1115
  try {
1071
- return await this.#recordWorkflowOutcome(client, workflowID, workflow_1.StatusString.SUCCESS, { output: status.output });
1116
+ return await this.#recordWorkflowOutcome(client, workflowID, workflow_1.StatusString.SUCCESS, { output: status.output }, ownerXid);
1072
1117
  }
1073
1118
  finally {
1074
1119
  client.release();
1075
1120
  }
1076
1121
  }
1077
- async recordWorkflowError(workflowID, status) {
1122
+ async recordWorkflowError(workflowID, status, ownerXid) {
1078
1123
  const client = await this.#connect();
1079
1124
  try {
1080
- return await this.#recordWorkflowOutcome(client, workflowID, workflow_1.StatusString.ERROR, { error: status.error });
1125
+ return await this.#recordWorkflowOutcome(client, workflowID, workflow_1.StatusString.ERROR, { error: status.error }, ownerXid);
1081
1126
  }
1082
1127
  finally {
1083
1128
  client.release();
1084
1129
  }
1085
1130
  }
1086
1131
  // Record a workflow's terminal outcome (SUCCESS or ERROR), reporting whether
1087
- // the write landed. The write applies only to a PENDING row: a run owns its
1088
- // workflow's outcome exactly as long as the row says that run is what the
1089
- // workflow is doing. (Note: this does not prevent a write when another
1090
- // concurrent execution is already running and the status is PENDING. However,
1091
- // both executions should be deterministic and idempotent.)
1132
+ // the write landed. The write applies only to a PENDING row still owned by
1133
+ // ownerXid, which defaults to the calling execution's token; a caller
1134
+ // outside the workflow's context passes it explicitly or goes unchecked.
1092
1135
  //
1093
1136
  // Returning false means the row was CANCELLED, dead-lettered, already
1094
- // terminal, handed to another execution (ENQUEUED/DELAYED, e.g. by a
1095
- // concurrent resume), or deleted; the caller resolves which by awaiting the
1096
- // recorded outcome.
1097
- async #recordWorkflowOutcome(client, workflowID, status, outcome) {
1137
+ // terminal, handed to another execution (e.g. by a concurrent resume or
1138
+ // recovery), or deleted; the caller resolves which by awaiting the recorded
1139
+ // outcome.
1140
+ async #recordWorkflowOutcome(client, workflowID, status, outcome, ownerXid) {
1141
+ ownerXid ??= (0, context_1.currentOwnerXid)(workflowID);
1098
1142
  let committed = false;
1099
1143
  try {
1100
1144
  await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
1101
1145
  const rowCount = await this.updateWorkflowStatus(client, workflowID, status, {
1102
- update: { resetDeduplicationID: true, setCompletedAt: true },
1103
- where: { status: workflow_1.StatusString.PENDING },
1146
+ // A finished workflow has no owner.
1147
+ update: { resetDeduplicationID: true, setCompletedAt: true, ownerXid: null },
1148
+ where: { status: workflow_1.StatusString.PENDING, ownerXid },
1104
1149
  throwOnFailure: false,
1105
1150
  });
1106
1151
  if (rowCount === 0) {
@@ -1142,6 +1187,7 @@ class SystemDatabase {
1142
1187
  const result = await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
1143
1188
  SET started_at_epoch_ms = NULL,
1144
1189
  status = $1,
1190
+ owner_xid = NULL,
1145
1191
  updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint,
1146
1192
  queue_name = COALESCE(queue_name, $2)
1147
1193
  WHERE status = $3
@@ -1161,7 +1207,7 @@ class SystemDatabase {
1161
1207
  const client = await this.#connect();
1162
1208
  try {
1163
1209
  // Check if the operation has been done before for OAOO (only do this inside a workflow).
1164
- const json = await this.#runAndRecordResult(client, exports.DBOS_FUNCNAME_GETSTATUS, callerID, callerFN, funcGetStatus);
1210
+ const json = await this.#inTransaction(client, () => this.#runAndRecordResult(client, exports.DBOS_FUNCNAME_GETSTATUS, callerID, callerFN, funcGetStatus));
1165
1211
  return parseStatus(json);
1166
1212
  }
1167
1213
  finally {
@@ -1202,6 +1248,7 @@ class SystemDatabase {
1202
1248
  resetNameTo: internalOptions?.updateName,
1203
1249
  queueName: internalOptions?.queueName,
1204
1250
  resetStartedAtEpochMs: internalOptions?.resetStartedAtEpochMs,
1251
+ ownerXid: internalOptions?.ownerXid,
1205
1252
  },
1206
1253
  });
1207
1254
  }
@@ -1236,7 +1283,7 @@ class SystemDatabase {
1236
1283
  async recordOperationResult(workflowID, functionID, functionName, checkConflict, startTimeEpochMs, endTimeEpochMs, options = {}) {
1237
1284
  const client = await this.#connect();
1238
1285
  try {
1239
- await this.recordOperationResultInternal(client, workflowID, functionID, functionName, checkConflict, startTimeEpochMs, endTimeEpochMs, options);
1286
+ await this.#inTransaction(client, () => this.recordOperationResultInternal(client, workflowID, functionID, functionName, checkConflict, startTimeEpochMs, endTimeEpochMs, options));
1240
1287
  }
1241
1288
  finally {
1242
1289
  client.release();
@@ -1274,31 +1321,38 @@ class SystemDatabase {
1274
1321
  if (functionID === undefined)
1275
1322
  throw new TypeError('functionID must be defined');
1276
1323
  patchName = `DBOS.patch-${patchName}`;
1277
- const { rows } = await this.pool.query(`SELECT function_name
1278
- FROM "${this.schemaName}".operation_outputs
1279
- WHERE workflow_uuid=$1 AND function_id=$2`, [workflowID, functionID]);
1324
+ const readCheckpointName = async (queryable) => {
1325
+ const { rows } = await queryable.query(`SELECT function_name
1326
+ FROM "${this.schemaName}".operation_outputs
1327
+ WHERE workflow_uuid=$1 AND function_id=$2`, [workflowID, functionID]);
1328
+ return rows.length === 0 ? undefined : rows[0].function_name;
1329
+ };
1280
1330
  if (deprecated) {
1281
1331
  // Deprecated does not write anything. We skip any existing matching patch marker if it matches
1282
- if (rows.length === 0) {
1332
+ const checkpointName = await readCheckpointName(this.pool);
1333
+ if (checkpointName === undefined) {
1283
1334
  return { isPatched: true, hasEntry: false };
1284
1335
  }
1285
- return { isPatched: true, hasEntry: rows[0].function_name === patchName };
1336
+ return { isPatched: true, hasEntry: checkpointName === patchName };
1286
1337
  }
1287
1338
  // Nondeprecated - skip matching entry, unpatched if nonmatching entry,
1288
- // If there is no entry, we insert one that indicates it is patched.
1289
- if (rows.length !== 0) {
1290
- if (rows[0].function_name === patchName) {
1339
+ // If there is no entry, we insert one that indicates it is patched, as an owner-checked checkpoint.
1340
+ const client = await this.#connect();
1341
+ try {
1342
+ return await this.#inTransaction(client, async () => {
1343
+ const checkpointName = await readCheckpointName(client);
1344
+ if (checkpointName !== undefined) {
1345
+ const matches = checkpointName === patchName;
1346
+ return { isPatched: matches, hasEntry: matches };
1347
+ }
1348
+ const dn = Date.now();
1349
+ await this.recordOperationResultInternal(client, workflowID, functionID, patchName, true, dn, dn);
1291
1350
  return { isPatched: true, hasEntry: true };
1292
- }
1293
- return { isPatched: false, hasEntry: false };
1351
+ });
1352
+ }
1353
+ finally {
1354
+ client.release();
1294
1355
  }
1295
- // Insert a patchmarker
1296
- const dn = Date.now();
1297
- await this.pool.query(`INSERT INTO "${this.schemaName}".operation_outputs
1298
- (workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, application_name, retention_timestamp)
1299
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, (EXTRACT(EPOCH FROM now()) * 1000)::bigint)
1300
- ON CONFLICT DO NOTHING;`, [workflowID, functionID, null, null, patchName, null, dn, dn, this.appName ?? null]);
1301
- return { isPatched: true, hasEntry: true };
1302
1356
  }
1303
1357
  // ==================== Workflow Management ====================
1304
1358
  async cancelWorkflows(workflowIDs, cancelChildren = false) {
@@ -1320,7 +1374,7 @@ class SystemDatabase {
1320
1374
  }
1321
1375
  async #cancelWorkflows(workflowIDs) {
1322
1376
  await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
1323
- SET status = $1, queue_name = NULL, deduplication_id = NULL, started_at_epoch_ms = NULL,
1377
+ SET status = $1, owner_xid = NULL, queue_name = NULL, deduplication_id = NULL, started_at_epoch_ms = NULL,
1324
1378
  updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint,
1325
1379
  completed_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint
1326
1380
  WHERE workflow_uuid = ANY($2)
@@ -1331,7 +1385,7 @@ class SystemDatabase {
1331
1385
  }
1332
1386
  async resumeWorkflows(workflowIDs, queueName) {
1333
1387
  await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
1334
- SET status = $1, queue_name = $2, recovery_attempts = 0,
1388
+ SET status = $1, owner_xid = NULL, queue_name = $2, recovery_attempts = 0,
1335
1389
  workflow_deadline_epoch_ms = NULL, deduplication_id = NULL,
1336
1390
  started_at_epoch_ms = NULL,
1337
1391
  updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint,
@@ -1583,7 +1637,7 @@ class SystemDatabase {
1583
1637
  params.push(options.applicationVersion);
1584
1638
  }
1585
1639
  const { rowCount } = await client.query(`UPDATE "${schema}".workflow_status
1586
- SET status = $1, queue_name = $2, queue_partition_key = $3, recovery_attempts = 0,
1640
+ SET status = $1, owner_xid = NULL, queue_name = $2, queue_partition_key = $3, recovery_attempts = 0,
1587
1641
  workflow_deadline_epoch_ms = NULL, deduplication_id = NULL, started_at_epoch_ms = NULL,
1588
1642
  completed_at = NULL, output = NULL, error = NULL,
1589
1643
  updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint${setVersion}
@@ -1832,9 +1886,9 @@ class SystemDatabase {
1832
1886
  for (const wfID of workflowIDs) {
1833
1887
  // Export workflow_status
1834
1888
  const statusResult = await client.query(
1835
- // owner_xid is intentionally omitted: it is a transient transaction-ownership
1836
- // token, not logical workflow state, and a source database's xid is
1837
- // meaningless in the target.
1889
+ // creator_xid and owner_xid are intentionally omitted: they are transient
1890
+ // tokens, not logical workflow state, and a source database's
1891
+ // tokens are meaningless in the target.
1838
1892
  `SELECT
1839
1893
  ws.workflow_uuid, ws.status, ws.name, ws.authenticated_user, ws.assumed_role,
1840
1894
  ws.authenticated_roles,
@@ -2004,43 +2058,63 @@ class SystemDatabase {
2004
2058
  }
2005
2059
  }
2006
2060
  // ==================== Awaiting Workflows ====================
2007
- registerRunningWorkflow(workflowID, workflowPromise, onSettled, queueName, queuePartitionKey) {
2061
+ /** Register one execution; the returned release is idempotent, so it can run both before a park and once the run settles. */
2062
+ registerRunningWorkflow(workflowID, queueName, queuePartitionKey) {
2063
+ const entry = {
2064
+ promise: Promise.resolve(),
2065
+ queueName,
2066
+ queuePartitionKey,
2067
+ release: () => {
2068
+ const entries = this.runningWorkflowMap.get(workflowID);
2069
+ const index = entries?.indexOf(entry) ?? -1;
2070
+ if (index < 0)
2071
+ return;
2072
+ entries.splice(index, 1);
2073
+ if (entries.length === 0)
2074
+ this.runningWorkflowMap.delete(workflowID);
2075
+ },
2076
+ };
2077
+ const entries = this.runningWorkflowMap.get(workflowID);
2078
+ if (entries)
2079
+ entries.push(entry);
2080
+ else
2081
+ this.runningWorkflowMap.set(workflowID, [entry]);
2082
+ return entry;
2083
+ }
2084
+ /** Track an execution's promise for shutdown, releasing its entry when it settles. */
2085
+ trackRunningWorkflow(workflowID, entry, workflowPromise) {
2008
2086
  // Need to await for the workflow and capture errors.
2009
- const awaitWorkflowPromise = workflowPromise
2087
+ entry.promise = workflowPromise
2010
2088
  .catch((error) => {
2011
2089
  const outcome = this.#destroyed ? 'was abandoned by shutdown' : 'failed';
2012
2090
  this.logger.debug(`Workflow ${workflowID} ${outcome}: ${error}`);
2013
2091
  })
2014
2092
  .finally(() => {
2015
- onSettled();
2016
- });
2017
- this.runningWorkflowMap.set(workflowID, {
2018
- promise: awaitWorkflowPromise,
2019
- queueName,
2020
- queuePartitionKey,
2093
+ entry.release();
2021
2094
  });
2022
2095
  }
2023
2096
  checkForRunningWorkflow(workflowID) {
2024
2097
  return this.runningWorkflowMap.has(workflowID);
2025
2098
  }
2026
- clearRunningWorkflow(workflowID) {
2027
- this.runningWorkflowMap.delete(workflowID);
2028
- }
2029
2099
  /** Workflows this worker is running for a queue, across every partition of it. */
2030
2100
  countRunningWorkflowsForQueue(queueName) {
2031
2101
  let count = 0;
2032
- for (const entry of this.runningWorkflowMap.values()) {
2033
- if (entry.queueName === queueName)
2034
- count++;
2102
+ for (const entries of this.runningWorkflowMap.values()) {
2103
+ for (const entry of entries) {
2104
+ if (entry.queueName === queueName)
2105
+ count++;
2106
+ }
2035
2107
  }
2036
2108
  return count;
2037
2109
  }
2038
2110
  /** Workflows this worker is running for one partition of a queue. */
2039
2111
  countRunningWorkflowsForPartition(queueName, queuePartitionKey) {
2040
2112
  let count = 0;
2041
- for (const entry of this.runningWorkflowMap.values()) {
2042
- if (entry.queueName === queueName && entry.queuePartitionKey === queuePartitionKey)
2043
- count++;
2113
+ for (const entries of this.runningWorkflowMap.values()) {
2114
+ for (const entry of entries) {
2115
+ if (entry.queueName === queueName && entry.queuePartitionKey === queuePartitionKey)
2116
+ count++;
2117
+ }
2044
2118
  }
2045
2119
  return count;
2046
2120
  }
@@ -2054,7 +2128,10 @@ class SystemDatabase {
2054
2128
  // Each pass picks up workflows a draining workflow started, and awaits any given run only once.
2055
2129
  const awaited = new Set();
2056
2130
  for (;;) {
2057
- const pending = Array.from(this.runningWorkflowMap.values(), (entry) => entry.promise).filter((promise) => !awaited.has(promise));
2131
+ const pending = Array.from(this.runningWorkflowMap.values())
2132
+ .flat()
2133
+ .map((entry) => entry.promise)
2134
+ .filter((promise) => !awaited.has(promise));
2058
2135
  if (pending.length === 0)
2059
2136
  break;
2060
2137
  for (const promise of pending)
@@ -2074,12 +2151,6 @@ class SystemDatabase {
2074
2151
  if (this.runningWorkflowMap.size > 0) {
2075
2152
  this.logger.warn(`Shutting down while ${this.runningWorkflowMap.size} workflows are still running: ${Array.from(this.runningWorkflowMap.keys()).join(', ')}`);
2076
2153
  }
2077
- if (this.workflowEventsMap.map.size > 0) {
2078
- this.logger.warn('Workflow events map is not empty - shutdown is not clean.');
2079
- }
2080
- if (this.notificationsMap.map.size > 0) {
2081
- this.logger.warn('Message notification map is not empty - shutdown is not clean.');
2082
- }
2083
2154
  }
2084
2155
  /**
2085
2156
  * Run a DB-backed polling read under the polling concurrency limiter, so that
@@ -2306,6 +2377,23 @@ class SystemDatabase {
2306
2377
  }
2307
2378
  return await this.sendDirectStandalone(destinationID, message, topic, serialization, idempotencyKey);
2308
2379
  }
2380
+ /** A send from inside a step of `workflowID`: not recorded, but it lands only while the caller still owns the workflow. */
2381
+ async sendFromStep(workflowID, destinationID, message, topic, serialization, idempotencyKey) {
2382
+ const ownerXid = (0, context_1.currentOwnerXid)(workflowID);
2383
+ const client = await this.#connect();
2384
+ try {
2385
+ await this.#inTransaction(client, async () => {
2386
+ await this.#sendDirectInternal(client, destinationID, message, topic, serialization, idempotencyKey);
2387
+ // After the insert, in the order the recorded send locks, so they cannot deadlock.
2388
+ if (ownerXid !== undefined) {
2389
+ await this.#checkOwner(client, workflowID, ownerXid);
2390
+ }
2391
+ });
2392
+ }
2393
+ finally {
2394
+ client.release();
2395
+ }
2396
+ }
2309
2397
  async sendDirectStandalone(destinationID, message, topic, serialization, idempotencyKey) {
2310
2398
  return await this.#sendDirectInternal(this.pool, destinationID, message, topic, serialization, idempotencyKey);
2311
2399
  }
@@ -2527,27 +2615,41 @@ class SystemDatabase {
2527
2615
  }
2528
2616
  // ==================== Streams ====================
2529
2617
  async writeStreamFromStep(workflowID, functionID, key, serializedValue, serialization) {
2530
- while (true) {
2531
- try {
2532
- // Derives the first unused offset inside the insert; two writers can still pick the same one.
2533
- await this.pool.query(`INSERT INTO "${this.schemaName}".streams (workflow_uuid, key, value, "offset", function_id, serialization)
2534
- SELECT $1::text, $2::text, $3::text, COALESCE(MAX(s."offset"), -1) + 1, $4::int, $5::text
2535
- FROM "${this.schemaName}".streams s
2536
- WHERE s.workflow_uuid = $1 AND s.key = $2`, [workflowID, key, serializedValue, functionID, serialization]);
2537
- }
2538
- catch (e) {
2539
- // Only an offset conflict resolves on retry; anything else would spin forever.
2540
- if (e instanceof pg_1.DatabaseError && e.code === '23505') {
2541
- this.logger.warn(`Stream offset conflict for workflow ${workflowID}, key ${key}; retrying`);
2542
- await (0, utils_1.sleepms)(100);
2543
- continue;
2618
+ const ownerXid = (0, context_1.currentOwnerXid)(workflowID);
2619
+ const client = await this.#connect();
2620
+ try {
2621
+ while (true) {
2622
+ try {
2623
+ await this.#inTransaction(client, async () => {
2624
+ // Derives the first unused offset inside the insert; two writers can still pick the same one.
2625
+ await client.query(`INSERT INTO "${this.schemaName}".streams (workflow_uuid, key, value, "offset", function_id, serialization)
2626
+ SELECT $1::text, $2::text, $3::text, COALESCE(MAX(s."offset"), -1) + 1, $4::int, $5::text
2627
+ FROM "${this.schemaName}".streams s
2628
+ WHERE s.workflow_uuid = $1 AND s.key = $2`, [workflowID, key, serializedValue, functionID, serialization]);
2629
+ // After the insert, in the order the workflow-level writes lock, so they cannot deadlock.
2630
+ if (ownerXid !== undefined) {
2631
+ await this.#checkOwner(client, workflowID, ownerXid);
2632
+ }
2633
+ });
2544
2634
  }
2545
- this.logger.error(e);
2546
- throw e;
2635
+ catch (e) {
2636
+ // Only an offset conflict resolves on retry; anything else would spin forever.
2637
+ if (e instanceof pg_1.DatabaseError && e.code === '23505') {
2638
+ this.logger.warn(`Stream offset conflict for workflow ${workflowID}, key ${key}; retrying`);
2639
+ await (0, utils_1.sleepms)(100);
2640
+ continue;
2641
+ }
2642
+ if (!(e instanceof error_1.DBOSWorkflowConflictError))
2643
+ this.logger.error(e);
2644
+ throw e;
2645
+ }
2646
+ // Notify only after commit, so a woken reader sees the value.
2647
+ this.#signalNotification(exports.DBOS_STREAMS_CHANNEL, `${workflowID}::${key}`);
2648
+ return;
2547
2649
  }
2548
- // Notify only after commit, so a woken reader sees the value.
2549
- this.#signalNotification(exports.DBOS_STREAMS_CHANNEL, `${workflowID}::${key}`);
2550
- return;
2650
+ }
2651
+ finally {
2652
+ client.release();
2551
2653
  }
2552
2654
  }
2553
2655
  async writeStreamFromWorkflow(workflowID, functionID, key, serializedValue, serialization, functionName) {
@@ -2763,7 +2865,7 @@ class SystemDatabase {
2763
2865
  // Literal statuses let the planner match idx_workflow_status_deadline under any plan mode.
2764
2866
  // SKIP LOCKED leaves a row a dequeue or a peer's sweep holds for the next sweep.
2765
2867
  const { rows } = await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
2766
- SET status = $1, queue_name = NULL, deduplication_id = NULL, started_at_epoch_ms = NULL,
2868
+ SET status = $1, owner_xid = NULL, queue_name = NULL, deduplication_id = NULL, started_at_epoch_ms = NULL,
2767
2869
  updated_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint,
2768
2870
  completed_at = (EXTRACT(EPOCH FROM now()) * 1000)::bigint
2769
2871
  WHERE workflow_uuid IN (
@@ -2805,7 +2907,9 @@ class SystemDatabase {
2805
2907
  SELECT pk FROM partitions WHERE pk IS NOT NULL`, params);
2806
2908
  return rows.map((row) => row.pk);
2807
2909
  }
2808
- async findAndMarkStartableWorkflows(queue, executorID, appVersion, queuePartitionKey, localRunningCount = 0, partitionLocalRunningCount = 0) {
2910
+ async findAndMarkStartableWorkflows(queue, executorID, appVersion, queuePartitionKey, localRunningCount = 0, partitionLocalRunningCount = 0,
2911
+ // Written to every claimed row: the dispatches of this claim own their executions.
2912
+ ownerXid = null) {
2809
2913
  const claimedIDs = [];
2810
2914
  const partitionParams = queuePartitionKey !== undefined ? [queuePartitionKey] : [];
2811
2915
  // Shares a concurrency or rate limit budget with other executors.
@@ -2938,12 +3042,14 @@ class SystemDatabase {
2938
3042
  workflow_1.StatusString.ENQUEUED,
2939
3043
  // Claim an unclaimed row for this application; a nameless dequeuer leaves ownership untouched.
2940
3044
  this.appName ?? null,
3045
+ ownerXid,
2941
3046
  ];
2942
3047
  // Re-check ownership alongside status, as the partitioned claim guard does.
2943
3048
  const claimScope = this.#appNameFilter('application_name', this.appName, updateParams);
2944
3049
  // RETURNING reports exactly the rows this statement flipped, so a row another worker won is absent.
2945
3050
  const flippedResult = await client.query(`UPDATE "${this.schemaName}".workflow_status
2946
3051
  SET status = $1,
3052
+ owner_xid = $8,
2947
3053
  executor_id = $2,
2948
3054
  application_version = $3,
2949
3055
  started_at_epoch_ms = (EXTRACT(epoch FROM now()) * 1000)::bigint,
@@ -2979,7 +3085,9 @@ class SystemDatabase {
2979
3085
  /** Max heads admitted per sweep: bounds dispatch, not the partition walk; an unconstrained sweep takes the lowest keys, so higher keys can wait under sustained load. */
2980
3086
  partitionedDequeueSweepCap = 8192;
2981
3087
  /** Dequeue each partition's head-of-line workflow in one transaction, at most {@link partitionedDequeueSweepCap} per sweep; only valid for partition-concurrency-1 queues with no queue-wide limit. */
2982
- async findAndMarkStartablePartitionedWorkflows(queue, executorID, appVersion, maxTasks = Infinity) {
3088
+ async findAndMarkStartablePartitionedWorkflows(queue, executorID, appVersion, maxTasks = Infinity,
3089
+ // Written to every claimed row: the dispatches of this claim own their executions.
3090
+ ownerXid = null) {
2983
3091
  if (queue.partitionConcurrency !== 1 ||
2984
3092
  queue.concurrency !== undefined ||
2985
3093
  queue.rateLimit !== undefined ||
@@ -3085,11 +3193,13 @@ class SystemDatabase {
3085
3193
  appVersion,
3086
3194
  // Claim the row, as the unpartitioned dequeue does.
3087
3195
  this.appName ?? null,
3196
+ ownerXid,
3088
3197
  ];
3089
3198
  const flipScope = this.#appNameFilter('application_name', this.appName, flipParams);
3090
3199
  // Start the workflows by marking them PENDING; RETURNING reports exactly the rows this statement flipped.
3091
3200
  const flippedResult = await client.query(`UPDATE "${this.schemaName}".workflow_status
3092
3201
  SET status = $1,
3202
+ owner_xid = $8,
3093
3203
  executor_id = $2,
3094
3204
  application_version = $6,
3095
3205
  started_at_epoch_ms = (EXTRACT(epoch FROM now()) * 1000)::bigint,
@@ -4429,7 +4539,7 @@ class SystemDatabase {
4429
4539
  return { queues, schedules, versions, workflows: inFlight + terminal, steps };
4430
4540
  }
4431
4541
  // ==================== Internal ====================
4432
- async insertWorkflowStatus(client, initStatus, ownerXid) {
4542
+ async insertWorkflowStatus(client, initStatus, creatorXid) {
4433
4543
  try {
4434
4544
  const { rows } = await client.query(`INSERT INTO "${this.schemaName}".workflow_status (
4435
4545
  workflow_uuid,
@@ -4454,18 +4564,19 @@ class SystemDatabase {
4454
4564
  forked_from,
4455
4565
  parent_workflow_id,
4456
4566
  serialization,
4457
- owner_xid,
4567
+ creator_xid,
4458
4568
  delay_until_epoch_ms,
4459
4569
  attributes,
4460
4570
  schedule_name,
4461
4571
  debounce_deadline_epoch_ms,
4462
4572
  is_debounced,
4463
- application_name
4464
- ) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29)
4573
+ application_name,
4574
+ owner_xid
4575
+ ) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30)
4465
4576
  ON CONFLICT (workflow_uuid)
4466
4577
  -- A no-op update, so an existing row comes back unchanged for the caller to inspect.
4467
- DO UPDATE SET owner_xid = workflow_status.owner_xid
4468
- RETURNING status, name, class_name, config_name, queue_name, executor_id, owner_xid, serialization`, [
4578
+ DO UPDATE SET creator_xid = workflow_status.creator_xid
4579
+ RETURNING status, name, class_name, config_name, queue_name, executor_id, creator_xid, serialization`, [
4469
4580
  initStatus.workflowUUID,
4470
4581
  initStatus.status,
4471
4582
  initStatus.workflowName,
@@ -4490,13 +4601,15 @@ class SystemDatabase {
4490
4601
  initStatus.forkedFrom ?? null,
4491
4602
  initStatus.parentWorkflowID ?? null,
4492
4603
  initStatus.serialization,
4493
- ownerXid,
4604
+ creatorXid,
4494
4605
  initStatus.delayUntilEpochMS ?? null,
4495
4606
  initStatus.attributes ? JSON.stringify(initStatus.attributes) : null,
4496
4607
  initStatus.scheduleName ?? null,
4497
4608
  initStatus.debounceDeadlineEpochMS ?? null,
4498
4609
  initStatus.isDebounced ?? false,
4499
4610
  initStatus.applicationName ?? null,
4611
+ // A direct start runs at once, so its creator token also owns the execution.
4612
+ initStatus.status === workflow_1.StatusString.PENDING ? creatorXid : null,
4500
4613
  ]);
4501
4614
  if (rows.length === 0) {
4502
4615
  throw new Error(`Attempt to insert workflow ${initStatus.workflowUUID} failed`);
@@ -4561,6 +4674,10 @@ class SystemDatabase {
4561
4674
  else if (update.clearCompletedAt) {
4562
4675
  setClause += `, completed_at = NULL`;
4563
4676
  }
4677
+ if (update.ownerXid !== undefined) {
4678
+ const param = args.push(update.ownerXid);
4679
+ setClause += `, owner_xid=$${param}`;
4680
+ }
4564
4681
  const where = options.where ?? {};
4565
4682
  if (where.status) {
4566
4683
  const param = args.push(where.status);
@@ -4570,6 +4687,10 @@ class SystemDatabase {
4570
4687
  const param = args.push(where.notStatus);
4571
4688
  whereClause += ` AND status!=$${param}`;
4572
4689
  }
4690
+ if (where.ownerXid !== undefined) {
4691
+ const param = args.push(where.ownerXid);
4692
+ whereClause += ` AND owner_xid=$${param}`;
4693
+ }
4573
4694
  const result = await client.query(`UPDATE "${this.schemaName}".workflow_status ${setClause} ${whereClause}`, args);
4574
4695
  const throwOnFailure = options.throwOnFailure ?? true;
4575
4696
  if (throwOnFailure && result.rowCount !== 1) {
@@ -4578,6 +4699,10 @@ class SystemDatabase {
4578
4699
  return result.rowCount ?? 0;
4579
4700
  }
4580
4701
  async recordOperationResultInternal(client, workflowID, functionID, functionName, checkConflict, startTimeEpochMs, endTimeEpochMs, options = {}) {
4702
+ const ownerXid = options.ownerChecked ? undefined : (0, context_1.currentOwnerXid)(workflowID);
4703
+ if (ownerXid !== undefined) {
4704
+ await this.#checkOwner(client, workflowID, ownerXid);
4705
+ }
4581
4706
  try {
4582
4707
  const out = await client.query(`INSERT INTO "${this.schemaName}".operation_outputs
4583
4708
  (workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, serialization, application_name, retention_timestamp)
@@ -4597,30 +4722,47 @@ class SystemDatabase {
4597
4722
  // Mirrors the parent: only the running application records its steps.
4598
4723
  this.appName ?? null,
4599
4724
  ]);
4725
+ // A row already there with another completion time is this execution's own doing
4726
+ // (a retry carries the same time), so it is nondeterminism, not a duplicate.
4600
4727
  if (checkConflict &&
4601
4728
  (out?.rowCount ?? 0) > 0 &&
4602
4729
  Number(out?.rows?.[0]?.completed_at_epoch_ms) !== endTimeEpochMs) {
4603
- dbos_executor_1.DBOSExecutor.globalInstance?.logger.warn(`Step output for ${workflowID}(${functionID}):${functionName} already recorded`);
4604
- throw new error_1.DBOSWorkflowConflictError(workflowID);
4605
- }
4606
- if (Number(out?.rows?.[0]?.completed_at_epoch_ms) === endTimeEpochMs) {
4607
- // Winning the checkpoint proves this executor is advancing the workflow:
4608
- // claim the executor_id marker (skips the write when already ours).
4609
- await client.query(`UPDATE "${this.schemaName}".workflow_status
4610
- SET executor_id = $1
4611
- WHERE workflow_uuid = $2 AND executor_id IS DISTINCT FROM $1`, [utils_1.globalParams.executorID, workflowID]);
4730
+ throw new error_1.DBOSStepNondeterminismError(workflowID, functionID);
4612
4731
  }
4613
4732
  }
4614
4733
  catch (error) {
4615
- const err = error;
4616
- if (err.code === '40001' || err.code === '23505') {
4617
- // Serialization and primary key conflict (Postgres).
4618
- throw new error_1.DBOSWorkflowConflictError(workflowID);
4619
- }
4620
- else {
4621
- throw err;
4734
+ if (error.code === '23505') {
4735
+ throw new error_1.DBOSStepNondeterminismError(workflowID, functionID);
4622
4736
  }
4737
+ throw error;
4738
+ }
4739
+ }
4740
+ /** Run `fn` in a READ COMMITTED transaction on `client`, so an ownership check holds its row lock until the write commits. */
4741
+ async #inTransaction(client, fn) {
4742
+ await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
4743
+ let result;
4744
+ try {
4745
+ result = await fn();
4746
+ }
4747
+ catch (e) {
4748
+ // Swallowed: a rollback failure must not mask why we are rolling back.
4749
+ await client.query('ROLLBACK').catch(() => undefined);
4750
+ throw e;
4623
4751
  }
4752
+ await client.query('COMMIT');
4753
+ return result;
4754
+ }
4755
+ /** Throw DBOSWorkflowConflictError unless ownerXid still owns the workflow; the row lock holds until commit, so no hand-off lands in between. */
4756
+ async #checkOwner(client, workflowID, ownerXid) {
4757
+ const { rows } = await client.query(`SELECT owner_xid FROM "${this.schemaName}".workflow_status WHERE workflow_uuid = $1 FOR NO KEY UPDATE`, [workflowID]);
4758
+ if (rows[0]?.owner_xid !== ownerXid) {
4759
+ throw new error_1.DBOSWorkflowConflictError(workflowID);
4760
+ }
4761
+ }
4762
+ /** The workflow's current ownership token; null if unowned or missing. */
4763
+ async getWorkflowOwner(workflowID) {
4764
+ const { rows } = await this.pool.query(`SELECT owner_xid FROM "${this.schemaName}".workflow_status WHERE workflow_uuid = $1`, [workflowID]);
4765
+ return rows[0]?.owner_xid ?? null;
4624
4766
  }
4625
4767
  async #getOperationResultAndThrowIfCancelled(client, workflowID, functionID) {
4626
4768
  await this.#checkIfCanceled(client, workflowID);
@@ -4682,10 +4824,10 @@ class SystemDatabase {
4682
4824
  }
4683
4825
  return JSON.parse(res.output);
4684
4826
  }
4685
- await this.recordOperationResultInternal(client, workflowID, functionID, exports.DBOS_FUNCNAME_SLEEP, false, startTimeMs, recordCompletionAtDeadline ? endTimeMs : startTimeMs, {
4827
+ await this.#inTransaction(client, () => this.recordOperationResultInternal(client, workflowID, functionID, exports.DBOS_FUNCNAME_SLEEP, false, startTimeMs, recordCompletionAtDeadline ? endTimeMs : startTimeMs, {
4686
4828
  output: serialization_1.DBOSPortableJSON.stringify(endTimeMs),
4687
4829
  serialization: serialization_1.DBOSPortableJSON.name(),
4688
- });
4830
+ }));
4689
4831
  return endTimeMs;
4690
4832
  }
4691
4833
  finally {
@@ -4838,13 +4980,13 @@ __decorate([
4838
4980
  __decorate([
4839
4981
  dbRetry(),
4840
4982
  __metadata("design:type", Function),
4841
- __metadata("design:paramtypes", [String, Object]),
4983
+ __metadata("design:paramtypes", [String, Object, String]),
4842
4984
  __metadata("design:returntype", Promise)
4843
4985
  ], SystemDatabase.prototype, "recordWorkflowOutput", null);
4844
4986
  __decorate([
4845
4987
  dbRetry(),
4846
4988
  __metadata("design:type", Function),
4847
- __metadata("design:paramtypes", [String, Object]),
4989
+ __metadata("design:paramtypes", [String, Object, String]),
4848
4990
  __metadata("design:returntype", Promise)
4849
4991
  ], SystemDatabase.prototype, "recordWorkflowError", null);
4850
4992
  __decorate([
@@ -4871,6 +5013,12 @@ __decorate([
4871
5013
  __metadata("design:paramtypes", [String, Number, String, Boolean, Number, Number, Object]),
4872
5014
  __metadata("design:returntype", Promise)
4873
5015
  ], SystemDatabase.prototype, "recordOperationResult", null);
5016
+ __decorate([
5017
+ dbRetry(),
5018
+ __metadata("design:type", Function),
5019
+ __metadata("design:paramtypes", [String, Number, String, Function]),
5020
+ __metadata("design:returntype", Promise)
5021
+ ], SystemDatabase.prototype, "runTransactionalStep", null);
4874
5022
  __decorate([
4875
5023
  dbRetry(),
4876
5024
  __metadata("design:type", Function),
@@ -4931,6 +5079,12 @@ __decorate([
4931
5079
  __metadata("design:paramtypes", [String, Number, String, Object, Object, Object, String]),
4932
5080
  __metadata("design:returntype", Promise)
4933
5081
  ], SystemDatabase.prototype, "send", null);
5082
+ __decorate([
5083
+ dbRetry(),
5084
+ __metadata("design:type", Function),
5085
+ __metadata("design:paramtypes", [String, String, Object, Object, Object, String]),
5086
+ __metadata("design:returntype", Promise)
5087
+ ], SystemDatabase.prototype, "sendFromStep", null);
4934
5088
  __decorate([
4935
5089
  dbRetry(),
4936
5090
  __metadata("design:type", Function),
@@ -5009,4 +5163,10 @@ __decorate([
5009
5163
  __metadata("design:paramtypes", [String]),
5010
5164
  __metadata("design:returntype", Promise)
5011
5165
  ], SystemDatabase.prototype, "getLatestApplicationVersion", null);
5166
+ __decorate([
5167
+ dbRetry(),
5168
+ __metadata("design:type", Function),
5169
+ __metadata("design:paramtypes", [String]),
5170
+ __metadata("design:returntype", Promise)
5171
+ ], SystemDatabase.prototype, "getWorkflowOwner", null);
5012
5172
  //# sourceMappingURL=system_database.js.map