@dbos-inc/dbos-sdk 4.26.3-preview → 4.26.4-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 (82) hide show
  1. package/dist/schemas/system_db_schema.d.ts +5 -0
  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/adminserver.js +1 -1
  5. package/dist/src/adminserver.js.map +1 -1
  6. package/dist/src/cli/cli.d.ts.map +1 -1
  7. package/dist/src/cli/cli.js +64 -0
  8. package/dist/src/cli/cli.js.map +1 -1
  9. package/dist/src/cli/schema.d.ts.map +1 -1
  10. package/dist/src/cli/schema.js +26 -16
  11. package/dist/src/cli/schema.js.map +1 -1
  12. package/dist/src/client.d.ts +62 -4
  13. package/dist/src/client.d.ts.map +1 -1
  14. package/dist/src/client.js +58 -8
  15. package/dist/src/client.js.map +1 -1
  16. package/dist/src/conductor/conductor.d.ts.map +1 -1
  17. package/dist/src/conductor/conductor.js +13 -2
  18. package/dist/src/conductor/conductor.js.map +1 -1
  19. package/dist/src/conductor/protocol.d.ts +16 -2
  20. package/dist/src/conductor/protocol.d.ts.map +1 -1
  21. package/dist/src/conductor/protocol.js +10 -2
  22. package/dist/src/conductor/protocol.js.map +1 -1
  23. package/dist/src/context.d.ts +1 -0
  24. package/dist/src/context.d.ts.map +1 -1
  25. package/dist/src/context.js +2 -1
  26. package/dist/src/context.js.map +1 -1
  27. package/dist/src/dbos-executor.d.ts +1 -0
  28. package/dist/src/dbos-executor.d.ts.map +1 -1
  29. package/dist/src/dbos-executor.js +11 -2
  30. package/dist/src/dbos-executor.js.map +1 -1
  31. package/dist/src/dbos.d.ts +41 -7
  32. package/dist/src/dbos.d.ts.map +1 -1
  33. package/dist/src/dbos.js +61 -4
  34. package/dist/src/dbos.js.map +1 -1
  35. package/dist/src/debouncer.d.ts +4 -0
  36. package/dist/src/debouncer.d.ts.map +1 -1
  37. package/dist/src/debouncer.js +32 -8
  38. package/dist/src/debouncer.js.map +1 -1
  39. package/dist/src/enqueue_options.d.ts +59 -0
  40. package/dist/src/enqueue_options.d.ts.map +1 -0
  41. package/dist/src/enqueue_options.js +61 -0
  42. package/dist/src/enqueue_options.js.map +1 -0
  43. package/dist/src/enqueue_workflow.d.ts +12 -0
  44. package/dist/src/enqueue_workflow.d.ts.map +1 -0
  45. package/dist/src/enqueue_workflow.js +91 -0
  46. package/dist/src/enqueue_workflow.js.map +1 -0
  47. package/dist/src/index.d.ts +2 -1
  48. package/dist/src/index.d.ts.map +1 -1
  49. package/dist/src/index.js +3 -1
  50. package/dist/src/index.js.map +1 -1
  51. package/dist/src/scheduler/scheduler.d.ts +1 -0
  52. package/dist/src/scheduler/scheduler.d.ts.map +1 -1
  53. package/dist/src/scheduler/scheduler.js +13 -5
  54. package/dist/src/scheduler/scheduler.js.map +1 -1
  55. package/dist/src/sysdb_migrations/internal/migrations.d.ts +2 -1
  56. package/dist/src/sysdb_migrations/internal/migrations.d.ts.map +1 -1
  57. package/dist/src/sysdb_migrations/internal/migrations.js +186 -2
  58. package/dist/src/sysdb_migrations/internal/migrations.js.map +1 -1
  59. package/dist/src/sysdb_migrations/migration_runner.d.ts +5 -0
  60. package/dist/src/sysdb_migrations/migration_runner.d.ts.map +1 -1
  61. package/dist/src/sysdb_migrations/migration_runner.js +48 -10
  62. package/dist/src/sysdb_migrations/migration_runner.js.map +1 -1
  63. package/dist/src/system_database.d.ts +65 -10
  64. package/dist/src/system_database.d.ts.map +1 -1
  65. package/dist/src/system_database.js +544 -152
  66. package/dist/src/system_database.js.map +1 -1
  67. package/dist/src/utils.d.ts +1 -0
  68. package/dist/src/utils.d.ts.map +1 -1
  69. package/dist/src/utils.js +1 -0
  70. package/dist/src/utils.js.map +1 -1
  71. package/dist/src/wfqueue.d.ts +2 -0
  72. package/dist/src/wfqueue.d.ts.map +1 -1
  73. package/dist/src/wfqueue.js +5 -1
  74. package/dist/src/wfqueue.js.map +1 -1
  75. package/dist/src/workflow.d.ts +2 -0
  76. package/dist/src/workflow.d.ts.map +1 -1
  77. package/dist/src/workflow.js.map +1 -1
  78. package/dist/src/workflow_management.d.ts.map +1 -1
  79. package/dist/src/workflow_management.js +4 -9
  80. package/dist/src/workflow_management.js.map +1 -1
  81. package/dist/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +1 -1
@@ -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.getDbosSchemaPermissionsSql = exports.DEFAULT_NOTIFICATION_COALESCE_MS = exports.DBOS_STREAMS_CHANNEL = exports.DBOS_WORKFLOW_EVENTS_CHANNEL = exports.DBOS_NOTIFICATIONS_CHANNEL = exports.DBOS_STREAM_CLOSED_SENTINEL = exports.DEFAULT_POOL_SIZE = exports.DBOS_FUNCNAME_CLOSESTREAM = exports.DBOS_FUNCNAME_WRITESTREAM = exports.DBOS_FUNCNAME_GETSTATUS = exports.DBOS_FUNCNAME_SLEEP = exports.DBOS_FUNCNAME_GETEVENT = exports.DBOS_FUNCNAME_SETEVENT = exports.DBOS_FUNCNAME_RECV = exports.DBOS_FUNCNAME_SEND = void 0;
12
+ exports.SystemDatabase = exports.ensureSystemDatabase = exports.grantDbosSchemaPermissions = exports.getDbosSchemaPermissionsSql = exports.DEFAULT_RENAME_BATCH_SIZE = exports.DEFAULT_NOTIFICATION_COALESCE_MS = exports.DBOS_STREAMS_CHANNEL = exports.DBOS_WORKFLOW_EVENTS_CHANNEL = exports.DBOS_NOTIFICATIONS_CHANNEL = exports.DBOS_STREAM_CLOSED_SENTINEL = exports.DEFAULT_POOL_SIZE = exports.DBOS_FUNCNAME_CLOSESTREAM = exports.DBOS_FUNCNAME_WRITESTREAM = exports.DBOS_FUNCNAME_GETSTATUS = exports.DBOS_FUNCNAME_SLEEP = exports.DBOS_FUNCNAME_GETEVENT = exports.DBOS_FUNCNAME_SETEVENT = exports.DBOS_FUNCNAME_RECV = exports.DBOS_FUNCNAME_SEND = void 0;
13
13
  const dbos_executor_1 = require("./dbos-executor");
14
14
  const pg_1 = require("pg");
15
15
  const error_1 = require("./error");
@@ -38,6 +38,8 @@ exports.DBOS_WORKFLOW_EVENTS_CHANNEL = 'dbos_workflow_events_channel';
38
38
  exports.DBOS_STREAMS_CHANNEL = 'dbos_streams_channel';
39
39
  // Interval for coalescing LISTEN/NOTIFY notifications off the write path; caps the rate of notifying commits regardless of write throughput.
40
40
  exports.DEFAULT_NOTIFICATION_COALESCE_MS = 10;
41
+ // Workflows re-owned per transaction by a rename. Matches the GC default.
42
+ exports.DEFAULT_RENAME_BATCH_SIZE = 10_000;
41
43
  const QUEUE_COLUMN_BY_FIELD = {
42
44
  concurrency: 'concurrency',
43
45
  workerConcurrency: 'worker_concurrency',
@@ -47,6 +49,7 @@ const QUEUE_COLUMN_BY_FIELD = {
47
49
  partitionQueue: 'partition_queue',
48
50
  pollingIntervalSec: 'polling_interval_sec',
49
51
  };
52
+ const QUEUE_COLUMNS = 'name, concurrency, worker_concurrency, rate_limit_max, rate_limit_period_sec, priority_enabled, partition_queue, polling_interval_sec, application_name';
50
53
  function queueRecordFromRow(row) {
51
54
  return {
52
55
  name: row.name,
@@ -57,6 +60,7 @@ function queueRecordFromRow(row) {
57
60
  priorityEnabled: row.priority_enabled,
58
61
  partitionQueue: row.partition_queue,
59
62
  pollingIntervalSec: row.polling_interval_sec,
63
+ applicationName: row.application_name ?? undefined,
60
64
  };
61
65
  }
62
66
  /** The statements granting permissions on all entities in the system schema to a role. */
@@ -172,6 +176,32 @@ class NotificationMap {
172
176
  }
173
177
  }
174
178
  }
