@cadenza.io/service 2.7.0 → 2.8.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.mjs CHANGED
@@ -251,6 +251,7 @@ var isBrowser = typeof window !== "undefined" && typeof window.document !== "und
251
251
  var META_INTENT_PREFIX = "meta-";
252
252
  var META_RUNTIME_TRANSPORT_DIAGNOSTICS_INTENT = "meta-runtime-transport-diagnostics";
253
253
  var META_RUNTIME_STATUS_INTENT = "meta-runtime-status";
254
+ var META_READINESS_INTENT = "meta-readiness";
254
255
  function isPlainObject(value) {
255
256
  return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
256
257
  }
@@ -314,6 +315,100 @@ function summarizeResponderStatuses(statuses) {
314
315
  return { responded, failed, timedOut, pending };
315
316
  }
316
317
 
318
+ // src/utils/readiness.ts
319
+ function evaluateDependencyReadiness(input) {
320
+ const missedHeartbeats = Math.max(
321
+ 0,
322
+ Math.trunc(Number(input.missedHeartbeats) || 0)
323
+ );
324
+ const stale = missedHeartbeats > 0;
325
+ const timeoutReached = missedHeartbeats >= Math.max(1, input.missThreshold);
326
+ if (!input.exists) {
327
+ return {
328
+ state: "unavailable",
329
+ stale: true,
330
+ blocked: true,
331
+ reason: "missing"
332
+ };
333
+ }
334
+ if (timeoutReached) {
335
+ return {
336
+ state: "unavailable",
337
+ stale: true,
338
+ blocked: true,
339
+ reason: "heartbeat-timeout"
340
+ };
341
+ }
342
+ if (input.runtimeState === "unavailable" || !input.acceptingWork) {
343
+ return {
344
+ state: "unavailable",
345
+ stale,
346
+ blocked: true,
347
+ reason: "runtime-unavailable"
348
+ };
349
+ }
350
+ if (stale) {
351
+ return {
352
+ state: "degraded",
353
+ stale: true,
354
+ blocked: false,
355
+ reason: "heartbeat-stale"
356
+ };
357
+ }
358
+ if (input.runtimeState === "overloaded") {
359
+ return {
360
+ state: "overloaded",
361
+ stale: false,
362
+ blocked: false,
363
+ reason: "runtime-overloaded"
364
+ };
365
+ }
366
+ if (input.runtimeState === "degraded") {
367
+ return {
368
+ state: "degraded",
369
+ stale: false,
370
+ blocked: false,
371
+ reason: "runtime-degraded"
372
+ };
373
+ }
374
+ return {
375
+ state: "ready",
376
+ stale: false,
377
+ blocked: false,
378
+ reason: "runtime-healthy"
379
+ };
380
+ }
381
+ function summarizeDependencyReadiness(evaluations) {
382
+ const summary = {
383
+ total: evaluations.length,
384
+ ready: 0,
385
+ degraded: 0,
386
+ overloaded: 0,
387
+ unavailable: 0,
388
+ stale: 0
389
+ };
390
+ for (const evaluation of evaluations) {
391
+ if (evaluation.state === "ready") summary.ready++;
392
+ if (evaluation.state === "degraded") summary.degraded++;
393
+ if (evaluation.state === "overloaded") summary.overloaded++;
394
+ if (evaluation.state === "unavailable") summary.unavailable++;
395
+ if (evaluation.stale) summary.stale++;
396
+ }
397
+ return summary;
398
+ }
399
+ function resolveServiceReadinessState(localRuntimeState, localAcceptingWork, dependencySummary) {
400
+ if (localRuntimeState === "unavailable" || !localAcceptingWork) {
401
+ return "blocked";
402
+ }
403
+ if (dependencySummary.unavailable > 0) {
404
+ return "blocked";
405
+ }
406
+ if (dependencySummary.degraded > 0 || dependencySummary.overloaded > 0 || dependencySummary.stale > 0) {
407
+ return "degraded";
408
+ }
409
+ return "ready";
410
+ }
411
+
317
412
  // src/utils/runtimeStatus.ts
