@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 +35 -0
- package/README.md +22 -0
- package/dist/index.cjs +32 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -3
- package/dist/index.d.ts +13 -3
- package/dist/index.js +29 -14
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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 ?? [])
|
|
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
|
-
|
|
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;
|