179
+ function mapVersionInfo(row) {
180
+ return {
181
+ versionId: row.version_id,
182
+ versionName: row.version_name,
183
+ versionTimestamp: Number(row.version_timestamp),
184
+ createdAt: Number(row.created_at),
185
+ applicationName: row.application_name ?? undefined,
186
+ };
187
+ }
188
+ const SCHEDULE_COLUMNS = 'schedule_id, schedule_name, workflow_name, workflow_class_name, schedule, status, context, last_fired_at, automatic_backfill, cron_timezone, queue_name, application_name';
189
+ function mapWorkflowSchedule(row) {
190
+ return {
191
+ scheduleId: row.schedule_id,
192
+ scheduleName: row.schedule_name,
193
+ workflowName: row.workflow_name,
194
+ workflowClassName: row.workflow_class_name,
195
+ schedule: row.schedule,
196
+ status: row.status,
197
+ context: row.context,
198
+ lastFiredAt: row.last_fired_at ?? null,
199
+ automaticBackfill: !!row.automatic_backfill,
200
+ cronTimezone: row.cron_timezone ?? null,
201
+ queueName: row.queue_name ?? null,
202
+ applicationName: row.application_name ?? undefined,
203
+ };
204
+ }
175
205
  function mapWorkflowStatus(row) {
176
206
  return {
177
207
  workflowUUID: row.workflow_uuid,
@@ -209,6 +239,7 @@ function mapWorkflowStatus(row) {
209
239
  scheduleName: row.schedule_name ?? undefined,
210
240
  debounceDeadlineEpochMS: row.debounce_deadline_epoch_ms ? Number(row.debounce_deadline_epoch_ms) : undefined,
211
241
  isDebounced: row.is_debounced ?? false,
242
+ applicationName: row.application_name ?? undefined,
212
243
  };
213
244
  }
214
245
  // SQLSTATE classes/codes that are generally safe to retry
@@ -364,6 +395,7 @@ class SystemDatabase {
364
395
  systemDatabaseUrl;
365
396
  logger;
366
397
  serializer;
398
+ appName;
367
399
  // ==================== Lifecycle ====================
368
400
  pool;
369
401
  schemaName;
@@ -411,10 +443,13 @@ class SystemDatabase {
411
443
  runningWorkflowMap = new Map(); // Map from workflowID to workflow promise, queue name and partition key
412
444
  // Per-partition-key created_at cursors: keep per-key queue order monotonic across batches
413
445
  #batchCreatedAtCursors = new Map();
414
- constructor(systemDatabaseUrl, logger, serializer, sysDbPoolSize = exports.DEFAULT_POOL_SIZE, systemDatabasePool, schemaName = 'dbos', useListenNotify = true, pollingConcurrency, notificationCoalesceMs = exports.DEFAULT_NOTIFICATION_COALESCE_MS) {
446
+ constructor(systemDatabaseUrl, logger, serializer, sysDbPoolSize = exports.DEFAULT_POOL_SIZE, systemDatabasePool, schemaName = 'dbos', useListenNotify = true, pollingConcurrency, notificationCoalesceMs = exports.DEFAULT_NOTIFICATION_COALESCE_MS,
447
+ // The application this handle acts for; undefined writes unclaimed rows.
448
+ appName) {
415
449
  this.systemDatabaseUrl = systemDatabaseUrl;
416
450
  this.logger = logger;
417
451
  this.serializer = serializer;
452
+ this.appName = appName;
418
453
  this.schemaName = schemaName;
419
454
  this.shouldUseDBNotifications = useListenNotify;
420
455
  this.notificationCoalesceMs = notificationCoalesceMs;
@@ -448,6 +483,60 @@ class SystemDatabase {
448
483
  getSerializer() {
449
484
  return this.serializer;
450
485
  }
486
+ // ==================== Application Ownership ====================
487
+ /**
488
+ * A predicate matching rows owned by these applications plus unclaimed ones, which belong to
489
+ * every application; unset or empty matches everything. Appends its bind parameter to `params`.
490
+ */
491
+ #appNameFilter(column, value, params) {
492
+ // An empty name is no name: it is not a value any application could be configured with.
493
+ const names = !value ? [] : Array.isArray(value) ? value : [value];
494
+ if (names.length === 0)
495
+ return 'TRUE';
496
+ params.push(names);
497
+ return `(${column} = ANY($${params.length}) OR ${column} IS NULL)`;
498
+ }
499
+ /**
500
+ * The filter above defaulted to this handle's own application: an unset filter scopes to what
501
+ * this application owns, not to every application's rows. A handle with no application of its
502
+ * own still matches every one.
503
+ */
504
+ #observabilityFilter(column, value, params) {
505
+ return this.#appNameFilter(column, value ?? this.appName, params);
506
+ }
507
+ /**
508
+ * The version name a dequeue treats as latest: this application's own plus unclaimed
509
+ * ones, so a named peer's deploy does not demote this one.
510
+ */
511
+ async #latestApplicationVersionName(client) {
512
+ const params = [];
513
+ const scope = this.#appNameFilter('application_name', this.appName, params);
514
+ const { rows } = await client.query(`SELECT version_name
515
+ FROM "${this.schemaName}".application_versions
516
+ WHERE ${scope}
517
+ ORDER BY version_timestamp DESC LIMIT 1`, params);
518
+ return rows[0]?.version_name;
519
+ }
520
+ /**
521
+ * The owner to persist when writing a row that may already exist. A nameless writer
522
+ * leaves the owner intact; a named one collides only with a different name.
523
+ */
524
+ async #resolveRowOwner(client, table, keyColumn, name, owner, kind) {
525
+ const { rows } = await client.query(`SELECT application_name FROM "${this.schemaName}".${table} WHERE ${keyColumn} = $1`, [name]);
526
+ const current = rows[0]?.application_name ?? null;
527
+ if (current === null)
528
+ return owner;
529
+ if (owner === undefined || current === owner)
530
+ return current;
531
+ // A version name is computed or pinned, so "pick another" is config advice, not a rename.
532
+ const takeANewName = kind === 'Application version'
533
+ ? `set a distinct applicationVersion for '${owner}'`
534
+ : `give '${owner}' a different ${kind.toLowerCase()} name`;
535
+ throw new error_1.DBOSError(`${kind} '${name}' is already registered by application '${current}' in this system database. ` +
536
+ `${kind} names must be unique across applications sharing a system database. ` +
537
+ `Either ${takeANewName}, or, if '${current}' was renamed to '${owner}', ` +
538
+ `re-own its rows first with dbos rename-application`);
539
+ }
451
540
  async init() {
452
541
  await ensureSystemDatabase(this.systemDatabaseUrl, this.logger, this.customPool ? this.pool : undefined, this.schemaName, this.shouldUseDBNotifications);
453
542
  if (this.shouldUseDBNotifications) {
@@ -658,6 +747,7 @@ class SystemDatabase {
658
747
  'delay_until_epoch_ms',
659
748
  'attributes',
660
749
  'schedule_name',
750
+ 'application_name',
661
751
  ];
662
752
  const client = await this.pool.connect();
663
753
  try {
@@ -675,7 +765,7 @@ class SystemDatabase {
675
765
  tuples.push(`(${columns.map(() => `$${paramIdx++}`).join(', ')})`);
676
766
  params.push(status.workflowUUID, status.status, status.workflowName,
677
767
  // For cross-language compatibility, these MUST be NULL in the database when not set
678
- 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);
768
+ 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, status.applicationName ?? null);
679
769
  }
680
770
  const { rows } = await client.query(`INSERT INTO "${this.schemaName}".workflow_status (${columns.join(', ')})
681
771
  VALUES ${tuples.join(', ')}
@@ -734,15 +824,28 @@ class SystemDatabase {
734
824
  return rowCount > 0;
735
825
  }
736
826
  async getPendingWorkflows(executorID, appVersion) {
827
+ const params = [workflow_1.StatusString.PENDING, executorID, appVersion];
828
+ // executor_id defaults to "local", so it collides across applications.
829
+ const scope = this.#appNameFilter('application_name', this.appName, params);
737
830
  const getWorkflows = await this.pool.query(`SELECT workflow_uuid
738
831
  FROM "${this.schemaName}".workflow_status
739
- WHERE status=$1 AND executor_id=$2 AND application_version=$3`, [workflow_1.StatusString.PENDING, executorID, appVersion]);
832
+ WHERE status=$1 AND executor_id=$2 AND application_version=$3 AND ${scope}`, params);
740
833
  return getWorkflows.rows.map((i) => ({
741
834
  workflowUUID: i.workflow_uuid,
742
835
  }));
743
836
  }
744
837
  // Recovery re-enqueues rather than executing directly so the queue's atomic dequeue admits exactly one runner, and the executor ID predicate rejects sweeps for rows a live executor has already claimed.
745
838
  async reenqueueWorkflowsForRecovery(executorID, appVersion, recoveryQueueName) {
839
+ const params = [
840
+ workflow_1.StatusString.ENQUEUED,
841
+ Date.now(),
842
+ recoveryQueueName,
843
+ workflow_1.StatusString.PENDING,
844
+ executorID,
845
+ appVersion,
846
+ ];
847
+ // executor_id defaults to "local", so it collides across applications.
848
+ const scope = this.#appNameFilter('application_name', this.appName, params);
746
849
  const result = await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
747
850
  SET started_at_epoch_ms = NULL,
748
851
  status = $1,
@@ -751,7 +854,8 @@ class SystemDatabase {
751
854
  WHERE status = $4
752
855
  AND executor_id = $5
753
856
  AND application_version = $6
754
- RETURNING workflow_uuid`, [workflow_1.StatusString.ENQUEUED, Date.now(), recoveryQueueName, workflow_1.StatusString.PENDING, executorID, appVersion]);
857
+ AND ${scope}
858
+ RETURNING workflow_uuid`, params);
755
859
  return result.rows.map((row) => row.workflow_uuid);
756
860
  }
757
861
  async getWorkflowStatus(workflowID, callerID, callerFN) {
@@ -877,9 +981,9 @@ class SystemDatabase {
877
981
  // Insert a patchmarker
878
982
  const dn = Date.now();
879
983
  await this.pool.query(`INSERT INTO ${this.schemaName}.operation_outputs
880
- (workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms)
881
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
882
- ON CONFLICT DO NOTHING;`, [workflowID, functionID, null, null, patchName, null, dn, dn]);
984
+ (workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, application_name)
985
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
986
+ ON CONFLICT DO NOTHING;`, [workflowID, functionID, null, null, patchName, null, dn, dn, this.appName ?? null]);
883
987
  return { isPatched: true, hasEntry: true };
884
988
  }
885
989
  // ==================== Workflow Management ====================
@@ -937,8 +1041,9 @@ class SystemDatabase {
937
1041
  * Extend an existing debounced DELAYED workflow's delay and update its inputs, atomically.
938
1042
  * The new delay is capped at the workflow's debounce_deadline_epoch_ms, if one is set.
939
1043
  * Matching on workflow name and class ensures a debounce-key collision between different
940
- * workflows never overwrites another workflow's inputs. If nothing matched, returns the
941
- * current holder (or that the key is unheld) so the caller can start fresh or surface a conflict.
1044
+ * workflows never overwrites another workflow's inputs. The bounce acts for `params.applicationName`:
1045
+ * it extends only that application's holders plus unclaimed ones, claiming those. If nothing matched,
1046
+ * returns the current holder (or that the key is unheld) so the caller can start fresh or surface a conflict.
942
1047
  * Runs on `client` if given, joining its transaction (e.g. a transactional step's);
943
1048
  * otherwise in its own retried transaction.
944
1049
  */
@@ -966,17 +1071,7 @@ class SystemDatabase {
966
1071
  }
967
1072
  async #debounceDelayedWorkflowInternal(client, params) {
968
1073
  const classNameOrNull = params.workflowClassName === '' ? null : params.workflowClassName;
969
- const updated = await client.query(`UPDATE "${this.schemaName}".workflow_status
970
- SET delay_until_epoch_ms = CASE
971
- WHEN debounce_deadline_epoch_ms IS NOT NULL AND debounce_deadline_epoch_ms < $1
972
- THEN debounce_deadline_epoch_ms
973
- ELSE $1
974
- END,
975
- inputs = $2, serialization = $3, updated_at = $4
976
- WHERE name = $5 AND class_name IS NOT DISTINCT FROM $6
977
- AND queue_name = $7 AND deduplication_id = $8
978
- AND status = $9 AND is_debounced = TRUE
979
- RETURNING workflow_uuid`, [
1074
+ const updateParams = [
980
1075
  params.delayUntilEpochMS,
981
1076
  params.input,
982
1077
  params.serialization,
@@ -986,7 +1081,24 @@ class SystemDatabase {
986
1081
  params.queueName,
987
1082
  params.deduplicationID,
988
1083
  workflow_1.StatusString.DELAYED,
989
- ]);
1084
+ params.applicationName ?? null,
1085
+ ];
1086
+ // Never extend a workflow the target application doesn't own; falls through to the holder below.
1087
+ const ownScope = this.#appNameFilter('application_name', params.applicationName, updateParams);
1088
+ const updated = await client.query(`UPDATE "${this.schemaName}".workflow_status
1089
+ SET delay_until_epoch_ms = CASE
1090
+ WHEN debounce_deadline_epoch_ms IS NOT NULL AND debounce_deadline_epoch_ms < $1
1091
+ THEN debounce_deadline_epoch_ms
1092
+ ELSE $1
1093
+ END,
1094
+ inputs = $2, serialization = $3, updated_at = $4,
1095
+ -- Claim it for the target, as its dequeue would: left unclaimed, every peer coalesces onto the one workflow and the last inputs win.
1096
+ application_name = COALESCE(application_name, $10)
1097
+ WHERE name = $5 AND class_name IS NOT DISTINCT FROM $6
1098
+ AND queue_name = $7 AND deduplication_id = $8
1099
+ AND status = $9 AND is_debounced = TRUE
1100
+ AND ${ownScope}
1101
+ RETURNING workflow_uuid`, updateParams);
990
1102
  if (updated.rows.length > 0) {
991
1103
  return {
992
1104
  bouncedWorkflowID: updated.rows[0].workflow_uuid,
@@ -994,10 +1106,11 @@ class SystemDatabase {
994
1106
  holderIsDebounced: false,
995
1107
  holderWorkflowName: null,
996
1108
  holderWorkflowClassName: null,
1109
+ holderApplicationName: null,
997
1110
  };
998
1111
  }
999
- // No match: the key is unheld, or held by a non-debounced or name-colliding workflow.
1000
- const holder = await client.query(`SELECT workflow_uuid, is_debounced, name, class_name
1112
+ // Unscoped, so a holder that blocked the update above is reportable.
1113
+ const holder = await client.query(`SELECT workflow_uuid, is_debounced, name, class_name, application_name
1001
1114
  FROM "${this.schemaName}".workflow_status
1002
1115
  WHERE queue_name = $1 AND deduplication_id = $2`, [params.queueName, params.deduplicationID]);
1003
1116
  if (holder.rows.length === 0) {
@@ -1007,6 +1120,7 @@ class SystemDatabase {
1007
1120
  holderIsDebounced: false,
1008
1121
  holderWorkflowName: null,
1009
1122
  holderWorkflowClassName: null,
1123
+ holderApplicationName: null,
1010
1124
  };
1011
1125
  }
1012
1126
  return {
@@ -1015,6 +1129,7 @@ class SystemDatabase {
1015
1129
  holderIsDebounced: holder.rows[0].is_debounced ?? false,
1016
1130
  holderWorkflowName: holder.rows[0].name,
1017
1131
  holderWorkflowClassName: holder.rows[0].class_name ?? null,
1132
+ holderApplicationName: holder.rows[0].application_name ?? null,
1018
1133
  };
1019
1134
  }
1020
1135
  // Get the immediate (one-level) child workflow IDs for a set of workflows.
@@ -1126,7 +1241,7 @@ class SystemDatabase {
1126
1241
  // Fetch the status of all original workflows inside the transaction.
1127
1242
  const { rows: statusRows } = await client.query(`SELECT workflow_uuid, name, class_name, config_name, application_id,
1128
1243
  authenticated_user, authenticated_roles, assumed_role, inputs, serialization,
1129
- request, application_version, attributes
1244
+ request, application_version, attributes, application_name
1130
1245
  FROM "${this.schemaName}".workflow_status
1131
1246
  WHERE workflow_uuid = ANY($1)`, [originalWorkflowIDs]);
1132
1247
  const statusByID = new Map(statusRows.map((r) => [r.workflow_uuid, r]));
@@ -1155,10 +1270,16 @@ class SystemDatabase {
1155
1270
  'forked_from',
1156
1271
  'serialization',
1157
1272
  'attributes',
1273
+ 'application_name',
1158
1274
  ];
1159
1275
  if (options.timeoutMS !== undefined) {
1160
1276
  insertCols.push('workflow_timeout_ms');
1161
1277
  }
1278
+ // One owner per fork, shared by its status row and its copied steps: the source's, or this application claiming an unclaimed one.
1279
+ const forkOwners = new Map(originalWorkflowIDs.map((origID, i) => [
1280
+ forkedWorkflowIDs[i],
1281
+ statusByID.get(origID).application_name ?? this.appName ?? null,
1282
+ ]));
1162
1283
  const valuesPlaceholders = [];
1163
1284
  const params = [];
1164
1285
  let paramIdx = 1;
@@ -1168,7 +1289,7 @@ class SystemDatabase {
1168
1289
  const ws = statusByID.get(origID);
1169
1290
  const placeholders = insertCols.map(() => `$${paramIdx++}`).join(', ');
1170
1291
  valuesPlaceholders.push(`(${placeholders})`);
1171
- params.push(forkID, workflow_1.StatusString.ENQUEUED, ws.name, ws.class_name ?? null, ws.config_name ?? null, queueName, ws.authenticated_user, ws.assumed_role, ws.authenticated_roles, ws.request, options.applicationVersion ?? ws.application_version ?? null, ws.application_id, ws.inputs, options.queuePartitionKey ?? null, origID, ws.serialization, ws.attributes ? JSON.stringify(ws.attributes) : null);
1292
+ params.push(forkID, workflow_1.StatusString.ENQUEUED, ws.name, ws.class_name ?? null, ws.config_name ?? null, queueName, ws.authenticated_user, ws.assumed_role, ws.authenticated_roles, ws.request, options.applicationVersion ?? ws.application_version ?? null, ws.application_id, ws.inputs, options.queuePartitionKey ?? null, origID, ws.serialization, ws.attributes ? JSON.stringify(ws.attributes) : null, forkOwners.get(forkID) ?? null);
1172
1293
  if (options.timeoutMS !== undefined) {
1173
1294
  params.push(options.timeoutMS);
1174
1295
  }
@@ -1187,11 +1308,12 @@ class SystemDatabase {
1187
1308
  const mappingParams = [];
1188
1309
  let mIdx = 1;
1189
1310
  for (const m of forkMappings) {
1190
- mappingValues.push(`($${mIdx}::text, $${mIdx + 1}::text, $${mIdx + 2}::int)`);
1191
- mappingParams.push(m.origID, m.forkID, m.startStep);
1192
- mIdx += 3;
1311
+ // Cast: an unclaimed fork makes owner a bare NULL the VALUES list cannot type.
1312
+ mappingValues.push(`($${mIdx}::text, $${mIdx + 1}::text, $${mIdx + 2}::int, $${mIdx + 3}::text)`);
1313
+ mappingParams.push(m.origID, m.forkID, m.startStep, forkOwners.get(m.forkID) ?? null);
1314
+ mIdx += 4;
1193
1315
  }
1194
- const mappingCTE = `WITH mapping(orig_id, fork_id, start_step) AS (VALUES ${mappingValues.join(', ')})`;
1316
+ const mappingCTE = `WITH mapping(orig_id, fork_id, start_step, owner) AS (VALUES ${mappingValues.join(', ')})`;
1195
1317
  // Build the child_workflow_id expression, applying replacements if provided.
1196
1318
  let childWfExpr = 'oo.child_workflow_id';
1197
1319
  const ooParams = [...mappingParams];
@@ -1207,8 +1329,8 @@ class SystemDatabase {
1207
1329
  // Copy operation outputs
1208
1330
  await client.query(`${mappingCTE}
1209
1331
  INSERT INTO "${this.schemaName}".operation_outputs
1210
- (workflow_uuid, function_id, output, error, serialization, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms)
1211
- SELECT m.fork_id, oo.function_id, oo.output, oo.error, oo.serialization, oo.function_name, ${childWfExpr}, oo.started_at_epoch_ms, oo.completed_at_epoch_ms
1332
+ (workflow_uuid, function_id, output, error, serialization, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, application_name)
1333
+ SELECT m.fork_id, oo.function_id, oo.output, oo.error, oo.serialization, oo.function_name, ${childWfExpr}, oo.started_at_epoch_ms, oo.completed_at_epoch_ms, m.owner
1212
1334
  FROM mapping m
1213
1335
  JOIN "${this.schemaName}".operation_outputs oo
1214
1336
  ON oo.workflow_uuid = m.orig_id AND oo.function_id < m.start_step`, ooParams);
@@ -1276,7 +1398,7 @@ class SystemDatabase {
1276
1398
  deduplication_id, inputs, priority, queue_partition_key, forked_from,
1277
1399
  parent_workflow_id, serialization, delay_until_epoch_ms,
1278
1400
  was_forked_from, rate_limited, completed_at, attributes, schedule_name,
1279
- debounce_deadline_epoch_ms, is_debounced
1401
+ debounce_deadline_epoch_ms, is_debounced, application_name
1280
1402
  FROM "${this.schemaName}".workflow_status
1281
1403
  WHERE workflow_uuid = $1`, [wfID]);
1282
1404
  if (statusResult.rows.length === 0) {
@@ -1287,7 +1409,7 @@ class SystemDatabase {
1287
1409
  const outputsResult = await client.query(`SELECT
1288
1410
  workflow_uuid, function_id, function_name, output, error,
1289
1411
  child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms,
1290
- serialization
1412
+ serialization, application_name
1291
1413
  FROM "${this.schemaName}".operation_outputs
1292
1414
  WHERE workflow_uuid = $1`, [wfID]);
1293
1415
  // Export workflow_events
@@ -1332,8 +1454,8 @@ class SystemDatabase {
1332
1454
  deduplication_id, inputs, priority, queue_partition_key, forked_from,
1333
1455
  parent_workflow_id, serialization, delay_until_epoch_ms,
1334
1456
  was_forked_from, rate_limited, completed_at, attributes, schedule_name,
1335
- debounce_deadline_epoch_ms, is_debounced
1336
- ) 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, $31, $32, $33, $34, $35, $36)`, [
1457
+ debounce_deadline_epoch_ms, is_debounced, application_name
1458
+ ) 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, $31, $32, $33, $34, $35, $36, $37)`, [
1337
1459
  status.workflow_uuid,
1338
1460
  status.status,
1339
1461
  status.name,
@@ -1372,14 +1494,15 @@ class SystemDatabase {
1372
1494
  status.schedule_name ?? null,
1373
1495
  status.debounce_deadline_epoch_ms ?? null,
1374
1496
  status.is_debounced ?? false,
1497
+ status.application_name ?? null,
1375
1498
  ]);
1376
1499
  // Import operation_outputs
1377
1500
  for (const output of workflow.operation_outputs) {
1378
1501
  await client.query(`INSERT INTO "${this.schemaName}".operation_outputs (
1379
1502
  workflow_uuid, function_id, function_name, output, error,
1380
1503
  child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms,
1381
- serialization
1382
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [
1504
+ serialization, application_name
1505
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [
1383
1506
  output.workflow_uuid,
1384
1507
  output.function_id,
1385
1508
  output.function_name,
@@ -1389,6 +1512,7 @@ class SystemDatabase {
1389
1512
  output.started_at_epoch_ms,
1390
1513
  output.completed_at_epoch_ms,
1391
1514
  output.serialization,
1515
+ output.application_name ?? null,
1392
1516
  ]);
1393
1517
  }
1394
1518
  // Import workflow_events
@@ -2092,10 +2216,13 @@ class SystemDatabase {
2092
2216
  // Transition workflows from DELAYED to ENQUEUED when their delay has expired.
2093
2217
  // For debounced workflows, clear the deduplication ID in the same atomic update: it is a
2094
2218
  // debounce key held only while DELAYED, so a later same-key debounce starts a fresh workflow.
2219
+ const params = [workflow_1.StatusString.ENQUEUED, Date.now(), workflow_1.StatusString.DELAYED];
2220
+ // Only what this application would dequeue: a peer's debounce key is not ours to clear.
2221
+ const scope = this.#appNameFilter('application_name', this.appName, params);
2095
2222
  await this.pool.query(`UPDATE "${this.schemaName}".workflow_status
2096
2223
  SET status = $1, updated_at = $2,
2097
2224
  deduplication_id = CASE WHEN is_debounced THEN NULL ELSE deduplication_id END
2098
- WHERE status = $3 AND delay_until_epoch_ms <= $2`, [workflow_1.StatusString.ENQUEUED, Date.now(), workflow_1.StatusString.DELAYED]);
2225
+ WHERE status = $3 AND delay_until_epoch_ms <= $2 AND ${scope}`, params);
2099
2226
  }
2100
2227
  async getDeduplicatedWorkflow(queueName, deduplicationID) {
2101
2228
  const { rows } = await this.pool.query(`SELECT workflow_uuid FROM "${this.schemaName}".workflow_status
@@ -2107,18 +2234,21 @@ class SystemDatabase {
2107
2234
  }
2108
2235
  async getQueuePartitions(queueName) {
2109
2236
  // Recursive-CTE loose index scan: SELECT DISTINCT would scan every ENQUEUED row, whereas each iteration here is one seek on idx_workflow_status_partition_dequeue_v2, so cost scales with the number of partitions rather than the backlog depth.
2237
+ const params = [queueName, workflow_1.StatusString.ENQUEUED];
2238
+ // Only partitions this application can actually dequeue from.
2239
+ const scope = this.#appNameFilter('application_name', this.appName, params);
2110
2240
  const { rows } = await this.pool.query(`WITH RECURSIVE partitions AS (
2111
2241
  (SELECT MIN(queue_partition_key) AS pk
2112
2242
  FROM "${this.schemaName}".workflow_status
2113
- WHERE queue_name = $1 AND status = $2 AND queue_partition_key IS NOT NULL)
2243
+ WHERE queue_name = $1 AND status = $2 AND queue_partition_key IS NOT NULL AND ${scope})
2114
2244
  UNION ALL
2115
2245
  (SELECT (SELECT MIN(queue_partition_key)
2116
2246
  FROM "${this.schemaName}".workflow_status
2117
- WHERE queue_name = $1 AND status = $2 AND queue_partition_key > partitions.pk)
2247
+ WHERE queue_name = $1 AND status = $2 AND queue_partition_key > partitions.pk AND ${scope})
2118
2248
  FROM partitions
2119
2249
  WHERE partitions.pk IS NOT NULL)
2120
2250
  )
2121
- SELECT pk FROM partitions WHERE pk IS NOT NULL`, [queueName, workflow_1.StatusString.ENQUEUED]);
2251
+ SELECT pk FROM partitions WHERE pk IS NOT NULL`, params);
2122
2252
  return rows.map((row) => row.pk);
2123
2253
  }
2124
2254
  async findAndMarkStartableWorkflows(queue, executorID, appVersion, queuePartitionKey) {
@@ -2152,11 +2282,14 @@ class SystemDatabase {
2152
2282
  startTimeMs - limiterPeriodMS,
2153
2283
  ...partitionParams,
2154
2284
  ];
2285
+ // Count only what this application would dequeue, matching the select below.
2286
+ const scope = this.#appNameFilter('application_name', this.appName, params);
2155
2287
  const countResult = await client.query(`SELECT COUNT(*) FROM "${this.schemaName}".workflow_status
2156
2288
  WHERE queue_name = $1
2157
2289
  AND rate_limited = TRUE
2158
2290
  AND status NOT IN ($2, $3)
2159
2291
  AND started_at_epoch_ms > $4
2292
+ AND ${scope}
2160
2293
  ${partitionFilter.replace('$PARTITION', '$5')}`, params);
2161
2294
  numRecentQueries = Number(countResult.rows[0].count);
2162
2295
  if (numRecentQueries >= queue.rateLimit.limitPerPeriod) {
@@ -2175,9 +2308,10 @@ class SystemDatabase {
2175
2308
  if (queue.concurrency !== undefined) {
2176
2309
  // Global concurrency still requires a DB query since other workers may be running workflows too.
2177
2310
  const params = [queue.name, workflow_1.StatusString.PENDING, ...partitionParams];
2311
+ const scope = this.#appNameFilter('application_name', this.appName, params);
2178
2312
  const runningTasksResult = await client.query(`SELECT COUNT(*) as task_count
2179
2313
  FROM "${this.schemaName}".workflow_status
2180
- WHERE queue_name = $1 AND status = $2
2314
+ WHERE queue_name = $1 AND status = $2 AND ${scope}
2181
2315
  ${partitionFilter.replace('$PARTITION', '$3')}`, params);
2182
2316
  const totalRunningTasks = Number(runningTasksResult.rows[0]?.task_count ?? 0);
2183
2317
  if (totalRunningTasks > queue.concurrency) {
@@ -2192,10 +2326,7 @@ class SystemDatabase {
2192
2326
  return claimedIDs;
2193
2327
  }
2194
2328
  // Retrieve the first max_tasks workflows in the queue.
2195
- const latestVersionResult = await client.query(`SELECT version_name
2196
- FROM "${this.schemaName}".application_versions
2197
- ORDER BY version_timestamp DESC LIMIT 1`);
2198
- const latestVersion = latestVersionResult.rows[0]?.version_name;
2329
+ const latestVersion = await this.#latestApplicationVersionName(client);
2199
2330
  const isLatestVersion = latestVersion === undefined || latestVersion === appVersion;
2200
2331
  const versionClause = isLatestVersion
2201
2332
  ? '(application_version = $3 OR application_version IS NULL)'
@@ -2203,12 +2334,14 @@ class SystemDatabase {
2203
2334
  const lockMode = queue.concurrency ? 'FOR UPDATE NOWAIT' : 'FOR UPDATE SKIP LOCKED';
2204
2335
  const limitClause = maxTasks !== Infinity ? `LIMIT ${maxTasks}` : '';
2205
2336
  const selectParams = [workflow_1.StatusString.ENQUEUED, queue.name, appVersion, ...partitionParams];
2337
+ const selectScope = this.#appNameFilter('application_name', this.appName, selectParams);
2206
2338
  const selectQuery = `
2207
2339
  SELECT workflow_uuid
2208
2340
  FROM "${this.schemaName}".workflow_status
2209
2341
  WHERE status = $1
2210
2342
  AND queue_name = $2
2211
2343
  AND ${versionClause}
2344
+ AND ${selectScope}
2212
2345
  ${partitionFilter.replace('$PARTITION', '$4')}
2213
2346
  ORDER BY priority ASC, created_at ASC
2214
2347
  ${limitClause}
@@ -2229,26 +2362,32 @@ class SystemDatabase {
2229
2362
  // Start the functions by marking them as pending and updating their executor IDs.
2230
2363
  // Only claim the workflow if the UPDATE actually transitioned an ENQUEUED row —
2231
2364
  // otherwise another worker won the race and we must not re-dispatch it.
2365
+ const updateParams = [
2366
+ workflow_1.StatusString.PENDING,
2367
+ executorID,
2368
+ appVersion,
2369
+ startTimeMs,
2370
+ queue.rateLimit !== undefined,
2371
+ id,
2372
+ workflow_1.StatusString.ENQUEUED,
2373
+ // Claim an unclaimed row for this application; a nameless dequeuer leaves ownership untouched.
2374
+ this.appName ?? null,
2375
+ ];
2376
+ // Re-check ownership alongside status, as the partitioned claim guard does.
2377
+ const claimScope = this.#appNameFilter('application_name', this.appName, updateParams);
2232
2378
  const updateRes = await client.query(`UPDATE "${this.schemaName}".workflow_status
2233
2379
  SET status = $1,
2234
2380
  executor_id = $2,
2235
2381
  application_version = $3,
2236
2382
  started_at_epoch_ms = $4,
2237
2383
  rate_limited = $5,
2384
+ application_name = COALESCE(application_name, $8),
2238
2385
  workflow_deadline_epoch_ms = CASE
2239
2386
  WHEN workflow_timeout_ms IS NOT NULL AND workflow_deadline_epoch_ms IS NULL
2240
2387
  THEN (EXTRACT(epoch FROM now()) * 1000)::bigint + workflow_timeout_ms
2241
2388
  ELSE workflow_deadline_epoch_ms
2242
2389
  END
2243
- WHERE workflow_uuid = $6 AND status = $7`, [
2244
- workflow_1.StatusString.PENDING,
2245
- executorID,
2246
- appVersion,
2247
- startTimeMs,
2248
- queue.rateLimit !== undefined,
2249
- id,
2250
- workflow_1.StatusString.ENQUEUED,
2251
- ]);
2390
+ WHERE workflow_uuid = $6 AND status = $7 AND ${claimScope}`, updateParams);
2252
2391
  if ((updateRes.rowCount ?? 0) > 0) {
2253
2392
  claimedIDs.push(id);
2254
2393
  }
@@ -2277,23 +2416,28 @@ class SystemDatabase {
2277
2416
  const client = await this.pool.connect();
2278
2417
  try {
2279
2418
  await client.query('BEGIN');
2280
- const latestVersionResult = await client.query(`SELECT version_name
2281
- FROM "${this.schemaName}".application_versions
2282
- ORDER BY version_timestamp DESC LIMIT 1`);
2283
- const latestVersion = latestVersionResult.rows[0]?.version_name;
2419
+ const latestVersion = await this.#latestApplicationVersionName(client);
2284
2420
  const isLatestVersion = latestVersion === undefined || latestVersion === appVersion;
2285
2421
  const versionClause = (n) => isLatestVersion
2286
2422
  ? `(application_version = $${n} OR application_version IS NULL)`
2287
2423
  : `application_version = $${n}`;
2424
+ const candidateParams = [
2425
+ queue.name,
2426
+ workflow_1.StatusString.ENQUEUED,
2427
+ appVersion,
2428
+ workflow_1.StatusString.PENDING,
2429
+ this.partitionedDequeueSweepCap,
2430
+ ];
2431
+ const candidateScope = this.#appNameFilter('application_name', this.appName, candidateParams);
2288
2432
  // Walk distinct partition keys with a recursive-CTE loose index scan (one seek per key, mirroring getQueuePartitions) so sweep cost scales with partition count, not backlog depth.
2289
2433
  const candidateResult = await client.query(`WITH RECURSIVE partitions AS (
2290
2434
  (SELECT MIN(queue_partition_key) AS pk
2291
2435
  FROM "${this.schemaName}".workflow_status
2292
- WHERE queue_name = $1 AND status = $2 AND queue_partition_key IS NOT NULL)
2436
+ WHERE queue_name = $1 AND status = $2 AND queue_partition_key IS NOT NULL AND ${candidateScope})
2293
2437
  UNION ALL
2294
2438
  (SELECT (SELECT MIN(queue_partition_key)
2295
2439
  FROM "${this.schemaName}".workflow_status
2296
- WHERE queue_name = $1 AND status = $2 AND queue_partition_key > partitions.pk)
2440
+ WHERE queue_name = $1 AND status = $2 AND queue_partition_key > partitions.pk AND ${candidateScope})
2297
2441
  FROM partitions
2298
2442
  WHERE partitions.pk IS NOT NULL)
2299
2443
  )
@@ -2306,12 +2450,13 @@ class SystemDatabase {
2306
2450
  WHERE queue_name = $1 AND status = $2
2307
2451
  AND queue_partition_key = partitions.pk
2308
2452
  AND ${versionClause(3)}
2453
+ AND ${candidateScope}
2309
2454
  -- workflow_uuid totalizes the head order (same head for every worker under created_at ties) and the index's trailing workflow_uuid keeps this a pure top-1 probe.
2310
2455
  ORDER BY priority ASC, created_at ASC, workflow_uuid ASC
2311
2456
  LIMIT 1
2312
2457
  ) head ON TRUE
2313
2458
  WHERE partitions.pk IS NOT NULL
2314
- -- Admit a partition's head only while nothing in that partition runs anywhere, on any app version.
2459
+ -- Unscoped by design: a mutual-exclusion probe must block on any owner's row.
2315
2460
  AND NOT EXISTS (
2316
2461
  SELECT 1
2317
2462
  FROM "${this.schemaName}".workflow_status
@@ -2319,7 +2464,7 @@ class SystemDatabase {
2319
2464
  AND queue_partition_key IS NOT NULL AND queue_partition_key = partitions.pk
2320
2465
  )
2321
2466
  ORDER BY partitions.pk ASC
2322
- LIMIT $5`, [queue.name, workflow_1.StatusString.ENQUEUED, appVersion, workflow_1.StatusString.PENDING, this.partitionedDequeueSweepCap]);
2467
+ LIMIT $5`, candidateParams);
2323
2468
  const candidateIDs = candidateResult.rows.map((row) => row.workflow_uuid);
2324
2469
  if (candidateIDs.length === 0) {
2325
2470
  await client.query('COMMIT');
@@ -2327,16 +2472,19 @@ class SystemDatabase {
2327
2472
  }
2328
2473
  await (0, debugpoint_1.debugTriggerPoint)(debugpoint_1.DEBUG_TRIGGER_PARTITIONED_DEQUEUE_AFTER_CANDIDATES);
2329
2474
  // Re-check queue/partition/version alongside status so a row resumeWorkflows moved to another queue mid-sweep is dropped, not hijacked.
2330
- const claimGuard = (ids, status, name, version) => `workflow_uuid = ANY($${ids}::text[])
2475
+ const claimGuard = (ids, status, name, version, scope) => `workflow_uuid = ANY($${ids}::text[])
2331
2476
  AND status = $${status}
2332
2477
  AND queue_name = $${name}
2333
2478
  AND queue_partition_key IS NOT NULL
2334
- AND ${versionClause(version)}`;
2479
+ AND ${versionClause(version)}
2480
+ AND ${scope}`;
2481
+ const lockedParams = [candidateIDs, workflow_1.StatusString.ENQUEUED, queue.name, appVersion];
2482
+ const lockedScope = this.#appNameFilter('application_name', this.appName, lockedParams);
2335
2483
  // Lock the fixed candidate set — never a LIMIT query, whose SKIP LOCKED could slide past a locked head and admit out of order.
2336
2484
  const lockedResult = await client.query(`SELECT workflow_uuid
2337
2485
  FROM "${this.schemaName}".workflow_status
2338
- WHERE ${claimGuard(1, 2, 3, 4)}
2339
- FOR UPDATE SKIP LOCKED`, [candidateIDs, workflow_1.StatusString.ENQUEUED, queue.name, appVersion]);
2486
+ WHERE ${claimGuard(1, 2, 3, 4, lockedScope)}
2487
+ FOR UPDATE SKIP LOCKED`, lockedParams);
2340
2488
  const lockedIDs = new Set(lockedResult.rows.map((row) => row.workflow_uuid));
2341
2489
  // Preserve partition order for submission.
2342
2490
  const claimIDs = candidateIDs.filter((id) => lockedIDs.has(id));
@@ -2344,6 +2492,18 @@ class SystemDatabase {
2344
2492
  await client.query('COMMIT');
2345
2493
  return [];
2346
2494
  }
2495
+ const flipParams = [
2496
+ workflow_1.StatusString.PENDING,
2497
+ executorID,
2498
+ claimIDs,
2499
+ workflow_1.StatusString.ENQUEUED,
2500
+ queue.name,
2501
+ appVersion,
2502
+ startTimeMs,
2503
+ // Claim the row, as the unpartitioned dequeue does.
2504
+ this.appName ?? null,
2505
+ ];
2506
+ const flipScope = this.#appNameFilter('application_name', this.appName, flipParams);
2347
2507
  // Start the workflows by marking them PENDING; RETURNING reports exactly the rows this statement flipped.
2348
2508
  const flippedResult = await client.query(`UPDATE "${this.schemaName}".workflow_status
2349
2509
  SET status = $1,
@@ -2351,13 +2511,14 @@ class SystemDatabase {
2351
2511
  application_version = $6,
2352
2512
  started_at_epoch_ms = $7,
2353
2513
  rate_limited = FALSE,
2514
+ application_name = COALESCE(application_name, $8),
2354
2515
  workflow_deadline_epoch_ms = CASE
2355
2516
  WHEN workflow_timeout_ms IS NOT NULL AND workflow_deadline_epoch_ms IS NULL
2356
2517
  THEN (EXTRACT(epoch FROM now()) * 1000)::bigint + workflow_timeout_ms
2357
2518
  ELSE workflow_deadline_epoch_ms
2358
2519
  END
2359
- WHERE ${claimGuard(3, 4, 5, 6)}
2360
- RETURNING workflow_uuid`, [workflow_1.StatusString.PENDING, executorID, claimIDs, workflow_1.StatusString.ENQUEUED, queue.name, appVersion, startTimeMs]);
2520
+ WHERE ${claimGuard(3, 4, 5, 6, flipScope)}
2521
+ RETURNING workflow_uuid`, flipParams);
2361
2522
  await client.query('COMMIT');
2362
2523
  const flippedIDs = new Set(flippedResult.rows.map((row) => row.workflow_uuid));
2363
2524
  const claimedIDs = claimIDs.filter((id) => flippedIDs.has(id));
@@ -2408,6 +2569,7 @@ class SystemDatabase {
2408
2569
  'schedule_name',
2409
2570
  'debounce_deadline_epoch_ms',
2410
2571
  'is_debounced',
2572
+ 'application_name',
2411
2573
  ];
2412
2574
  input.loadInput = input.loadInput ?? true;
2413
2575
  input.loadOutput = input.loadOutput ?? true;
@@ -2452,6 +2614,15 @@ class SystemDatabase {
2452
2614
  addFilter('name', input.workflowName);
2453
2615
  addFilter('queue_name', input.queueName);
2454
2616
  addFilter('schedule_name', input.scheduleName);
2617
+ // A workflow ID is a global address, so an ID-keyed read is an identity read: it takes an
2618
+ // explicit filter but is never defaulted to this one. Otherwise unset scopes to this
2619
+ // application, as on every other observability query.
2620
+ // Falsy, not just undefined: a Conductor request carries an omitted list as JSON null.
2621
+ const idKeyed = (input.workflowIDs?.length ?? 0) > 0;
2622
+ whereClauses.push(idKeyed
2623
+ ? this.#appNameFilter('application_name', input.applicationName, params)
2624
+ : this.#observabilityFilter('application_name', input.applicationName, params));
2625
+ paramCounter = params.length + 1;
2455
2626
  if (input.workflow_id_prefix) {
2456
2627
  if (Array.isArray(input.workflow_id_prefix)) {
2457
2628
  const likeClauses = input.workflow_id_prefix.map((_, i) => `workflow_uuid LIKE $${paramCounter + i}`);
@@ -2554,6 +2725,7 @@ class SystemDatabase {
2554
2725
  ['queue_name', input.groupByQueueName ?? false, 'queue_name'],
2555
2726
  ['executor_id', input.groupByExecutorId ?? false, 'executor_id'],
2556
2727
  ['application_version', input.groupByApplicationVersion ?? false, 'application_version'],
2728
+ ['application_name', input.groupByApplicationName ?? false, 'application_name'],
2557
2729
  ];
2558
2730
  const groupNames = [];
2559
2731
  const groupColumns = [];
@@ -2624,6 +2796,9 @@ class SystemDatabase {
2624
2796
  addFilter('forked_from', input.forkedFrom);
2625
2797
  addFilter('parent_workflow_id', input.parentWorkflowID);
2626
2798
  addFilter('schedule_name', input.scheduleName);
2799
+ // Unset scopes to this application, as on every other observability query.
2800
+ whereClauses.push(this.#observabilityFilter('application_name', input.applicationName, params));
2801
+ paramIdx = params.length + 1;
2627
2802
  // Only workflows that are actively enqueued.
2628
2803
  if (input.queuesOnly) {
2629
2804
  whereClauses.push(`queue_name IS NOT NULL`);
@@ -2786,6 +2961,9 @@ class SystemDatabase {
2786
2961
  params.push(new Date(input.completedBefore).getTime());
2787
2962
  paramIdx++;
2788
2963
  }
2964
+ // Unset scopes to this application, as on every other observability query.
2965
+ whereClauses.push(this.#observabilityFilter('application_name', input.applicationName, params));
2966
+ paramIdx = params.length + 1;
2789
2967
  const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
2790
2968
  const groupByClause = groupColumns.join(', ');
2791
2969
  const selectClause = [...groupSelectColumns, ...selectColumns].join(', ');
@@ -2814,10 +2992,13 @@ class SystemDatabase {
2814
2992
  async garbageCollect(cutoffEpochTimestampMs, rowsThreshold) {
2815
2993
  if (rowsThreshold !== undefined && rowsThreshold !== null) {
2816
2994
  // Get the created_at timestamp of the rows_threshold newest row
2995
+ const params = [rowsThreshold - 1];
2996
+ const scope = this.#appNameFilter('application_name', this.appName, params);
2817
2997
  const result = await this.pool.query(`SELECT created_at
2818
2998
  FROM "${this.schemaName}".workflow_status
2999
+ WHERE ${scope}
2819
3000
  ORDER BY created_at DESC
2820
- LIMIT 1 OFFSET $1`, [rowsThreshold - 1]);
3001
+ LIMIT 1 OFFSET $1`, params);
2821
3002
  if (result.rows.length > 0) {
2822
3003
  const rowsBasedCutoff = result.rows[0].created_at;
2823
3004
  // Use the more restrictive cutoff (higher timestamp = more recent = more deletion)
@@ -2831,21 +3012,51 @@ class SystemDatabase {
2831
3012
  if (cutoffEpochTimestampMs === undefined || cutoffEpochTimestampMs === null) {
2832
3013
  return;
2833
3014
  }
3015
+ const deleteParams = [
3016
+ cutoffEpochTimestampMs,
3017
+ workflow_1.StatusString.PENDING,
3018
+ workflow_1.StatusString.ENQUEUED,
3019
+ workflow_1.StatusString.DELAYED,
3020
+ ];
3021
+ // Unclaimed rows included: excluding them would leak pre-upgrade rows forever.
3022
+ const deleteScope = this.#appNameFilter('application_name', this.appName, deleteParams);
2834
3023
  // Delete all workflows older than cutoff that are NOT PENDING, ENQUEUED, or DELAYED
2835
3024
  await this.pool.query(`DELETE FROM "${this.schemaName}".workflow_status
2836
3025
  WHERE created_at < $1
2837
- AND status NOT IN ($2, $3, $4)`, [cutoffEpochTimestampMs, workflow_1.StatusString.PENDING, workflow_1.StatusString.ENQUEUED, workflow_1.StatusString.DELAYED]);
3026
+ AND status NOT IN ($2, $3, $4)
3027
+ AND ${deleteScope}`, deleteParams);
2838
3028
  return;
2839
3029
  }
2840
- async getMetrics(startTime, endTime) {
3030
+ /**
3031
+ * IDs of this application's in-flight workflows created at or before the cutoff.
3032
+ * Claiming-scoped, so an upgrade still times out its own unclaimed workflows.
3033
+ */
3034
+ async listTimedOutWorkflowIds(cutoffEpochTimestampMs) {
3035
+ const params = [
3036
+ cutoffEpochTimestampMs,
3037
+ workflow_1.StatusString.PENDING,
3038
+ workflow_1.StatusString.ENQUEUED,
3039
+ workflow_1.StatusString.DELAYED,
3040
+ ];
3041
+ const scope = this.#appNameFilter('application_name', this.appName, params);
3042
+ const { rows } = await this.pool.query(`SELECT workflow_uuid
3043
+ FROM "${this.schemaName}".workflow_status
3044
+ WHERE created_at <= $1
3045
+ AND status IN ($2, $3, $4)
3046
+ AND ${scope}`, params);
3047
+ return rows.map((row) => row.workflow_uuid);
3048
+ }
3049
+ async getMetrics(startTime, endTime, applicationName) {
2841
3050
  const startEpochMs = new Date(startTime).getTime();
2842
3051
  const endEpochMs = new Date(endTime).getTime();
2843
3052
  const metrics = [];
2844
3053
  // Query workflow metrics
3054
+ const workflowParams = [startEpochMs, endEpochMs];
3055
+ const workflowScope = this.#observabilityFilter('application_name', applicationName, workflowParams);
2845
3056
  const workflowResult = await this.pool.query(`SELECT name, COUNT(workflow_uuid) as count
2846
3057
  FROM "${this.schemaName}".workflow_status
2847
- WHERE created_at >= $1 AND created_at < $2
2848
- GROUP BY name`, [startEpochMs, endEpochMs]);
3058
+ WHERE created_at >= $1 AND created_at < $2 AND ${workflowScope}
3059
+ GROUP BY name`, workflowParams);
2849
3060
  for (const row of workflowResult.rows) {
2850
3061
  metrics.push({
2851
3062
  metricType: 'workflow_count',
@@ -2854,10 +3065,12 @@ class SystemDatabase {
2854
3065
  });
2855
3066
  }
2856
3067
  // Query step metrics
3068
+ const stepParams = [startEpochMs, endEpochMs];
3069
+ const stepScope = this.#observabilityFilter('application_name', applicationName, stepParams);
2857
3070
  const stepResult = await this.pool.query(`SELECT function_name, COUNT(*) as count
2858
3071
  FROM "${this.schemaName}".operation_outputs
2859
- WHERE completed_at_epoch_ms >= $1 AND completed_at_epoch_ms < $2
2860
- GROUP BY function_name`, [startEpochMs, endEpochMs]);
3072
+ WHERE completed_at_epoch_ms >= $1 AND completed_at_epoch_ms < $2 AND ${stepScope}
3073
+ GROUP BY function_name`, stepParams);
2861
3074
  for (const row of stepResult.rows) {
2862
3075
  metrics.push({
2863
3076
  metricType: 'step_count',
@@ -2870,10 +3083,11 @@ class SystemDatabase {
2870
3083
  // ==================== Scheduling ====================
2871
3084
  async createSchedule(schedule, client) {
2872
3085
  const q = client ?? this.pool;
3086
+ const owner = await this.#resolveRowOwner(q, 'workflow_schedules', 'schedule_name', schedule.scheduleName, schedule.applicationName, 'Schedule');
2873
3087
  try {
2874
3088
  await q.query(`INSERT INTO "${this.schemaName}".workflow_schedules
2875
- (schedule_id, schedule_name, workflow_name, workflow_class_name, schedule, status, context, last_fired_at, automatic_backfill, cron_timezone, queue_name)
2876
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [
3089
+ (schedule_id, schedule_name, workflow_name, workflow_class_name, schedule, status, context, last_fired_at, automatic_backfill, cron_timezone, queue_name, application_name)
3090
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, [
2877
3091
  schedule.scheduleId,
2878
3092
  schedule.scheduleName,
2879
3093
  schedule.workflowName,
@@ -2885,6 +3099,7 @@ class SystemDatabase {
2885
3099
  schedule.automaticBackfill,
2886
3100
  schedule.cronTimezone,
2887
3101
  schedule.queueName,
3102
+ owner ?? null,
2888
3103
  ]);
2889
3104
  }
2890
3105
  catch (e) {
@@ -2894,6 +3109,10 @@ class SystemDatabase {
2894
3109
  throw e;
2895
3110
  }
2896
3111
  }
3112
+ /**
3113
+ * List only schedules owned by these applications, plus unclaimed ones.
3114
+ * By default, only list this application's schedules.
3115
+ */
2897
3116
  async listSchedules(filters, client) {
2898
3117
  const q = client ?? this.pool;
2899
3118
  const conditions = [];
@@ -2925,45 +3144,23 @@ class SystemDatabase {
2925
3144
  });
2926
3145
  conditions.push(`(${likeClauses.join(' OR ')})`);
2927
3146
  }
3147
+ // Unset scopes to this application, as on every other observability query.
3148
+ conditions.push(this.#observabilityFilter('application_name', filters?.applicationName, params));
3149
+ paramIdx = params.length + 1;
2928
3150
  const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
2929
- const result = await q.query(`SELECT schedule_id, schedule_name, workflow_name, workflow_class_name, schedule, status, context, last_fired_at, automatic_backfill, cron_timezone, queue_name
3151
+ const result = await q.query(`SELECT ${SCHEDULE_COLUMNS}
2930
3152
  FROM "${this.schemaName}".workflow_schedules${where}
2931
3153
  ORDER BY schedule_name`, params);
2932
- return result.rows.map((row) => ({
2933
- scheduleId: row.schedule_id,
2934
- scheduleName: row.schedule_name,
2935
- workflowName: row.workflow_name,
2936
- workflowClassName: row.workflow_class_name,
2937
- schedule: row.schedule,
2938
- status: row.status,
2939
- context: row.context,
2940
- lastFiredAt: row.last_fired_at ?? null,
2941
- automaticBackfill: !!row.automatic_backfill,
2942
- cronTimezone: row.cron_timezone ?? null,
2943
- queueName: row.queue_name ?? null,
2944
- }));
3154
+ return result.rows.map((row) => mapWorkflowSchedule(row));
2945
3155
  }
2946
3156
  async getSchedule(name, client) {
2947
3157
  const q = client ?? this.pool;
2948
- const result = await q.query(`SELECT schedule_id, schedule_name, workflow_name, workflow_class_name, schedule, status, context, last_fired_at, automatic_backfill, cron_timezone, queue_name
3158
+ const result = await q.query(`SELECT ${SCHEDULE_COLUMNS}
2949
3159
  FROM "${this.schemaName}".workflow_schedules
2950
3160
  WHERE schedule_name = $1`, [name]);
2951
3161
  if (result.rows.length === 0)
2952
3162
  return null;
2953
- const row = result.rows[0];
2954
- return {
2955
- scheduleId: row.schedule_id,
2956
- scheduleName: row.schedule_name,
2957
- workflowName: row.workflow_name,
2958
- workflowClassName: row.workflow_class_name,
2959
- schedule: row.schedule,
2960
- status: row.status,
2961
- context: row.context,
2962
- lastFiredAt: row.last_fired_at ?? null,
2963
- automaticBackfill: !!row.automatic_backfill,
2964
- cronTimezone: row.cron_timezone ?? null,
2965
- queueName: row.queue_name ?? null,
2966
- };
3163
+ return mapWorkflowSchedule(result.rows[0]);
2967
3164
  }
2968
3165
  async deleteSchedule(name, client) {
2969
3166
  const q = client ?? this.pool;
@@ -3019,10 +3216,11 @@ class SystemDatabase {
3019
3216
  try {
3020
3217
  await client.query('BEGIN');
3021
3218
  for (const sched of schedules) {
3219
+ const owner = await this.#resolveRowOwner(client, 'workflow_schedules', 'schedule_name', sched.scheduleName, sched.applicationName, 'Schedule');
3022
3220
  // Upsert on schedule_name; on conflict, preserve schedule_id and runtime state (status, last_fired_at) and update only the declared definition fields, so an unchanged re-apply is a no-op.
3023
3221
  await client.query(`INSERT INTO "${this.schemaName}".workflow_schedules
3024
- (schedule_id, schedule_name, workflow_name, workflow_class_name, schedule, status, context, last_fired_at, automatic_backfill, cron_timezone, queue_name)
3025
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
3222
+ (schedule_id, schedule_name, workflow_name, workflow_class_name, schedule, status, context, last_fired_at, automatic_backfill, cron_timezone, queue_name, application_name)
3223
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
3026
3224
  ON CONFLICT (schedule_name) DO UPDATE SET
3027
3225
  workflow_name = EXCLUDED.workflow_name,
3028
3226
  workflow_class_name = EXCLUDED.workflow_class_name,
@@ -3030,7 +3228,9 @@ class SystemDatabase {
3030
3228
  context = EXCLUDED.context,
3031
3229
  automatic_backfill = EXCLUDED.automatic_backfill,
3032
3230
  cron_timezone = EXCLUDED.cron_timezone,
3033
- queue_name = EXCLUDED.queue_name`, [
3231
+ queue_name = EXCLUDED.queue_name,
3232
+ -- Claim only an unclaimed row, so a registration landing between the check above and this write keeps the name it took.
3233
+ application_name = COALESCE("${this.schemaName}".workflow_schedules.application_name, EXCLUDED.application_name)`, [
3034
3234
  sched.scheduleId,
3035
3235
  sched.scheduleName,
3036
3236
  sched.workflowName,
@@ -3042,7 +3242,10 @@ class SystemDatabase {
3042
3242
  sched.automaticBackfill,
3043
3243
  sched.cronTimezone,
3044
3244
  sched.queueName,
3245
+ owner ?? null,
3045
3246
  ]);
3247
+ // Read back, since the guard above is silent about why it declined to claim.
3248
+ await this.#resolveRowOwner(client, 'workflow_schedules', 'schedule_name', sched.scheduleName, sched.applicationName, 'Schedule');
3046
3249
  }
3047
3250
  await client.query('COMMIT');
3048
3251
  }
@@ -3055,56 +3258,109 @@ class SystemDatabase {
3055
3258
  }
3056
3259
  }
3057
3260
  // ==================== Application Versions ====================
3058
- async createApplicationVersion(versionName) {
3059
- const versionId = (0, crypto_1.randomUUID)();
3060
- await this.pool.query(`INSERT INTO "${this.schemaName}".application_versions (version_id, version_name)
3061
- VALUES ($1, $2)
3062
- ON CONFLICT (version_name) DO NOTHING`, [versionId, versionName]);
3261
+ /**
3262
+ * Register this version, claiming the row if nobody owns it yet so a pinned version
3263
+ * does not stay unclaimed. A peer's name is a collision, which is why this throws.
3264
+ */
3265
+ async createApplicationVersion(versionName, applicationName) {
3266
+ const owner = applicationName ?? this.appName;
3267
+ const client = await this.pool.connect();
3268
+ try {
3269
+ await client.query('BEGIN');
3270
+ // Claim a pre-upgrade row in place, so the version is not recreated or retimed.
3271
+ const claimed = await client.query(`UPDATE "${this.schemaName}".application_versions
3272
+ SET application_name = $1
3273
+ WHERE version_name = $2 AND application_name IS NULL`, [owner ?? null, versionName]);
3274
+ if ((claimed.rowCount ?? 0) === 0) {
3275
+ // Targetless DO NOTHING: names no arbiter, so it survives version_name's uniqueness being dropped while still absorbing a concurrent registrar.
3276
+ await client.query(`INSERT INTO "${this.schemaName}".application_versions (version_id, version_name, application_name)
3277
+ VALUES ($1, $2, $3)
3278
+ ON CONFLICT DO NOTHING`, [(0, crypto_1.randomUUID)(), versionName, owner ?? null]);
3279
+ }
3280
+ // Read back, since the writes above are silent about why they declined to claim.
3281
+ await this.#resolveRowOwner(client, 'application_versions', 'version_name', versionName, owner, 'Application version');
3282
+ await client.query('COMMIT');
3283
+ }
3284
+ catch (e) {
3285
+ await client.query('ROLLBACK');
3286
+ throw e;
3287
+ }
3288
+ finally {
3289
+ client.release();
3290
+ }
3063
3291
  }
3064
- async updateApplicationVersionTimestamp(versionName, newTimestamp) {
3065
- await this.pool.query(`UPDATE "${this.schemaName}".application_versions
3066
- SET version_timestamp = $1
3067
- WHERE version_name = $2`, [newTimestamp, versionName]);
3292
+ /**
3293
+ * Promote a version to latest. Promoting a peer's is a collision, not a retiming;
3294
+ * promotion claims an unclaimed row, which would otherwise be every peer's latest.
3295
+ */
3296
+ async updateApplicationVersionTimestamp(versionName, newTimestamp, applicationName) {
3297
+ const owner = applicationName ?? this.appName;
3298
+ const client = await this.pool.connect();
3299
+ try {
3300
+ await client.query('BEGIN');
3301
+ const resolved = await this.#resolveRowOwner(client, 'application_versions', 'version_name', versionName, owner, 'Application version');
3302
+ // Scoped to the row this writer resolved to: once version_name is no longer globally unique, a bare name match would retime every peer's version.
3303
+ const scope = resolved === undefined ? 'application_name IS NULL' : '(application_name = $3 OR application_name IS NULL)';
3304
+ const params = [newTimestamp, versionName];
3305
+ if (resolved !== undefined)
3306
+ params.push(resolved);
3307
+ await client.query(`UPDATE "${this.schemaName}".application_versions
3308
+ SET version_timestamp = $1, application_name = ${resolved === undefined ? 'application_name' : '$3'}
3309
+ WHERE version_name = $2 AND ${scope}`, params);
3310
+ await client.query('COMMIT');
3311
+ }
3312
+ catch (e) {
3313
+ await client.query('ROLLBACK');
3314
+ throw e;
3315
+ }
3316
+ finally {
3317
+ client.release();
3318
+ }
3068
3319
  }
3069
3320
  async listApplicationVersions() {
3070
- const { rows } = await this.pool.query(`SELECT version_id, version_name, version_timestamp, created_at
3321
+ const params = [];
3322
+ const scope = this.#appNameFilter('application_name', this.appName, params);
3323
+ const { rows } = await this.pool.query(`SELECT version_id, version_name, version_timestamp, created_at, application_name
3071
3324
  FROM "${this.schemaName}".application_versions
3072
- ORDER BY version_timestamp DESC`);
3073
- return rows.map((r) => ({
3074
- versionId: r.version_id,
3075
- versionName: r.version_name,
3076
- versionTimestamp: Number(r.version_timestamp),
3077
- createdAt: Number(r.created_at),
3078
- }));
3325
+ WHERE ${scope}
3326
+ ORDER BY version_timestamp DESC`, params);
3327
+ return rows.map(mapVersionInfo);
3079
3328
  }
3080
- async getLatestApplicationVersion() {
3081
- const { rows } = await this.pool.query(`SELECT version_id, version_name, version_timestamp, created_at
3329
+ /**
3330
+ * The latest version registered by an application. Defaults to this handle's, so a
3331
+ * caller acting for another one — firing its schedule — must name it.
3332
+ */
3333
+ async getLatestApplicationVersion(applicationName) {
3334
+ const owner = applicationName ?? this.appName;
3335
+ const params = [];
3336
+ const scope = this.#appNameFilter('application_name', owner, params);
3337
+ const { rows } = await this.pool.query(`SELECT version_id, version_name, version_timestamp, created_at, application_name
3082
3338
  FROM "${this.schemaName}".application_versions
3339
+ WHERE ${scope}
3083
3340
  ORDER BY version_timestamp DESC
3084
- LIMIT 1`);
3341
+ LIMIT 1`, params);
3085
3342
  if (rows.length === 0) {
3086
3343
  throw new error_1.DBOSInitializationError('No application versions found');
3087
3344
  }
3088
- const r = rows[0];
3089
- return {
3090
- versionId: r.version_id,
3091
- versionName: r.version_name,
3092
- versionTimestamp: Number(r.version_timestamp),
3093
- createdAt: Number(r.created_at),
3094
- };
3345
+ return mapVersionInfo(rows[0]);
3095
3346
  }
3096
3347
  // ==================== Queues ====================
3097
3348
  async getQueue(name) {
3098
- const { rows } = await this.pool.query(`SELECT name, concurrency, worker_concurrency, rate_limit_max, rate_limit_period_sec,
3099
- priority_enabled, partition_queue, polling_interval_sec
3349
+ const { rows } = await this.pool.query(`SELECT ${QUEUE_COLUMNS}
3100
3350
  FROM "${this.schemaName}".queues
3101
3351
  WHERE name = $1`, [name]);
3102
3352
  return rows.length === 0 ? null : queueRecordFromRow(rows[0]);
3103
3353
  }
3104
- async listQueues() {
3105
- const { rows } = await this.pool.query(`SELECT name, concurrency, worker_concurrency, rate_limit_max, rate_limit_period_sec,
3106
- priority_enabled, partition_queue, polling_interval_sec
3107
- FROM "${this.schemaName}".queues`);
3354
+ /**
3355
+ * List only queues owned by these applications, plus unclaimed ones.
3356
+ * By default, only list this application's queues.
3357
+ */
3358
+ async listQueues(applicationName) {
3359
+ const params = [];
3360
+ const scope = this.#observabilityFilter('application_name', applicationName, params);
3361
+ const { rows } = await this.pool.query(`SELECT ${QUEUE_COLUMNS}
3362
+ FROM "${this.schemaName}".queues
3363
+ WHERE ${scope}`, params);
3108
3364
  return rows.map(queueRecordFromRow);
3109
3365
  }
3110
3366
  async deleteQueue(name) {
@@ -3140,16 +3396,21 @@ class SystemDatabase {
3140
3396
  priority_enabled = EXCLUDED.priority_enabled,
3141
3397
  partition_queue = EXCLUDED.partition_queue,
3142
3398
  polling_interval_sec = EXCLUDED.polling_interval_sec,
3143
- updated_at = EXCLUDED.updated_at`
3399
+ updated_at = EXCLUDED.updated_at,
3400
+ -- Claim only an unclaimed row, so a registration landing between the check above and this write keeps the name it just took.
3401
+ application_name = COALESCE("${this.schemaName}".queues.application_name, EXCLUDED.application_name)`
3144
3402
  : `ON CONFLICT (name) DO NOTHING`;
3403
+ const owner = record.applicationName ?? this.appName;
3145
3404
  const client = await this.pool.connect();
3146
3405
  try {
3147
3406
  await client.query('BEGIN');
3148
3407
  const existed = await client.query(`SELECT name FROM "${this.schemaName}".queues WHERE name = $1`, [record.name]);
3408
+ // A name collision is a conflict in every mode: the name is the queue's address.
3409
+ const resolvedOwner = await this.#resolveRowOwner(client, 'queues', 'name', record.name, owner, 'Queue');
3149
3410
  await client.query(`INSERT INTO "${this.schemaName}".queues
3150
3411
  (name, concurrency, worker_concurrency, rate_limit_max, rate_limit_period_sec,
3151
- priority_enabled, partition_queue, polling_interval_sec, updated_at)
3152
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
3412
+ priority_enabled, partition_queue, polling_interval_sec, updated_at, application_name)
3413
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
3153
3414
  ${onConflict}`, [
3154
3415
  record.name,
3155
3416
  record.concurrency,
@@ -3160,7 +3421,10 @@ class SystemDatabase {
3160
3421
  record.partitionQueue,
3161
3422
  record.pollingIntervalSec,
3162
3423
  now,
3424
+ resolvedOwner ?? null,
3163
3425
  ]);
3426
+ // Read back, since the guard above is silent about why it declined to claim.
3427
+ await this.#resolveRowOwner(client, 'queues', 'name', record.name, owner, 'Queue');
3164
3428
  await client.query('COMMIT');
3165
3429
  return existed.rowCount === 0;
3166
3430
  }
@@ -3172,6 +3436,123 @@ class SystemDatabase {
3172
3436
  client.release();
3173
3437
  }
3174
3438
  }
3439
+ // ==================== Application Rename ====================
3440
+ /**
3441
+ * Rows a rename moves: an application's own, unclaimed ones, or both. Unlike
3442
+ * the claiming scope, unclaimed rows are not implied; they move only when asked.
3443
+ */
3444
+ #renameSource(oldName, adoptUnclaimedRows, params) {
3445
+ const clauses = [];
3446
+ if (oldName !== undefined) {
3447
+ params.push(oldName);
3448
+ clauses.push(`application_name = $${params.length}`);
3449
+ }
3450
+ if (adoptUnclaimedRows) {
3451
+ clauses.push('application_name IS NULL');
3452
+ }
3453
+ // Callers validate that at least one source is named.
3454
+ return `(${clauses.join(' OR ')})`;
3455
+ }
3456
+ /**
3457
+ * Re-own a table's rows in half-open key ranges, so a long history neither moves in
3458
+ * one transaction nor rescans what it already moved; a re-run resumes.
3459
+ */
3460
+ async #renameRowsInBatches(table, keyColumn, oldName, newName, batchSize, adoptUnclaimedRows) {
3461
+ if (batchSize === undefined) {
3462
+ const params = [newName];
3463
+ const predicate = this.#renameSource(oldName, adoptUnclaimedRows, params);
3464
+ const res = await this.pool.query(`UPDATE "${this.schemaName}".${table} SET application_name = $1 WHERE ${predicate}`, params);
3465
+ return res.rowCount ?? 0;
3466
+ }
3467
+ let total = 0;
3468
+ // Ranges, not LIMIT: a LIMIT repages every row already moved, and an IN list of keys plans as a whole-table hash join.
3469
+ let watermark = undefined;
3470
+ for (;;) {
3471
+ const boundParams = [];
3472
+ const predicate = this.#renameSource(oldName, adoptUnclaimedRows, boundParams);
3473
+ let scope = predicate;
3474
+ if (watermark !== undefined) {
3475
+ boundParams.push(watermark);
3476
+ scope = `${predicate} AND ${keyColumn} > $${boundParams.length}`;
3477
+ }
3478
+ // The batchSize-th matching key bounds this range; distinct, so a key's rows are never split across batches.
3479
+ const upperResult = await this.pool.query(`SELECT DISTINCT ${keyColumn} FROM "${this.schemaName}".${table}
3480
+ WHERE ${scope} ORDER BY ${keyColumn} LIMIT 1 OFFSET ${batchSize - 1}`, boundParams);
3481
+ const upper = upperResult.rows[0]?.[keyColumn];
3482
+ const updateParams = [newName];
3483
+ const updatePredicate = this.#renameSource(oldName, adoptUnclaimedRows, updateParams);
3484
+ let batch = updatePredicate;
3485
+ if (upper !== undefined) {
3486
+ if (watermark !== undefined) {
3487
+ updateParams.push(watermark);
3488
+ batch = `${batch} AND ${keyColumn} > $${updateParams.length}`;
3489
+ }
3490
+ updateParams.push(upper);
3491
+ batch = `${batch} AND ${keyColumn} <= $${updateParams.length}`;
3492
+ }
3493
+ // The final batch drops the watermark, so rows that appeared below it still move.
3494
+ const res = await this.pool.query(`UPDATE "${this.schemaName}".${table} SET application_name = $1 WHERE ${batch}`, updateParams);
3495
+ total += res.rowCount ?? 0;
3496
+ // Fewer than a full batch remained, so that update took the rest.
3497
+ if (upper === undefined)
3498
+ return total;
3499
+ watermark = upper;
3500
+ }
3501
+ }
3502
+ /**
3503
+ * Give `newName` ownership of rows `oldName` holds, of unclaimed rows, or of both.
3504
+ * The renamed application must be stopped, or its dequeues race this.
3505
+ */
3506
+ async renameApplication(oldName, newName, options = {}) {
3507
+ const adoptUnclaimedRows = options.adoptUnclaimedRows ?? false;
3508
+ const batchSize = options.batchSize === null ? undefined : (options.batchSize ?? exports.DEFAULT_RENAME_BATCH_SIZE);
3509
+ if (oldName !== undefined && oldName === '') {
3510
+ throw new error_1.DBOSError("The application's previous name cannot be empty.");
3511
+ }
3512
+ if (oldName === undefined && !adoptUnclaimedRows) {
3513
+ throw new error_1.DBOSError('Nothing to re-own: name the application to rename, adopt unclaimed rows, or both.');
3514
+ }
3515
+ if (oldName === newName) {
3516
+ throw new error_1.DBOSError(`Application '${newName}' already holds that name; nothing to rename.`);
3517
+ }
3518
+ // A NaN survives a bare `< 1` test and would only fail once it reached SQL, leaving the rename half-applied.
3519
+ if (batchSize !== undefined && (!Number.isInteger(batchSize) || batchSize < 1)) {
3520
+ throw new error_1.DBOSError(`batchSize must be a positive integer, got ${batchSize}`);
3521
+ }
3522
+ // Never a merge: queue, schedule, and version names are globally unique whatever their owner, so this cannot collide.
3523
+ const client = await this.pool.connect();
3524
+ let queues, schedules, versions, inFlight;
3525
+ try {
3526
+ await client.query('BEGIN');
3527
+ const move = async (table, statuses) => {
3528
+ const params = [newName];
3529
+ let where = this.#renameSource(oldName, adoptUnclaimedRows, params);
3530
+ if (statuses !== undefined) {
3531
+ params.push(statuses);
3532
+ where = `${where} AND status = ANY($${params.length})`;
3533
+ }
3534
+ const res = await client.query(`UPDATE "${this.schemaName}".${table} SET application_name = $1 WHERE ${where}`, params);
3535
+ return res.rowCount ?? 0;
3536
+ };
3537
+ // A half-owned application dequeues work whose version row it can no longer see, so these move together.
3538
+ queues = await move('queues');
3539
+ schedules = await move('workflow_schedules');
3540
+ versions = await move('application_versions');
3541
+ inFlight = await move('workflow_status', [workflow_1.StatusString.PENDING, workflow_1.StatusString.ENQUEUED, workflow_1.StatusString.DELAYED]);
3542
+ await client.query('COMMIT');
3543
+ }
3544
+ catch (e) {
3545
+ await client.query('ROLLBACK');
3546
+ throw e;
3547
+ }
3548
+ finally {
3549
+ client.release();
3550
+ }
3551
+ // Only terminal rows are left to match, and they scope observability and GC alone, so they may lag behind the commit above.
3552
+ const terminal = await this.#renameRowsInBatches('workflow_status', 'workflow_uuid', oldName, newName, batchSize, adoptUnclaimedRows);
3553
+ const steps = await this.#renameRowsInBatches('operation_outputs', 'workflow_uuid', oldName, newName, batchSize, adoptUnclaimedRows);
3554
+ return { queues, schedules, versions, workflows: inFlight + terminal, steps };
3555
+ }
3175
3556
  // ==================== Internal ====================
3176
3557
  async insertWorkflowStatus(client, initStatus, ownerXid, incrementAttempts = false) {
3177
3558
  try {
@@ -3206,8 +3587,9 @@ class SystemDatabase {
3206
3587
  attributes,
3207
3588
  schedule_name,
3208
3589
  debounce_deadline_epoch_ms,
3209
- is_debounced
3210
- ) 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, $26, $27, $28, $29, $30, $31, $32)
3590
+ is_debounced,
3591
+ application_name
3592
+ ) 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, $26, $27, $28, $29, $30, $31, $32, $33)
3211
3593
  ON CONFLICT (workflow_uuid)
3212
3594
  DO UPDATE SET
3213
3595
  recovery_attempts = CASE
@@ -3255,6 +3637,8 @@ class SystemDatabase {
3255
3637
  initStatus.scheduleName ?? null,
3256
3638
  initStatus.debounceDeadlineEpochMS ?? null,
3257
3639
  initStatus.isDebounced ?? false,
3640
+ // Absent from the conflict update: a re-enqueue must not re-own a claimed row.
3641
+ initStatus.applicationName ?? null,
3258
3642
  ]);
3259
3643
  if (rows.length === 0) {
3260
3644
  throw new Error(`Attempt to insert workflow ${initStatus.workflowUUID} failed`);
@@ -3342,8 +3726,8 @@ class SystemDatabase {
3342
3726
  async recordOperationResultInternal(client, workflowID, functionID, functionName, checkConflict, startTimeEpochMs, endTimeEpochMs, options = {}) {
3343
3727
  try {
3344
3728
  const out = await client.query(`INSERT INTO ${this.schemaName}.operation_outputs
3345
- (workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, serialization)
3346
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
3729
+ (workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, serialization, application_name)
3730
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
3347
3731
  ON CONFLICT (workflow_uuid, function_id) DO UPDATE
3348
3732
  SET completed_at_epoch_ms = operation_outputs.completed_at_epoch_ms
3349
3733
  RETURNING completed_at_epoch_ms;`, [
@@ -3356,6 +3740,8 @@ class SystemDatabase {
3356
3740
  startTimeEpochMs,
3357
3741
  endTimeEpochMs,
3358
3742
  options.serialization ?? null,
3743
+ // Mirrors the parent: only the running application records its steps.
3744
+ this.appName ?? null,
3359
3745
  ]);
3360
3746
  if (checkConflict &&
3361
3747
  (out?.rowCount ?? 0) > 0 &&
@@ -3697,7 +4083,13 @@ __decorate([
3697
4083
  __decorate([
3698
4084
  dbRetry(),
3699
4085
  __metadata("design:type", Function),
3700
- __metadata("design:paramtypes", [String, String]),
4086
+ __metadata("design:paramtypes", [Number]),
4087
+ __metadata("design:returntype", Promise)
4088
+ ], SystemDatabase.prototype, "listTimedOutWorkflowIds", null);
4089
+ __decorate([
4090
+ dbRetry(),
4091
+ __metadata("design:type", Function),
4092
+ __metadata("design:paramtypes", [String, String, Array]),
3701
4093
  __metadata("design:returntype", Promise)
3702
4094
  ], SystemDatabase.prototype, "getMetrics", null);
3703
4095
  //# sourceMappingURL=system_database.js.map