318
413
  function resolveRuntimeStatus(input) {
319
414
  const numberOfRunningGraphs = Math.max(
@@ -395,6 +490,8 @@ var INTERNAL_RUNTIME_STATUS_TASK_NAMES = /* @__PURE__ */ new Set([
395
490
  "Monitor dependee heartbeat freshness",
396
491
  "Resolve runtime status fallback inquiry",
397
492
  "Respond runtime status inquiry",
493
+ "Respond readiness inquiry",
494
+ "Collect distributed readiness",
398
495
  "Get status"
399
496
  ]);
400
497
  function readPositiveIntegerEnv(name, fallback) {
@@ -431,6 +528,8 @@ var ServiceRegistry = class _ServiceRegistry {
431
528
  this.remoteIntentDeputiesByTask = /* @__PURE__ */ new Map();
432
529
  this.dependeesByService = /* @__PURE__ */ new Map();
433
530
  this.dependeeByInstance = /* @__PURE__ */ new Map();
531
+ this.readinessDependeesByService = /* @__PURE__ */ new Map();
532
+ this.readinessDependeeByInstance = /* @__PURE__ */ new Map();
434
533
  this.lastHeartbeatAtByInstance = /* @__PURE__ */ new Map();
435
534
  this.missedHeartbeatsByInstance = /* @__PURE__ */ new Map();
436
535
  this.runtimeStatusFallbackInFlightByInstance = /* @__PURE__ */ new Set();
@@ -546,6 +645,66 @@ var ServiceRegistry = class _ServiceRegistry {
546
645
  },
547
646
  "Responds to runtime-status inquiries with local service instance status."
548
647
  ).respondsTo(META_RUNTIME_STATUS_INTENT);
648
+ CadenzaService.defineIntent({
649
+ name: META_READINESS_INTENT,
650
+ description: "Gather service readiness reports derived from local runtime status and required dependees.",
651
+ input: {
652
+ type: "object",
653
+ properties: {
654
+ detailLevel: {
655
+ type: "string",
656
+ constraints: {
657
+ oneOf: ["minimal", "full"]
658
+ }
659
+ },
660
+ includeDependencies: {
661
+ type: "boolean"
662
+ },
663
+ refreshStaleDependencies: {
664
+ type: "boolean"
665
+ },
666
+ targetServiceName: {
667
+ type: "string"
668
+ },
669
+ targetServiceInstanceId: {
670
+ type: "string"
671
+ }
672
+ }
673
+ },
674
+ output: {
675
+ type: "object",
676
+ properties: {
677
+ readinessReports: {
678
+ type: "array"
679
+ }
680
+ }
681
+ }
682
+ });
683
+ CadenzaService.createMetaTask(
684
+ "Respond readiness inquiry",
685
+ async (ctx) => {
686
+ const targetServiceName = ctx.targetServiceName;
687
+ const targetServiceInstanceId = ctx.targetServiceInstanceId;
688
+ const report = await this.buildLocalReadinessReport({
689
+ detailLevel: ctx.detailLevel === "full" ? "full" : "minimal",
690
+ includeDependencies: ctx.includeDependencies,
691
+ refreshStaleDependencies: ctx.refreshStaleDependencies
692
+ });
693
+ if (!report) {
694
+ return {};
695
+ }
696
+ if (targetServiceName && targetServiceName !== report.serviceName) {
697
+ return {};
698
+ }
699
+ if (targetServiceInstanceId && targetServiceInstanceId !== report.serviceInstanceId) {
700
+ return {};
701
+ }
702
+ return {
703
+ readinessReports: [report]
704
+ };
705
+ },
706
+ "Responds to distributed readiness inquiries using required dependee health."
707
+ ).respondsTo(META_READINESS_INTENT);
549
708
  this.handleInstanceUpdateTask = CadenzaService.createMetaTask(
550
709
  "Handle Instance Update",
551
710
  (ctx, emit) => {
@@ -662,7 +821,11 @@ var ServiceRegistry = class _ServiceRegistry {
662
821
  if (!ctx.serviceName || !ctx.serviceInstanceId) {
663
822
  return false;
664
823
  }
665
- this.registerDependee(ctx.serviceName, ctx.serviceInstanceId);
824
+ this.registerDependee(ctx.serviceName, ctx.serviceInstanceId, {
825
+ requiredForReadiness: this.shouldRequireReadinessFromCommunicationTypes(
826
+ ctx.communicationTypes
827
+ )
828
+ });
666
829
  return true;
667
830
  },
668
831
  "Tracks remote dependency instances for runtime heartbeat monitoring."
@@ -1347,36 +1510,20 @@ var ServiceRegistry = class _ServiceRegistry {
1347
1510
  return false;
1348
1511
  }
1349
1512
  try {
1350
- const inquiryResult = await CadenzaService.inquire(
1351
- META_RUNTIME_STATUS_INTENT,
1352
- {
1353
- targetServiceName: serviceName,
1354
- targetServiceInstanceId: serviceInstanceId,
1355
- detailLevel: ctx.detailLevel === "full" ? "full" : "minimal"
1356
- },
1513
+ const { report, inquiryMeta } = await this.resolveRuntimeStatusFallbackInquiry(
1514
+ serviceName,
1515
+ serviceInstanceId,
1357
1516
  {
1358
- overallTimeoutMs: ctx.overallTimeoutMs ?? this.runtimeStatusFallbackTimeoutMs,
1359
- perResponderTimeoutMs: ctx.perResponderTimeoutMs ?? Math.max(250, Math.floor(this.runtimeStatusFallbackTimeoutMs * 0.75)),
1360
- requireComplete: ctx.requireComplete ?? false
1517
+ detailLevel: ctx.detailLevel === "full" ? "full" : "minimal",
1518
+ overallTimeoutMs: ctx.overallTimeoutMs,
1519
+ perResponderTimeoutMs: ctx.perResponderTimeoutMs,
1520
+ requireComplete: ctx.requireComplete
1361
1521
  }
1362
1522
  );
1363
- const report = this.selectRuntimeStatusReportForTarget(
1364
- inquiryResult,
1365
- serviceName,
1366
- serviceInstanceId
1367
- );
1368
- if (!report) {
1369
- throw new Error(
1370
- `No runtime status report for ${serviceName}/${serviceInstanceId}`
1371
- );
1372
- }
1373
- this.applyRuntimeStatusReport(report);
1374
- this.lastHeartbeatAtByInstance.set(serviceInstanceId, Date.now());
1375
- this.missedHeartbeatsByInstance.set(serviceInstanceId, 0);
1376
1523
  return {
1377
1524
  ...ctx,
1378
1525
  runtimeStatusReport: report,
1379
- __inquiryMeta: inquiryResult.__inquiryMeta
1526
+ __inquiryMeta: inquiryMeta
1380
1527
  };
1381
1528
  } catch (error) {
1382
1529
  const instance = this.getInstance(serviceName, serviceInstanceId);
@@ -1410,6 +1557,27 @@ var ServiceRegistry = class _ServiceRegistry {
1410
1557
  },
1411
1558
  "Runs runtime-status inquiry fallback for a dependee instance after missed heartbeats."
1412
1559
  ).doOn("meta.service_registry.runtime_status_fallback_requested").emits("meta.service_registry.runtime_status_fallback_resolved").emitsOnFail("meta.service_registry.runtime_status_fallback_failed");
1560
+ this.collectReadinessTask = CadenzaService.createMetaTask(
1561
+ "Collect distributed readiness",
1562
+ async (ctx) => {
1563
+ const inquiryResult = await CadenzaService.inquire(
1564
+ META_READINESS_INTENT,
1565
+ {
1566
+ detailLevel: ctx.detailLevel === "full" ? "full" : "minimal",
1567
+ includeDependencies: ctx.includeDependencies,
1568
+ refreshStaleDependencies: ctx.refreshStaleDependencies,
1569
+ targetServiceName: ctx.targetServiceName,
1570
+ targetServiceInstanceId: ctx.targetServiceInstanceId
1571
+ },
1572
+ ctx.inquiryOptions ?? ctx.__inquiryOptions ?? {}
1573
+ );
1574
+ return {
1575
+ ...ctx,
1576
+ ...inquiryResult
1577
+ };
1578
+ },
1579
+ "Collects distributed readiness reports from services."
1580
+ ).doOn("meta.service_registry.readiness_requested").emits("meta.service_registry.readiness_collected").emitsOnFail("meta.service_registry.readiness_failed");
1413
1581
  this.collectTransportDiagnosticsTask = CadenzaService.createMetaTask(
1414
1582
  "Collect transport diagnostics",
1415
1583
  async (ctx) => {
@@ -1758,7 +1926,7 @@ var ServiceRegistry = class _ServiceRegistry {
1758
1926
  }
1759
1927
  return this.getInstance(this.serviceName, this.serviceInstanceId);
1760
1928
  }
1761
- registerDependee(serviceName, serviceInstanceId) {
1929
+ registerDependee(serviceName, serviceInstanceId, options = {}) {
1762
1930
  if (!serviceName || !serviceInstanceId) {
1763
1931
  return;
1764
1932
  }
@@ -1767,6 +1935,13 @@ var ServiceRegistry = class _ServiceRegistry {
1767
1935
  }
1768
1936
  this.dependeesByService.get(serviceName).add(serviceInstanceId);
1769
1937
  this.dependeeByInstance.set(serviceInstanceId, serviceName);
1938
+ if (options.requiredForReadiness) {
1939
+ if (!this.readinessDependeesByService.has(serviceName)) {
1940
+ this.readinessDependeesByService.set(serviceName, /* @__PURE__ */ new Set());
1941
+ }
1942
+ this.readinessDependeesByService.get(serviceName).add(serviceInstanceId);
1943
+ this.readinessDependeeByInstance.set(serviceInstanceId, serviceName);
1944
+ }
1770
1945
  this.lastHeartbeatAtByInstance.set(serviceInstanceId, Date.now());
1771
1946
  this.missedHeartbeatsByInstance.set(serviceInstanceId, 0);
1772
1947
  }
@@ -1779,10 +1954,39 @@ var ServiceRegistry = class _ServiceRegistry {
1779
1954
  }
1780
1955
  }
1781
1956
  this.dependeeByInstance.delete(serviceInstanceId);
1957
+ const readinessDependeeServiceName = serviceName ?? this.readinessDependeeByInstance.get(serviceInstanceId);
1958
+ if (readinessDependeeServiceName) {
1959
+ this.readinessDependeesByService.get(readinessDependeeServiceName)?.delete(serviceInstanceId);
1960
+ if (!this.readinessDependeesByService.get(readinessDependeeServiceName)?.size) {
1961
+ this.readinessDependeesByService.delete(readinessDependeeServiceName);
1962
+ }
1963
+ }
1964
+ this.readinessDependeeByInstance.delete(serviceInstanceId);
1782
1965
  this.lastHeartbeatAtByInstance.delete(serviceInstanceId);
1783
1966
  this.missedHeartbeatsByInstance.delete(serviceInstanceId);
1784
1967
  this.runtimeStatusFallbackInFlightByInstance.delete(serviceInstanceId);
1785
1968
  }
1969
+ getHeartbeatMisses(serviceInstanceId, now = Date.now()) {
1970
+ const observedMisses = this.missedHeartbeatsByInstance.get(serviceInstanceId) ?? 0;
1971
+ const lastHeartbeatAt = this.lastHeartbeatAtByInstance.get(serviceInstanceId) ?? 0;
1972
+ if (lastHeartbeatAt <= 0) {
1973
+ return Math.max(observedMisses, this.runtimeStatusMissThreshold);
1974
+ }
1975
+ const estimatedMisses = Math.max(
1976
+ 0,
1977
+ Math.floor((now - lastHeartbeatAt) / this.runtimeStatusHeartbeatIntervalMs)
1978
+ );
1979
+ return Math.max(observedMisses, estimatedMisses);
1980
+ }
1981
+ shouldRequireReadinessFromCommunicationTypes(communicationTypes) {
1982
+ if (!Array.isArray(communicationTypes)) {
1983
+ return false;
1984
+ }
1985
+ return communicationTypes.some((type) => {
1986
+ const normalized = String(type).toLowerCase();
1987
+ return normalized === "delegation" || normalized === "inquiry";
1988
+ });
1989
+ }
1786
1990
  resolveRuntimeStatusSnapshot(numberOfRunningGraphs, isActive, isNonResponsive, isBlocked) {
1787
1991
  return resolveRuntimeStatus({
1788
1992
  numberOfRunningGraphs,
@@ -1926,6 +2130,166 @@ var ServiceRegistry = class _ServiceRegistry {
1926
2130
  }
1927
2131
  return null;
1928
2132
  }
2133
+ async resolveRuntimeStatusFallbackInquiry(serviceName, serviceInstanceId, options = {}) {
2134
+ const inquiryResult = await CadenzaService.inquire(
2135
+ META_RUNTIME_STATUS_INTENT,
2136
+ {
2137
+ targetServiceName: serviceName,
2138
+ targetServiceInstanceId: serviceInstanceId,
2139
+ detailLevel: options.detailLevel ?? "minimal"
2140
+ },
2141
+ {
2142
+ overallTimeoutMs: options.overallTimeoutMs ?? this.runtimeStatusFallbackTimeoutMs,
2143
+ perResponderTimeoutMs: options.perResponderTimeoutMs ?? Math.max(250, Math.floor(this.runtimeStatusFallbackTimeoutMs * 0.75)),
2144
+ requireComplete: options.requireComplete ?? false
2145
+ }
2146
+ );
2147
+ const report = this.selectRuntimeStatusReportForTarget(
2148
+ inquiryResult,
2149
+ serviceName,
2150
+ serviceInstanceId
2151
+ );
2152
+ if (!report) {
2153
+ throw new Error(
2154
+ `No runtime status report for ${serviceName}/${serviceInstanceId}`
2155
+ );
2156
+ }
2157
+ if (!this.applyRuntimeStatusReport(report)) {
2158
+ throw new Error(
2159
+ `No tracked instance for runtime fallback ${serviceName}/${serviceInstanceId}`
2160
+ );
2161
+ }
2162
+ this.lastHeartbeatAtByInstance.set(serviceInstanceId, Date.now());
2163
+ this.missedHeartbeatsByInstance.set(serviceInstanceId, 0);
2164
+ return {
2165
+ report,
2166
+ inquiryMeta: inquiryResult.__inquiryMeta ?? {}
2167
+ };
2168
+ }
2169
+ evaluateDependencyReadinessDetail(serviceName, serviceInstanceId, now = Date.now()) {
2170
+ const instance = this.getInstance(serviceName, serviceInstanceId);
2171
+ const missedHeartbeats = this.getHeartbeatMisses(serviceInstanceId, now);
2172
+ const runtimeState = instance ? instance.runtimeState ?? this.resolveRuntimeStatusSnapshot(
2173
+ instance.numberOfRunningGraphs ?? 0,
2174
+ instance.isActive,
2175
+ instance.isNonResponsive,
2176
+ instance.isBlocked
2177
+ ).state : "unavailable";
2178
+ const acceptingWork = instance ? typeof instance.acceptingWork === "boolean" ? instance.acceptingWork : this.resolveRuntimeStatusSnapshot(
2179
+ instance.numberOfRunningGraphs ?? 0,
2180
+ instance.isActive,
2181
+ instance.isNonResponsive,
2182
+ instance.isBlocked
2183
+ ).acceptingWork : false;
2184
+ const evaluation = evaluateDependencyReadiness({
2185
+ exists: Boolean(instance),
2186
+ runtimeState,
2187
+ acceptingWork,
2188
+ missedHeartbeats,
2189
+ missThreshold: this.runtimeStatusMissThreshold
2190
+ });
2191
+ const lastHeartbeat = this.lastHeartbeatAtByInstance.get(serviceInstanceId);
2192
+ return {
2193
+ serviceName,
2194
+ serviceInstanceId,
2195
+ dependencyState: evaluation.state,
2196
+ runtimeState,
2197
+ acceptingWork,
2198
+ missedHeartbeats,
2199
+ stale: evaluation.stale,
2200
+ blocked: evaluation.blocked,
2201
+ reason: evaluation.reason,
2202
+ lastHeartbeatAt: lastHeartbeat ? new Date(lastHeartbeat).toISOString() : null,
2203
+ reportedAt: instance?.reportedAt ?? null
2204
+ };
2205
+ }
2206
+ async buildLocalReadinessReport(options = {}) {
2207
+ const localRuntime = this.buildLocalRuntimeStatusReport("minimal");
2208
+ if (!localRuntime) {
2209
+ return null;
2210
+ }
2211
+ const detailLevel = options.detailLevel ?? "minimal";
2212
+ const includeDependencies = options.includeDependencies ?? detailLevel === "full";
2213
+ const refreshStaleDependencies = options.refreshStaleDependencies ?? true;
2214
+ const dependencyPairs = Array.from(this.readinessDependeesByService.entries()).flatMap(
2215
+ ([serviceName, instanceIds]) => Array.from(instanceIds).map((serviceInstanceId) => ({
2216
+ serviceName,
2217
+ serviceInstanceId
2218
+ }))
2219
+ ).sort((left, right) => {
2220
+ if (left.serviceName !== right.serviceName) {
2221
+ return left.serviceName.localeCompare(right.serviceName);
2222
+ }
2223
+ return left.serviceInstanceId.localeCompare(right.serviceInstanceId);
2224
+ });
2225
+ if (refreshStaleDependencies) {
2226
+ for (const dependency of dependencyPairs) {
2227
+ const misses = this.getHeartbeatMisses(dependency.serviceInstanceId);
2228
+ if (misses < this.runtimeStatusMissThreshold) {
2229
+ continue;
2230
+ }
2231
+ if (this.runtimeStatusFallbackInFlightByInstance.has(
2232
+ dependency.serviceInstanceId
2233
+ )) {
2234
+ continue;
2235
+ }
2236
+ this.runtimeStatusFallbackInFlightByInstance.add(
2237
+ dependency.serviceInstanceId
2238
+ );
2239
+ try {
2240
+ await this.resolveRuntimeStatusFallbackInquiry(
2241
+ dependency.serviceName,
2242
+ dependency.serviceInstanceId
2243
+ );
2244
+ } catch (error) {
2245
+ CadenzaService.log(
2246
+ "Readiness dependency fallback failed.",
2247
+ {
2248
+ serviceName: dependency.serviceName,
2249
+ serviceInstanceId: dependency.serviceInstanceId,
2250
+ error: error instanceof Error ? error.message : String(error)
2251
+ },
2252
+ "warning"
2253
+ );
2254
+ } finally {
2255
+ this.runtimeStatusFallbackInFlightByInstance.delete(
2256
+ dependency.serviceInstanceId
2257
+ );
2258
+ }
2259
+ }
2260
+ }
2261
+ const now = Date.now();
2262
+ const dependencyDetails = dependencyPairs.map(
2263
+ (dependency) => this.evaluateDependencyReadinessDetail(
2264
+ dependency.serviceName,
2265
+ dependency.serviceInstanceId,
2266
+ now
2267
+ )
2268
+ );
2269
+ const dependencySummary = summarizeDependencyReadiness(
2270
+ dependencyDetails.map((detail) => ({
2271
+ state: detail.dependencyState,
2272
+ stale: detail.stale,
2273
+ blocked: detail.blocked,
2274
+ reason: detail.reason
2275
+ }))
2276
+ );
2277
+ const readinessState = resolveServiceReadinessState(
2278
+ localRuntime.state,
2279
+ localRuntime.acceptingWork,
2280
+ dependencySummary
2281
+ );
2282
+ return {
2283
+ serviceName: localRuntime.serviceName,
2284
+ serviceInstanceId: localRuntime.serviceInstanceId,
2285
+ reportedAt: new Date(now).toISOString(),
2286
+ readinessState,
2287
+ runtimeState: localRuntime.state,
2288
+ acceptingWork: localRuntime.acceptingWork,
2289
+ dependencySummary,
2290
+ ...includeDependencies ? { dependencies: dependencyDetails } : {}
2291
+ };
2292
+ }
1929
2293
  reset() {
1930
2294
  this.instances.clear();
1931
2295
  this.deputies.clear();
@@ -1935,6 +2299,8 @@ var ServiceRegistry = class _ServiceRegistry {
1935
2299
  this.remoteIntentDeputiesByTask.clear();
1936
2300
  this.dependeesByService.clear();
1937
2301
  this.dependeeByInstance.clear();
2302
+ this.readinessDependeesByService.clear();
2303
+ this.readinessDependeeByInstance.clear();
1938
2304
  this.lastHeartbeatAtByInstance.clear();
1939
2305
  this.missedHeartbeatsByInstance.clear();
1940
2306
  this.runtimeStatusFallbackInFlightByInstance.clear();