@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.js CHANGED
@@ -1185,6 +1185,7 @@ function aggregateAnnouncements(descriptors) {
1185
1185
  const first = announcements[0];
1186
1186
  if (!first) continue;
1187
1187
  out.push({
1188
+ evidence: "declared",
1188
1189
  key,
1189
1190
  name: first.name,
1190
1191
  ...first.version !== void 0 ? {
@@ -1205,6 +1206,137 @@ function aggregateAnnouncements(descriptors) {
1205
1206
  return out.sort((a, b) => a.key.localeCompare(b.key));
1206
1207
  }
1207
1208
  __name(aggregateAnnouncements, "aggregateAnnouncements");
1209
+ function observedAnnouncements(heartbeats, covered) {
1210
+ const byToken = /* @__PURE__ */ new Map();
1211
+ for (const beat of heartbeats) {
1212
+ if (!beat.group || covered.has(beat.group)) continue;
1213
+ const instances = byToken.get(beat.group);
1214
+ if (instances) instances.add(beat.instanceId);
1215
+ else byToken.set(beat.group, /* @__PURE__ */ new Set([
1216
+ beat.instanceId
1217
+ ]));
1218
+ }
1219
+ return [
1220
+ ...byToken.entries()
1221
+ ].map(([group, instances]) => ({
1222
+ evidence: "observed",
1223
+ key: group,
1224
+ name: group,
1225
+ groups: [
1226
+ group
1227
+ ],
1228
+ origins: [],
1229
+ requires: [],
1230
+ runtimes: [],
1231
+ instances: [
1232
+ ...instances
1233
+ ].sort(),
1234
+ disagreements: []
1235
+ })).sort((a, b) => a.key.localeCompare(b.key));
1236
+ }
1237
+ __name(observedAnnouncements, "observedAnnouncements");
1238
+ function coveredTokens(announced, descriptors, sanitize) {
1239
+ const tokens = /* @__PURE__ */ new Set();
1240
+ for (const entry of announced) {
1241
+ tokens.add(sanitize(entry.name));
1242
+ for (const group of entry.groups) tokens.add(group);
1243
+ }
1244
+ for (const raw of descriptors) {
1245
+ const d = normalizeDescriptor(raw);
1246
+ for (const step of d.steps) {
1247
+ const token = sanitize(step);
1248
+ tokens.add(token);
1249
+ if (d.partition) tokens.add(`${token}@${d.partition}`);
1250
+ }
1251
+ }
1252
+ return tokens;
1253
+ }
1254
+ __name(coveredTokens, "coveredTokens");
1255
+
1256
+ // src/handshake/directory.ts
1257
+ var DEFAULT_PARTITION = "default";
1258
+ function partitionOfToken(token) {
1259
+ const at = token.lastIndexOf("@");
1260
+ return at === -1 ? DEFAULT_PARTITION : token.slice(at + 1);
1261
+ }
1262
+ __name(partitionOfToken, "partitionOfToken");
1263
+ function buildWorkflowDirectory(input) {
1264
+ const { descriptors, heartbeats, namespace, supported, sanitize } = input;
1265
+ if (!supported) {
1266
+ return {
1267
+ supported: false,
1268
+ workflows: [],
1269
+ otherPartitions: [],
1270
+ 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."
1271
+ };
1272
+ }
1273
+ const mine = namespace === void 0 ? void 0 : namespace;
1274
+ const ownDescriptors = mine === void 0 ? descriptors : descriptors.filter((d) => (d.namespace ?? DEFAULT_PARTITION) === mine);
1275
+ const ownBeats = [];
1276
+ const foreign = /* @__PURE__ */ new Map();
1277
+ for (const beat of heartbeats) {
1278
+ if (!beat.group) continue;
1279
+ const partition = partitionOfToken(beat.group);
1280
+ if (mine === void 0 || partition === mine) {
1281
+ ownBeats.push(beat);
1282
+ continue;
1283
+ }
1284
+ const seen = foreign.get(partition);
1285
+ if (seen) {
1286
+ seen.groups.add(beat.group);
1287
+ seen.instances.add(beat.instanceId);
1288
+ } else {
1289
+ foreign.set(partition, {
1290
+ groups: /* @__PURE__ */ new Set([
1291
+ beat.group
1292
+ ]),
1293
+ instances: /* @__PURE__ */ new Set([
1294
+ beat.instanceId
1295
+ ])
1296
+ });
1297
+ }
1298
+ }
1299
+ const declared = aggregateAnnouncements(ownDescriptors);
1300
+ const observed = observedAnnouncements(ownBeats, coveredTokens(declared, ownDescriptors, sanitize));
1301
+ const workflows = [
1302
+ ...declared,
1303
+ ...observed
1304
+ ].sort((a, b) => a.key.localeCompare(b.key));
1305
+ const otherPartitions = [
1306
+ ...foreign.entries()
1307
+ ].map(([partition, seen]) => ({
1308
+ partition,
1309
+ groups: [
1310
+ ...seen.groups
1311
+ ].sort(),
1312
+ instances: [
1313
+ ...seen.instances
1314
+ ].sort()
1315
+ })).sort((a, b) => a.partition.localeCompare(b.partition));
1316
+ return {
1317
+ supported: true,
1318
+ workflows,
1319
+ otherPartitions,
1320
+ detail: describeDirectory(workflows, otherPartitions, mine)
1321
+ };
1322
+ }
1323
+ __name(buildWorkflowDirectory, "buildWorkflowDirectory");
1324
+ function summarize(names, limit = 3) {
1325
+ if (names.length <= limit) return names.join(", ");
1326
+ return `${names.slice(0, limit).join(", ")} and ${names.length - limit} more`;
1327
+ }
1328
+ __name(summarize, "summarize");
1329
+ function describeDirectory(workflows, otherPartitions, namespace) {
1330
+ const here = namespace === void 0 ? "this deployment" : `partition "${namespace}"`;
1331
+ 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.`;
1332
+ if (workflows.length === 0) {
1333
+ 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}`;
1334
+ }
1335
+ const observed = workflows.filter((w) => w.evidence === "observed").length;
1336
+ 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.`;
1337
+ 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}`;
1338
+ }
1339
+ __name(describeDirectory, "describeDirectory");
1208
1340
 
1209
1341
  // src/handshake/negotiate.ts
1210
1342
  var rangesOverlap = /* @__PURE__ */ __name((a, b) => Math.max(a[0], b[0]) <= Math.min(a[1], b[1]), "rangesOverlap");
@@ -1756,6 +1888,36 @@ var TransportPool = class {
1756
1888
  ...byInstance.values()
1757
1889
  ];
1758
1890
  }
1891
+ /**
1892
+ * Every live worker heartbeat in the deployment, merged across every transport that can report
1893
+ * them and de-duplicated by `(group, instanceId)` — the same worker is one beat per token, and two
1894
+ * transports scanning the same keyspace must not double-count it.
1895
+ *
1896
+ * Returns `[]` when no transport implements {@link Transport.readAllWorkerHeartbeats}, which is
1897
+ * indistinguishable from "nothing is beating" on its own — {@link introspectsFleet} is what tells
1898
+ * those apart, and the directory reads it rather than guessing from an empty array.
1899
+ */
1900
+ async readAllWorkerHeartbeats() {
1901
+ const seen = /* @__PURE__ */ new Map();
1902
+ for (const { transport } of this.transports) {
1903
+ if (!transport.readAllWorkerHeartbeats) continue;
1904
+ for (const beat of await transport.readAllWorkerHeartbeats()) {
1905
+ const key = `${beat.group}\0${beat.instanceId}`;
1906
+ if (!seen.has(key)) seen.set(key, beat);
1907
+ }
1908
+ }
1909
+ return [
1910
+ ...seen.values()
1911
+ ];
1912
+ }
1913
+ /**
1914
+ * Can ANY transport here introspect the fleet — descriptors or heartbeats? This is the difference
1915
+ * between "the fleet is empty" and "nobody asked", and it is a property of the POOL rather than of
1916
+ * a read's result, so it stays answerable when both reads legitimately come back empty.
1917
+ */
1918
+ get introspectsFleet() {
1919
+ return this.transports.some(({ transport }) => transport.readAllWorkerDescriptors || transport.readAllWorkerHeartbeats);
1920
+ }
1759
1921
  /** Distinct worker groups with a live heartbeat, merged across every transport that can report it. */
1760
1922
  async listWorkerGroups() {
1761
1923
  const groups = /* @__PURE__ */ new Set();
@@ -2520,6 +2682,7 @@ function mergeTags(staticTags, runTags) {
2520
2682
  __name(mergeTags, "mergeTags");
2521
2683
  var BREAKPOINT = "breakpoint";
2522
2684
  var isBreakpoint = /* @__PURE__ */ __name((cp) => cp.status === "pending" && cp.name.startsWith(BREAKPOINT), "isBreakpoint");
2685
+ var VERSION_UNDECLARED_TAG = "version:undeclared";
2523
2686
  var versionKey = /* @__PURE__ */ __name((name, version) => `${name}@${version}`, "versionKey");
2524
2687
  function stepGroup(step, runNamespace) {
2525
2688
  return tenantGroup(sanitizeQueueToken(step.name), step.partition ?? runNamespace);
@@ -3011,7 +3174,9 @@ var WorkflowEngine = class {
3011
3174
  createdAt: /* @__PURE__ */ new Date(),
3012
3175
  updatedAt: /* @__PURE__ */ new Date()
3013
3176
  };
3014
- registered = await this.resolveRemoteByConvention(conventionRun);
3177
+ registered = await this.resolveRemoteByConvention(conventionRun, {
3178
+ askFleetForVersion: true
3179
+ });
3015
3180
  }
3016
3181
  if (!registered) {
3017
3182
  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)})`);
@@ -3027,10 +3192,16 @@ var WorkflowEngine = class {
3027
3192
  };
3028
3193
  }
3029
3194
  const now = /* @__PURE__ */ new Date();
3030
- const tags = mergeTags(registered.tags, registered.singleton ? [
3195
+ const stampedTags = [
3031
3196
  ...opts?.tags ?? [],
3032
- this.singletons.tag(registered.singleton, input)
3033
- ] : opts?.tags);
3197
+ ...registered.singleton ? [
3198
+ this.singletons.tag(registered.singleton, input)
3199
+ ] : [],
3200
+ ...registered.versionDeclared === false ? [
3201
+ VERSION_UNDECLARED_TAG
3202
+ ] : []
3203
+ ];
3204
+ const tags = mergeTags(registered.tags, stampedTags);
3034
3205
  if (registered.singleton) {
3035
3206
  await this.singletons.assertCapacity(name, registered.singleton, input);
3036
3207
  }
@@ -3140,14 +3311,15 @@ var WorkflowEngine = class {
3140
3311
  * Recomputed per call (never memoized into `this.workflows`) so it stays correct if group
3141
3312
  * liveness changes between calls, exactly as {@link synthesizeRemoteChild} does.
3142
3313
  */
3143
- async resolveRemoteByConvention(run) {
3314
+ async resolveRemoteByConvention(run, opts = {}) {
3144
3315
  const group = tenantGroup(sanitizeQueueToken(run.workflow), run.namespace);
3145
3316
  const liveTransport = await this.pool.transportWithLiveGroup(group);
3146
3317
  if (!liveTransport) return void 0;
3147
3318
  const executor = new RemoteWorkflowExecutor(liveTransport, run.workflow, run.namespace);
3319
+ const declared = opts.askFleetForVersion ? await this.declaredVersion(liveTransport, group, run.workflow) : void 0;
3148
3320
  return {
3149
3321
  name: run.workflow,
3150
- version: run.workflowVersion,
3322
+ version: declared ?? run.workflowVersion,
3151
3323
  fn: /* @__PURE__ */ __name(() => {
3152
3324
  throw new Error(`remote workflow ${run.workflow} has no local body`);
3153
3325
  }, "fn"),
@@ -3155,10 +3327,46 @@ var WorkflowEngine = class {
3155
3327
  remote: {
3156
3328
  group,
3157
3329
  executor
3158
- }
3330
+ },
3331
+ ...opts.askFleetForVersion ? {
3332
+ versionDeclared: declared !== void 0
3333
+ } : {}
3159
3334
  };
3160
3335
  }
3161
3336
  /**
3337
+ * The version the LIVE fleet declares for `workflow` on `group`, or `undefined` when it declares
3338
+ * none — which is a fact about the fleet, not a failure to read it.
3339
+ *
3340
+ * The read is the descriptor advertisement, scoped to the one group being resolved: the workers on
3341
+ * that queue, their `registrations`, the entry whose `name` matches. That is where a version can
3342
+ * come from at all — a heartbeat has no room for one, so a worker on an SDK too old to publish a
3343
+ * descriptor declares nothing and the caller is told so rather than handed a default.
3344
+ *
3345
+ * `undefined` when the announcers DISAGREE, and deliberately: two live workers claiming different
3346
+ * versions of one name is a deployment in the middle of something, and picking either would stamp
3347
+ * a run with a version half the fleet does not serve. Undeclared is the honest reading of a
3348
+ * contradiction, and it is the reading that refuses to let a pin pass on a coin flip.
3349
+ */
3350
+ async declaredVersion(transport, group, workflow) {
3351
+ if (!transport.readWorkerDescriptors) return void 0;
3352
+ let descriptors;
3353
+ try {
3354
+ descriptors = await transport.readWorkerDescriptors(group);
3355
+ } catch {
3356
+ return void 0;
3357
+ }
3358
+ const versions = /* @__PURE__ */ new Set();
3359
+ for (const announced of aggregateAnnouncements(descriptors)) {
3360
+ if (announced.name === workflow && announced.version !== void 0) {
3361
+ versions.add(announced.version);
3362
+ }
3363
+ }
3364
+ const only = [
3365
+ ...versions
3366
+ ];
3367
+ return only.length === 1 ? only[0] : void 0;
3368
+ }
3369
+ /**
3162
3370
  * Walk the parent chain of `childRunId` to the nearest ancestor registered as REMOTE, returning its
3163
3371
  * registration (or `undefined` if there is none). The parent of an awaited child is found via its
3164
3372
  * live `child:<childId>` signal waiter — present for as long as the parent is suspended awaiting the
@@ -4113,9 +4321,30 @@ var WorkflowEngine = class {
4113
4321
  * Workflows only, and deliberately: see the "steps are out of scope" note in `handshake/announced`.
4114
4322
  */
4115
4323
  async announcedWorkflows() {
4116
- const descriptors = await this.pool.readAllWorkerDescriptors();
4117
- const mine = this.namespace === void 0 ? descriptors : descriptors.filter((d) => (d.namespace ?? "default") === this.namespace);
4118
- return aggregateAnnouncements(mine);
4324
+ return (await this.workflowDirectory()).workflows;
4325
+ }
4326
+ /**
4327
+ * {@link announcedWorkflows}, plus the thing a list of workflows can never say: why it is the
4328
+ * length it is. See {@link WorkflowDirectory} for the three states an empty array used to collapse
4329
+ * into one — "nobody asked", "nothing is live", and the partition mismatch that reads exactly like
4330
+ * the second from the caller's side.
4331
+ *
4332
+ * Two live reads, both on demand and neither retained: the descriptor advertisement (what workers
4333
+ * SAY they serve) and the heartbeat keyspace (what is demonstrably consuming a queue right now).
4334
+ * The second is why a worker on an SDK too old to publish a descriptor appears here at all — it
4335
+ * beats and never describes itself, and reading only descriptors reported such a fleet as empty
4336
+ * while `resolveRemoteByConvention`, a few lines away in this same class, was routing calls to it
4337
+ * off those very keys. Two answers out of one Redis, disagreeing. Now they agree, and the entry
4338
+ * says which of the two kinds of evidence it rests on rather than flattening them together.
4339
+ */
4340
+ async workflowDirectory() {
4341
+ return buildWorkflowDirectory({
4342
+ descriptors: await this.pool.readAllWorkerDescriptors(),
4343
+ heartbeats: await this.pool.readAllWorkerHeartbeats(),
4344
+ namespace: this.namespace,
4345
+ supported: this.pool.introspectsFleet,
4346
+ sanitize: sanitizeQueueToken
4347
+ });
4119
4348
  }
4120
4349
  /**
4121
4350
  * The routing groups of every in-flight run's still-`pending` remote step — the queues where work is
@@ -6576,6 +6805,7 @@ export {
6576
6805
  CURRENT_PROTOCOL_VERSION,
6577
6806
  CodecStateStore,
6578
6807
  ContinueAsNew,
6808
+ DEFAULT_PARTITION,
6579
6809
  DURABLE_OPTIONS,
6580
6810
  DURABLE_OPTIONS_CANONICAL,
6581
6811
  DURABLE_STEP_CONFIG,
@@ -6606,6 +6836,7 @@ export {
6606
6836
  TERMINAL_RUN_STATUSES,
6607
6837
  TRANSPORT,
6608
6838
  TRANSPORT_CANONICAL,
6839
+ VERSION_UNDECLARED_TAG,
6609
6840
  WORKFLOW_NAME_KEY,
6610
6841
  WorkflowBlocked,
6611
6842
  WorkflowEngine,
@@ -6617,12 +6848,14 @@ export {
6617
6848
  attributeOperand,
6618
6849
  bindWorkflowClass,
6619
6850
  breakpointToken,
6851
+ buildWorkflowDirectory,
6620
6852
  canRoute,
6621
6853
  capableWorkers,
6622
6854
  classifyWaiterToken,
6623
6855
  clearDurableExecutionWrappers,
6624
6856
  collectMetrics,
6625
6857
  controlPlaneDescriptor,
6858
+ coveredTokens,
6626
6859
  createStepLogger,
6627
6860
  currentStep,
6628
6861
  currentWorkflowCtx,
@@ -6648,7 +6881,9 @@ export {
6648
6881
  negotiate,
6649
6882
  normalizeAttributeRows,
6650
6883
  normalizeDescriptor,
6884
+ observedAnnouncements,
6651
6885
  parseDuration,
6886
+ partitionOfToken,
6652
6887
  planDispatch,
6653
6888
  prevCronFireMs,
6654
6889
  readSearchAttributes,