@fabricorg/platform-host 7.3.0 → 7.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @fabricorg/platform-host
2
2
 
3
+ ## 7.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - eac74a9: Adapter circuit breakers now work. `AdapterImplementation.circuitBreaker` was declared and documented, but nothing read it. Platform now implements the breaker, and Host enforces it (issue #22).
8
+
9
+ **Release note: new failure semantics.** An adapter that declares `circuitBreaker: { mode: "enforce", ... }` can now have calls refused. Before this release that config had no effect. Review every adapter that already declares `mode: "enforce"` before upgrading, or switch it to `"monitor"` to observe first.
10
+
11
+ - **`@fabricorg/platform` (adapters subpath and root).**
12
+ - New exports: `InMemoryAdapterCircuitBreaker`, `AdapterCircuitBreakerRegistry`, `AdapterCircuitOpenError`, `isAdapterCircuitOpenError`, `classifyCircuitBreakerOutcome`, `assertAdapterCircuitBreakerConfig`, and the types `AdapterCircuitBreaker`, `AdapterCircuitBreakerProvider`, `AdapterCircuitState`, `AdapterCircuitOutcome`, `AdapterCircuitAdmission`, `AdapterCircuitBreakerEvent` and `AdapterCircuitBreakerManualOverride`. `AdapterCircuitAdmission.generation` identifies the epoch an admission was granted in.
13
+ - `AdapterCircuitBreakerConfig` gains the optional `manualOverride` (`"force_open"` or `"force_closed"`) and `probeLeaseMs` fields. No existing field changed.
14
+ - The breaker is per process: state is in memory, is not shared between replicas and is lost on restart.
15
+ - It goes closed → open → half-open (one probe) → closed, over a rolling `windowMs` and a `cooldownMs`. At most one half-open probe is outstanding until it records or its lease expires. The lease is the new optional `probeLeaseMs` (default `max(cooldownMs, windowMs)`), independent of the cooldown. `check()` is a side-effect-free preview.
16
+ - Only outcomes from the current epoch change state. The breaker's epoch advances on every state change, manual-override change and probe grant, and each admission carries it as `generation`. An outcome admitted in an earlier epoch is dropped: it never closes or reopens the breaker, never resolves or frees the probe slot, and is not counted toward a later trip. For example, a replaced probe that fails after its replacement closed the breaker does not reopen it. A direct `record()` call without an admission, or with one that has no `generation`, is treated as current.
17
+ - `failureThreshold` is a failure count, matching the .NET port. Once `minimumSamples` (default `failureThreshold`) outcomes are in the window, the breaker opens on `failureThreshold` consecutive failures, or on a failure ratio of at least `failureThreshold / minimumSamples`.
18
+ - Outcomes `transient_failure`, `timeout`, `ambiguous` and unclassified thrown errors count as failures. `permanent_failure` is ignored.
19
+ - **`executeWithAdapterRetry`** takes an optional `circuitBreaker`. Without it, behaviour is unchanged.
20
+ - With it, admission is checked immediately before every attempt, with no await between admission and `execute`, and the outcome of every attempt that reached `execute` is recorded.
21
+ - New optional `prepareAttempt(attempt, maxAttempts)` hook, awaited before admission. If it throws, no admission is taken and no outcome is recorded; the error is classified and retried or rethrown like an error thrown by `execute`.
22
+ - In `enforce` mode, a refused attempt is not executed and `AdapterCircuitOpenError` is thrown (`code: "ADAPTER_CIRCUIT_OPEN"`, with `adapterKey`, `attempt` and `retryAfterMs` when known).
23
+ - When the breaker opens between attempts, retrying stops and the same error is thrown.
24
+ - `monitor` mode never blocks. It reports `would_reject` telemetry through `onEvent`, and the callback's errors are swallowed. Events are delivered after the breaker operation that produced them commits, never during a decision or a record, so a callback that calls back into the breaker (for example to force it open) sees committed state, and its change applies from the next decision on. A callback never revokes an admission that is already committed; the README lists the two edge cases this leaves (a breaker call made from inside a callback, and `cooldownMs: 0`).
25
+ - **`@fabricorg/platform-host`.**
26
+ - Each Host instance keeps one breaker per `adapterType:operation`. A breaker is created on the adapter's first call, and only for adapters that declare `circuitBreaker`.
27
+ - Host now writes each attempt number to the adapter invocation record in `prepareAttempt`, before breaker admission, instead of inside `execute`. A store failure there is classified and retried as before, and is not counted against the breaker.
28
+ - The new `adapterCircuitBreakers` option supplies a shared `AdapterCircuitBreakerRegistry` or a custom provider (shared breakers, telemetry, manual override).
29
+ - A circuit-open refusal means the adapter never ran. Host marks the adapter invocation `failed` and appends `AdapterInvocationFailed` with the additive payload fields `reason: "circuit_open"` and `retryAfterMs`. The invocation then fails with status `failed`, the same path as an unsuccessful adapter result.
30
+ - A refusal is never routed to `reconciliation_required`, and it is never classified as a timeout. Domain events already appended in the `before_adapters` phase are kept.
31
+ - `createGovernedActionHost()` now throws at construction if an adapter declares an invalid `circuitBreaker` config, for example a `failureThreshold` below 1. Such a config was previously ignored.
32
+ - **Peer range:** Host now requires `@fabricorg/platform` `^1.8.0` (was `^1.2.0`), because it imports the new breaker exports at runtime. The manifest declares it as `workspace:^`, like the other workspace packages that peer on Platform, so the published range is the Platform version released with Host. A literal `^1.8.0` would fail the packed-consumer check (`pnpm verify:packed`) until the version PR publishes 1.8.0. A side effect: each later Host release will require at least the Platform version that is current when that Host is released.
33
+
34
+ ### Patch Changes
35
+
36
+ - e0f7ca7: Host now imports `canonicalJson` from `@fabricorg/platform` instead of defining its own copy (issue #24). The function moved unchanged, so parameter digests and the external-completion digest are byte-identical, and Host still exports `canonicalJson`, `digestParameters` and `PARAMETER_DIGEST_ALGORITHM`. Host's peer range on `@fabricorg/platform` stays `workspace:^`, so the published range is the Platform version released alongside this Host release, which includes `canonicalJson`.
37
+
3
38
  ## 7.3.0
4
39
 
5
40
  ### Minor Changes
package/README.md CHANGED
@@ -65,6 +65,28 @@ may or may not have been applied — is routed to `reconciliation_required` with
65
65
  `ExternalReconciliation` evidence and an `AdapterInvocationAmbiguous` lifecycle event rather than
66
66
  blindly retrying an unsafe effect.
67
67
 
68
+ ### Adapter circuit breakers
69
+
70
+ An adapter that declares `circuitBreaker` gets a breaker from Host. By default each Host instance
71
+ keeps its own `AdapterCircuitBreakerRegistry`: one in-process breaker per `adapterType:operation`,
72
+ created on the adapter's first call from that adapter's own config. Adapters without a config get
73
+ none, and behave as before. `createGovernedActionHost()` throws if a declared config is invalid.
74
+ Breaker state lives in the Host's process memory: it is not shared between replicas or workers and
75
+ is lost on restart. Pass `adapterCircuitBreakers` to share a registry between Host instances in one
76
+ process, to receive breaker telemetry (`new AdapterCircuitBreakerRegistry({ onEvent })`), to set a
77
+ manual override (`registry.get(type, operation)?.setManualOverride("force_open")`), or to supply a
78
+ custom `AdapterCircuitBreakerProvider`.
79
+
80
+ The breaker is consulted immediately before every attempt, including retries. Host writes the attempt number to the adapter invocation record first, in `prepareAttempt`, so nothing awaits between admission and the adapter call; a store failure there is not counted against the breaker. In `monitor` mode it
81
+ never blocks. In `enforce` mode, while it is open, Host does not call the adapter: the adapter
82
+ invocation is marked `failed`, `AdapterInvocationFailed` is appended with
83
+ `payload.reason: "circuit_open"` (and `retryAfterMs` when known), and the invocation fails with
84
+ status `failed` exactly like an unsuccessful adapter result. Because the adapter never ran, a
85
+ circuit-open refusal is never routed to `reconciliation_required`. Domain events already appended
86
+ in the `before_adapters` phase stay appended. If the breaker opens between retry attempts, Host stops
87
+ retrying and records the refusal. See the `@fabricorg/platform` README for thresholds and outcome
88
+ mapping.
89
+
68
90
  ## Execution authorization
69
91
 
70
92
  Submission authorization proves that a caller may create an invocation. Applications that delegate
package/dist/index.cjs CHANGED
@@ -45,18 +45,8 @@ var ExternalCompletionError = class extends Error {
45
45
  }
46
46
  };
