@dudousxd/nestjs-durable-core 0.65.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -26,6 +26,7 @@ __export(index_exports, {
26
26
  CURRENT_PROTOCOL_VERSION: () => CURRENT_PROTOCOL_VERSION,
27
27
  CodecStateStore: () => CodecStateStore,
28
28
  ContinueAsNew: () => ContinueAsNew,
29
+ DEFAULT_PARTITION: () => DEFAULT_PARTITION,
29
30
  DURABLE_OPTIONS: () => DURABLE_OPTIONS,
30
31
  DURABLE_OPTIONS_CANONICAL: () => DURABLE_OPTIONS_CANONICAL,
31
32
  DURABLE_STEP_CONFIG: () => DURABLE_STEP_CONFIG,
@@ -56,6 +57,7 @@ __export(index_exports, {
56
57
  TERMINAL_RUN_STATUSES: () => TERMINAL_RUN_STATUSES,
57
58
  TRANSPORT: () => TRANSPORT,
58
59
  TRANSPORT_CANONICAL: () => TRANSPORT_CANONICAL,
60
+ VERSION_UNDECLARED_TAG: () => VERSION_UNDECLARED_TAG,
59
61
  WORKFLOW_NAME_KEY: () => WORKFLOW_NAME_KEY,
60
62
  WorkflowBlocked: () => WorkflowBlocked,
61
63
  WorkflowEngine: () => WorkflowEngine,
@@ -67,12 +69,14 @@ __export(index_exports, {
67
69
  attributeOperand: () => attributeOperand,
68
70
  bindWorkflowClass: () => bindWorkflowClass,
69
71
  breakpointToken: () => breakpointToken,
72
+ buildWorkflowDirectory: () => buildWorkflowDirectory,
70
73
  canRoute: () => canRoute,
71
74
  capableWorkers: () => capableWorkers,
72
75
  classifyWaiterToken: () => classifyWaiterToken,
73
76
  clearDurableExecutionWrappers: () => clearDurableExecutionWrappers,
74
77
  collectMetrics: () => collectMetrics,
75
78
  controlPlaneDescriptor: () => controlPlaneDescriptor,
79
+ coveredTokens: () => coveredTokens,
76
80
  createStepLogger: () => createStepLogger,
77
81
  currentStep: () => currentStep,
78
82
  currentWorkflowCtx: () => currentWorkflowCtx,
@@ -98,7 +102,9 @@ __export(index_exports, {
98
102
  negotiate: () => negotiate,
99
103
  normalizeAttributeRows: () => normalizeAttributeRows,
100
104
  normalizeDescriptor: () => normalizeDescriptor,
105
+ observedAnnouncements: () => observedAnnouncements,
101
106
  parseDuration: () => parseDuration,
107
+ partitionOfToken: () => partitionOfToken,
102
108
  planDispatch: () => planDispatch,
103
109
  prevCronFireMs: () => prevCronFireMs,
104
110
  readSearchAttributes: () => readSearchAttributes,
@@ -1310,6 +1316,7 @@ function aggregateAnnouncements(descriptors) {
1310
1316
  const first = announcements[0];
1311
1317
  if (!first) continue;
1312
1318
  out.push({
1319
+ evidence: "declared",
1313
1320
  key,
1314
1321
  name: first.name,
1315
1322
  ...first.version !== void 0 ? {
@@ -1330,6 +1337,137 @@ function aggregateAnnouncements(descriptors) {
1330
1337
  return out.sort((a, b) => a.key.localeCompare(b.key));
1331
1338
  }
1332
1339
  __name(aggregateAnnouncements, "aggregateAnnouncements");
1340
+ function observedAnnouncements(heartbeats, covered) {
1341
+ const byToken = /* @__PURE__ */ new Map();
1342
+ for (const beat of heartbeats) {
1343
+ if (!beat.group || covered.has(beat.group)) continue;
1344
+ const instances = byToken.get(beat.group);
1345
+ if (instances) instances.add(beat.instanceId);
1346
+ else byToken.set(beat.group, /* @__PURE__ */ new Set([
1347
+ beat.instanceId
1348
+ ]));
1349
+ }
1350
+ return [
1351
+ ...byToken.entries()
1352
+ ].map(([group, instances]) => ({
1353
+ evidence: "observed",
1354
+ key: group,
1355
+ name: group,
1356
+ groups: [
1357
+ group
1358
+ ],
1359
+ origins: [],
1360
+ requires: [],
1361
+ runtimes: [],
1362
+ instances: [
1363
+ ...instances
1364
+ ].sort(),
1365
+ disagreements: []
1366
+ })).sort((a, b) => a.key.localeCompare(b.key));
1367
+ }
1368
+ __name(observedAnnouncements, "observedAnnouncements");
1369
+ function coveredTokens(announced, descriptors, sanitize) {
1370
+ const tokens = /* @__PURE__ */ new Set();
1371
+ for (const entry of announced) {
1372
+ tokens.add(sanitize(entry.name));
1373
+ for (const group of entry.groups) tokens.add(group);
1374
+ }
1375
+ for (const raw of descriptors) {
1376
+ const d = normalizeDescriptor(raw);
1377
+ for (const step of d.steps) {
1378
+ const token = sanitize(step);
1379
+ tokens.add(token);
1380
+ if (d.partition) tokens.add(`${token}@${d.partition}`);
1381
+ }
1382
+ }
1383
+ return tokens;
1384
+ }
1385
+ __name(coveredTokens, "coveredTokens");
1386
+
1387
+ // src/handshake/directory.ts
1388
+ var DEFAULT_PARTITION = "default";
1389
+ function partitionOfToken(token) {
1390
+ const at = token.lastIndexOf("@");
1391
+ return at === -1 ? DEFAULT_PARTITION : token.slice(at + 1);
1392
+ }
1393
+ __name(partitionOfToken, "partitionOfToken");
1394
+ function buildWorkflowDirectory(input) {
1395
+ const { descriptors, heartbeats, namespace, supported, sanitize } = input;
1396
+ if (!supported) {
1397
+ return {
1398
+ supported: false,
1399
+ workflows: [],
1400
+ otherPartitions: [],
1401
+ detail: "No transport in this process can read what the fleet advertises, so nothing here was asked and this list is empty for that reason alone. Workers may well be running. A workflow can still be called by name."
1402
+ };
1403
+ }
1404
+ const mine = namespace === void 0 ? void 0 : namespace;
1405
+ const ownDescriptors = mine === void 0 ? descriptors : descriptors.filter((d) => (d.namespace ?? DEFAULT_PARTITION) === mine);
1406
+ const ownBeats = [];
1407
+ const foreign = /* @__PURE__ */ new Map();
1408
+ for (const beat of heartbeats) {
1409
+ if (!beat.group) continue;
1410
+ const partition = partitionOfToken(beat.group);
1411
+ if (mine === void 0 || partition === mine) {
1412
+ ownBeats.push(beat);
1413
+ continue;
1414
+ }
1415
+ const seen = foreign.get(partition);
1416
+ if (seen) {
1417
+ seen.groups.add(beat.group);
1418
+ seen.instances.add(beat.instanceId);
1419
+ } else {
1420
+ foreign.set(partition, {
1421
+ groups: /* @__PURE__ */ new Set([
1422
+ beat.group
1423
+ ]),
1424
+ instances: /* @__PURE__ */ new Set([
1425
+ beat.instanceId
1426
+ ])
1427
+ });
1428
+ }
1429
+ }
1430
+ const declared = aggregateAnnouncements(ownDescriptors);
1431
+ const observed = observedAnnouncements(ownBeats, coveredTokens(declared, ownDescriptors, sanitize));
1432
+ const workflows = [
1433
+ ...declared,
1434
+ ...observed
1435
+ ].sort((a, b) => a.key.localeCompare(b.key));
1436
+ const otherPartitions = [
1437
+ ...foreign.entries()
1438
+ ].map(([partition, seen]) => ({
1439
+ partition,
1440
+ groups: [
1441
+ ...seen.groups
1442
+ ].sort(),
1443
+ instances: [
1444
+ ...seen.instances
1445
+ ].sort()
1446
+ })).sort((a, b) => a.partition.localeCompare(b.partition));
1447
+ return {
1448
+ supported: true,
1449
+ workflows,
1450
+ otherPartitions,
1451
+ detail: describeDirectory(workflows, otherPartitions, mine)
1452
+ };
1453
+ }
1454
+ __name(buildWorkflowDirectory, "buildWorkflowDirectory");
1455
+ function summarize(names, limit = 3) {
1456
+ if (names.length <= limit) return names.join(", ");
1457
+ return `${names.slice(0, limit).join(", ")} and ${names.length - limit} more`;
1458
+ }
1459
+ __name(summarize, "summarize");
1460
+ function describeDirectory(workflows, otherPartitions, namespace) {
1461
+ const here = namespace === void 0 ? "this deployment" : `partition "${namespace}"`;
1462
+ const elsewhere = otherPartitions.length === 0 ? "" : ` Live workers were seen on ${otherPartitions.map((p) => `"${p.partition}" (${summarize(p.groups)})`).join(", ")}, which ${here} does not route to \u2014 a worker on the wrong partition looks exactly like no worker at all, so it is named here rather than left out.`;
1463
+ if (workflows.length === 0) {
1464
+ return `The fleet was asked just now and nothing is live in ${here}, so this list is empty because the deployment is empty \u2014 not because the question could not be put.${elsewhere}`;
1465
+ }
1466
+ const observed = workflows.filter((w) => w.evidence === "observed").length;
1467
+ const declaredNote = observed === 0 ? "" : ` ${observed} of them ${observed === 1 ? "is" : "are"} known only from a live queue: the worker serving ${observed === 1 ? "it" : "them"} publishes no description, so ${observed === 1 ? "its" : "their"} version, origin and runtime are genuinely unknown and a version pinned against ${observed === 1 ? "it" : "them"} cannot be checked.`;
1468
+ return `Every workflow a live worker in ${here} can execute, read just now. A worker that stops beating drops off this list within about half a minute.${declaredNote}${elsewhere}`;
1469
+ }
1470
+ __name(describeDirectory, "describeDirectory");
1333
1471
 
1334
1472
  // src/handshake/negotiate.ts
1335
1473
  var rangesOverlap = /* @__PURE__ */ __name((a, b) => Math.max(a[0], b[0]) <= Math.min(a[1], b[1]), "rangesOverlap");
@@ -1881,6 +2019,36 @@ var TransportPool = class {
1881
2019
  ...byInstance.values()
1882
2020
  ];
1883
2021
  }
2022
+ /**
2023
+ * Every live worker heartbeat in the deployment, merged across every transport that can report
2024
+ * them and de-duplicated by `(group, instanceId)` — the same worker is one beat per token, and two
2025
+ * transports scanning the same keyspace must not double-count it.
2026
+ *
2027
+ * Returns `[]` when no transport implements {@link Transport.readAllWorkerHeartbeats}, which is
2028
+ * indistinguishable from "nothing is beating" on its own — {@link introspectsFleet} is what tells
2029
+ * those apart, and the directory reads it rather than guessing from an empty array.
2030
+ */
2031
+ async readAllWorkerHeartbeats() {
2032
+ const seen = /* @__PURE__ */ new Map();
2033
+ for (const { transport } of this.transports) {
2034
+ if (!transport.readAllWorkerHeartbeats) continue;
2035
+ for (const beat of await transport.readAllWorkerHeartbeats()) {
2036
+ const key = `${beat.group}\0${beat.instanceId}`;
2037
+ if (!seen.has(key)) seen.set(key, beat);
2038
+ }
2039
+ }
2040
+ return [
2041
+ ...seen.values()
2042
+ ];
2043
+ }
2044
+ /**
2045
+ * Can ANY transport here introspect the fleet — descriptors or heartbeats? This is the difference
2046
+ * between "the fleet is empty" and "nobody asked", and it is a property of the POOL rather than of
2047
+ * a read's result, so it stays answerable when both reads legitimately come back empty.
2048
+ */
2049
+ get introspectsFleet() {
2050
+ return this.transports.some(({ transport }) => transport.readAllWorkerDescriptors || transport.readAllWorkerHeartbeats);
2051
+ }
1884
2052
  /** Distinct worker groups with a live heartbeat, merged across every transport that can report it. */
1885
2053
  async listWorkerGroups() {
1886
2054
  const groups = /* @__PURE__ */ new Set();
@@ -2645,6 +2813,7 @@ function mergeTags(staticTags, runTags) {
2645
2813
  __name(mergeTags, "mergeTags");
2646
2814
  var BREAKPOINT = "breakpoint";
2647
2815
  var isBreakpoint = /* @__PURE__ */ __name((cp) => cp.status === "pending" && cp.name.startsWith(BREAKPOINT), "isBreakpoint");
2816
+ var VERSION_UNDECLARED_TAG = "version:undeclared";
2648
2817
  var versionKey = /* @__PURE__ */ __name((name, version) => `${name}@${version}`, "versionKey");
2649
2818
  function stepGroup(step, runNamespace) {
2650
2819
  return tenantGroup(sanitizeQueueToken(step.name), step.partition ?? runNamespace);
@@ -3136,7 +3305,9 @@ var WorkflowEngine = class {
3136
3305
  createdAt: /* @__PURE__ */ new Date(),
3137
3306
  updatedAt: /* @__PURE__ */ new Date()
3138
3307
  };
3139
- registered = await this.resolveRemoteByConvention(conventionRun);
3308
+ registered = await this.resolveRemoteByConvention(conventionRun, {
3309
+ askFleetForVersion: true
3310
+ });
3140
3311
  }
3141
3312
  if (!registered) {
3142
3313
  throw new Error(pinned === void 0 ? `workflow ${name} is not registered` : `workflow ${versionKey(name, pinned)} is not registered \u2014 a pinned start never falls back to the newest version (registered: ${this.registeredVersions(name)})`);
@@ -3152,10 +3323,16 @@ var WorkflowEngine = class {
3152
3323
  };
3153
3324
  }
3154
3325
  const now = /* @__PURE__ */ new Date();
3155
- const tags = mergeTags(registered.tags, registered.singleton ? [
3326
+ const stampedTags = [
3156
3327
  ...opts?.tags ?? [],
3157
- this.singletons.tag(registered.singleton, input)
3158
- ] : opts?.tags);
3328
+ ...registered.singleton ? [
3329
+ this.singletons.tag(registered.singleton, input)
3330
+ ] : [],
3331
+ ...registered.versionDeclared === false ? [
3332
+ VERSION_UNDECLARED_TAG
3333
+ ] : []
3334
+ ];
3335
+ const tags = mergeTags(registered.tags, stampedTags);
3159
3336
  if (registered.singleton) {
3160
3337
  await this.singletons.assertCapacity(name, registered.singleton, input);
3161
3338
  }
@@ -3265,14 +3442,15 @@ var WorkflowEngine = class {
3265
3442
  * Recomputed per call (never memoized into `this.workflows`) so it stays correct if group
3266
3443
  * liveness changes between calls, exactly as {@link synthesizeRemoteChild} does.
3267
3444
  */
3268
- async resolveRemoteByConvention(run) {
3445
+ async resolveRemoteByConvention(run, opts = {}) {
3269
3446
  const group = tenantGroup(sanitizeQueueToken(run.workflow), run.namespace);
3270
3447
  const liveTransport = await this.pool.transportWithLiveGroup(group);
3271
3448
  if (!liveTransport) return void 0;
3272
3449
  const executor = new RemoteWorkflowExecutor(liveTransport, run.workflow, run.namespace);
3450
+ const declared = opts.askFleetForVersion ? await this.declaredVersion(liveTransport, group, run.workflow) : void 0;
3273
3451
  return {
3274
3452
  name: run.workflow,
3275
- version: run.workflowVersion,
3453
+ version: declared ?? run.workflowVersion,
3276
3454
  fn: /* @__PURE__ */ __name(() => {
3277
3455
  throw new Error(`remote workflow ${run.workflow} has no local body`);
3278
3456
  }, "fn"),
@@ -3280,10 +3458,46 @@ var WorkflowEngine = class {
3280
3458
  remote: {
3281
3459
  group,
3282
3460
  executor
3283
- }
3461
+ },
3462
+ ...opts.askFleetForVersion ? {
3463
+ versionDeclared: declared !== void 0
3464
+ } : {}
3284
3465
  };
3285
3466
  }
3286
3467
  /**
3468
+ * The version the LIVE fleet declares for `workflow` on `group`, or `undefined` when it declares
3469
+ * none — which is a fact about the fleet, not a failure to read it.
3470
+ *
3471
+ * The read is the descriptor advertisement, scoped to the one group being resolved: the workers on
3472
+ * that queue, their `registrations`, the entry whose `name` matches. That is where a version can
3473
+ * come from at all — a heartbeat has no room for one, so a worker on an SDK too old to publish a
3474
+ * descriptor declares nothing and the caller is told so rather than handed a default.
3475
+ *
3476
+ * `undefined` when the announcers DISAGREE, and deliberately: two live workers claiming different
3477
+ * versions of one name is a deployment in the middle of something, and picking either would stamp
3478
+ * a run with a version half the fleet does not serve. Undeclared is the honest reading of a
3479
+ * contradiction, and it is the reading that refuses to let a pin pass on a coin flip.
3480
+ */
3481
+ async declaredVersion(transport, group, workflow) {
3482
+ if (!transport.readWorkerDescriptors) return void 0;
3483
+ let descriptors;
3484
+ try {
3485
+ descriptors = await transport.readWorkerDescriptors(group);
3486
+ } catch {
3487
+ return void 0;
3488
+ }
3489
+ const versions = /* @__PURE__ */ new Set();
3490
+ for (const announced of aggregateAnnouncements(descriptors)) {
3491
+ if (announced.name === workflow && announced.version !== void 0) {
3492
+ versions.add(announced.version);
3493
+ }
3494
+ }
3495
+ const only = [
3496
+ ...versions
3497
+ ];
3498
+ return only.length === 1 ? only[0] : void 0;
3499
+ }
3500
+ /**
3287
3501
  * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
3288
3502
  * registration (or `undefined` if there is none). The parent of an awaited child is found via its
3289
3503
  * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
@@ -4238,9 +4452,30 @@ var WorkflowEngine = class {
4238
4452
  * Workflows only, and deliberately: see the "steps are out of scope" note in `handshake/announced`.
4239
4453
  */
4240
4454
  async announcedWorkflows() {
4241
- const descriptors = await this.pool.readAllWorkerDescriptors();
4242
- const mine = this.namespace === void 0 ? descriptors : descriptors.filter((d) => (d.namespace ?? "default") === this.namespace);
4243
- return aggregateAnnouncements(mine);
4455
+ return (await this.workflowDirectory()).workflows;
4456
+ }
4457
+ /**
4458
+ * {@link announcedWorkflows}, plus the thing a list of workflows can never say: why it is the
4459
+ * length it is. See {@link WorkflowDirectory} for the three states an empty array used to collapse
4460
+ * into one — "nobody asked", "nothing is live", and the partition mismatch that reads exactly like
4461
+ * the second from the caller's side.
4462
+ *
4463
+ * Two live reads, both on demand and neither retained: the descriptor advertisement (what workers
4464
+ * SAY they serve) and the heartbeat keyspace (what is demonstrably consuming a queue right now).
4465
+ * The second is why a worker on an SDK too old to publish a descriptor appears here at all — it
4466
+ * beats and never describes itself, and reading only descriptors reported such a fleet as empty
4467
+ * while `resolveRemoteByConvention`, a few lines away in this same class, was routing calls to it
4468
+ * off those very keys. Two answers out of one Redis, disagreeing. Now they agree, and the entry
4469
+ * says which of the two kinds of evidence it rests on rather than flattening them together.
4470
+ */
4471
+ async workflowDirectory() {
4472
+ return buildWorkflowDirectory({
4473
+ descriptors: await this.pool.readAllWorkerDescriptors(),
4474
+ heartbeats: await this.pool.readAllWorkerHeartbeats(),
4475
+ namespace: this.namespace,
4476
+ supported: this.pool.introspectsFleet,
4477
+ sanitize: sanitizeQueueToken
4478
+ });
4244
4479
  }
4245
4480
  /**
4246
4481
  * The routing groups of every in-flight run's still-`pending` remote step — the queues where work is
@@ -6702,6 +6937,7 @@ var InMemoryTransport = class {
6702
6937
  CURRENT_PROTOCOL_VERSION,
6703
6938
  CodecStateStore,
6704
6939
  ContinueAsNew,
6940
+ DEFAULT_PARTITION,
6705
6941
  DURABLE_OPTIONS,
6706
6942
  DURABLE_OPTIONS_CANONICAL,
6707
6943
  DURABLE_STEP_CONFIG,
@@ -6732,6 +6968,7 @@ var InMemoryTransport = class {
6732
6968
  TERMINAL_RUN_STATUSES,
6733
6969
  TRANSPORT,
6734
6970
  TRANSPORT_CANONICAL,
6971
+ VERSION_UNDECLARED_TAG,
6735
6972
  WORKFLOW_NAME_KEY,
6736
6973
  WorkflowBlocked,
6737
6974
  WorkflowEngine,
@@ -6743,12 +6980,14 @@ var InMemoryTransport = class {
6743
6980
  attributeOperand,
6744
6981
  bindWorkflowClass,
6745
6982
  breakpointToken,
6983
+ buildWorkflowDirectory,
6746
6984
  canRoute,
6747
6985
  capableWorkers,
6748
6986
  classifyWaiterToken,
6749
6987
  clearDurableExecutionWrappers,
6750
6988
  collectMetrics,
6751
6989
  controlPlaneDescriptor,
6990
+ coveredTokens,
6752
6991
  createStepLogger,
6753
6992
  currentStep,
6754
6993
  currentWorkflowCtx,
@@ -6774,7 +7013,9 @@ var InMemoryTransport = class {
6774
7013
  negotiate,
6775
7014
  normalizeAttributeRows,
6776
7015
  normalizeDescriptor,
7016
+ observedAnnouncements,
6777
7017
  parseDuration,
7018
+ partitionOfToken,
6778
7019
  planDispatch,
6779
7020
  prevCronFireMs,
6780
7021
  readSearchAttributes,