47
47
  var PARAMETER_DIGEST_ALGORITHM = "fabric-canonical-json-sha256-v1";
48
- function canonicalJson(value) {
49
- return JSON.stringify(sort(value));
50
- }
51
- function sort(value) {
52
- if (Array.isArray(value)) return value.map(sort);
53
- if (value && typeof value === "object") {
54
- return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, child]) => [key, sort(child)]));
55
- }
56
- return value;
57
- }
58
48
  function digestParameters(parameters) {
59
- return crypto.createHash("sha256").update(canonicalJson(parameters)).digest("hex");
49
+ return crypto.createHash("sha256").update(platform.canonicalJson(parameters)).digest("hex");
60
50
  }
61
51
  var ATTRIBUTE_NAME = /^[a-z][a-z0-9-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)+$/;
62
52
  var INVOCATION_SOURCE = /^(?:ui|sdui|api|agent|worker|system|[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+)$/;
@@ -457,7 +447,7 @@ function createExternalCompletionLifecycle(deps) {
457
447
  return withConsistency(actionResult2({ ...invocation, ...patch }), invocation.actionId);
458
448
  }
459
449
  function completionDigest(completion, provider) {
460
- return crypto.createHash("sha256").update(canonicalJson({
450
+ return crypto.createHash("sha256").update(platform.canonicalJson({
461
451
  provider: provider ?? null,
462
452
  externalReference: completion.externalReference,
463
453
  outcome: completion.outcome,
@@ -886,8 +876,12 @@ function createGovernedActionHost(options) {
886
876
  }
887
877
  }
888
878
  const adapters = new platform.AdapterRegistry();
889
- for (const adapter of options.adapters ?? []) adapters.register(adapter);
879
+ for (const adapter of options.adapters ?? []) {
880
+ if (adapter.circuitBreaker) platform.assertAdapterCircuitBreakerConfig(adapter.circuitBreaker);
881
+ adapters.register(adapter);
882
+ }
890
883
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
884
+ const circuitBreakers = options.adapterCircuitBreakers ?? new platform.AdapterCircuitBreakerRegistry({ now: () => now().getTime() });
891
885
  const extractEvents = options.extractEvents ?? DEFAULT_EXTRACT_EVENTS;
892
886
  const configuredActionResolver = options.resolveAction ?? options.registry?.resolveAction;
893
887
  if (!configuredActionResolver) throw new Error("Platform Host requires an explicit module registry or resolveAction function.");
@@ -1569,14 +1563,20 @@ function createGovernedActionHost(options) {
1569
1563
  );
1570
1564
  adapterDeadlineTimer.unref?.();
1571
1565
  }
1566
+ const circuitBreaker = circuitBreakers.forAdapter(adapter);
1572
1567
  const result2 = await platform.executeWithAdapterRetry({
1568
+ ...circuitBreaker ? { circuitBreaker } : {},
1573
1569
  policy: step.retryPolicy ?? adapter.retryPolicy,
1574
1570
  defaultIdempotent: adapter.idempotent,
1575
- execute: async (attempt, maxAttempts) => {
1571
+ // Every await before the call happens here, before breaker
1572
+ // admission, so nothing awaits between admission and the adapter.
1573
+ prepareAttempt: async (attempt) => {
1576
1574
  await options.store.updateAdapterInvocation(adapterInvocationId, {
1577
1575
  attempt,
1578
1576
  updatedAt: now()
1579
1577
  });
1578
+ },
1579
+ execute: (attempt, maxAttempts) => {
1580
1580
  return adapter.execute(input, {
1581
1581
  tenantId,
1582
1582
  spaceId,
@@ -1594,6 +1594,7 @@ function createGovernedActionHost(options) {
1594
1594
  getError: (result3) => result3.error,
1595
1595
  classifyOutcome: (result3) => result3.outcome ?? "transient_failure",
1596
1596
  classifyThrownError: (error) => {
1597
+ if (platform.isAdapterCircuitOpenError(error)) return "transient_failure";
1597
1598
  if (adapterController?.signal.aborted) return "timeout";
1598
1599
  const message = error instanceof Error ? error.message : String(error);
1599
1600
  if (/timeout|deadline|timed?\s*out/i.test(message)) return "timeout";
@@ -1771,6 +1772,19 @@ function createGovernedActionHost(options) {
1771
1772
  error: message,
1772
1773
  updatedAt: now()
1773
1774
  });
1775
+ if (platform.isAdapterCircuitOpenError(error)) {
1776
+ await appendEvent(invocation, {
1777
+ eventType: "AdapterInvocationFailed",
1778
+ subjectType: adapterEventSubject.subjectType,
1779
+ subjectId: adapterEventSubject.subjectId,
1780
+ payload: {
1781
+ adapterType: step.adapterType,
1782
+ operation: step.operation,
1783
+ reason: "circuit_open",
1784
+ ...error.retryAfterMs !== void 0 ? { retryAfterMs: error.retryAfterMs } : {}
1785
+ }
1786
+ }, `adapter:${stepIndex}:failed`);
1787
+ }
1774
1788
  return fail2(invocation, "failed", message);
1775
1789
  } finally {
1776
1790
  if (adapterDeadlineTimer) clearTimeout(adapterDeadlineTimer);
@@ -4449,6 +4463,10 @@ async function runTenantIsolationChecks(subject) {
4449
4463
  return { passed: checks.every((check) => check.status !== "failed"), checks };
4450
4464
  }
4451
4465
 
4466
+ Object.defineProperty(exports, "canonicalJson", {
4467
+ enumerable: true,
4468
+ get: function () { return platform.canonicalJson; }
4469
+ });
4452
4470
  exports.ActionAdmissionError = ActionAdmissionError;
4453
4471
  exports.ExternalCompletionError = ExternalCompletionError;
4454
4472
  exports.IdempotencyConflictError = IdempotencyConflictError;
@@ -4459,7 +4477,6 @@ exports.PLATFORM_HOST_HEALTH_CONTRACT_VERSION = PLATFORM_HOST_HEALTH_CONTRACT_VE
4459
4477
  exports.PLATFORM_HOST_METRIC_NAMES = PLATFORM_HOST_METRIC_NAMES;
4460
4478
  exports.PostgresPlatformHostStore = PostgresPlatformHostStore;
4461
4479
  exports.applyPostgresMigrations = applyPostgresMigrations;
4462
- exports.canonicalJson = canonicalJson;
4463
4480
  exports.cloneOutboxRecord = cloneOutboxRecord;
4464
4481
  exports.createDurableSagaParentLifecycle = createDurableSagaParentLifecycle;
4465
4482
  exports.createGovernedActionHost = createGovernedActionHost;