@gkoos/caracal 0.1.0 → 0.1.1
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 +16 -5
- package/README.md +10 -0
- package/dist/{circuit-breaker-BSkcV0W_.d.ts → circuit-breaker-DHEz0YLV.d.ts} +6 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +51 -36
- package/dist/index.js.map +1 -1
- package/dist/redis.d.ts +1 -1
- package/dist/redis.js +11 -0
- package/dist/redis.js.map +1 -1
- package/package.json +9 -5
- package/src/coordination/redis/scripts.ts +20 -3
- package/src/core/bulkhead.ts +4 -1
- package/src/core/circuit-breaker.ts +81 -36
- package/test/harness/adapter-contract.ts +170 -0
- package/test/harness/index.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.1.
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- cf60c44: Fixed
|
|
8
|
+
|
|
9
|
+
- `failureThreshold` values that the distributed coordinator cannot resolve to thousandths are rejected at construction instead of being silently reinterpreted. Below `0.0005` the numerator rounded to 0, which made the comparison unconditionally true - the breaker opened on a success-only window and re-opened after every recovery; at `0.9995` and above it rounded to 1000, requiring every observation to fail, so the breaker effectively never opened
|
|
10
|
+
- `circuitBreaker.local` enforces the same `0.0005 <= failureThreshold < 0.9995` range, so one policy config behaves the same under either coordination
|
|
11
|
+
- an ignored result recorded nothing but also settled nothing, so a distributed half-open probe kept its slot until the probe lease elapsed: after an operation whose classifier returned `ignored`, the next call was rejected with `CircuitOpenError` for up to `probeLeaseTtlMs` (60s with the defaults), while the local breaker released its slot immediately. An ignored probe result now releases its slot without recording an outcome or advancing recovery, so one policy config behaves the same under either coordination
|
|
12
|
+
- packaging and event fixes: `engines.node` now requires `>=20.3.0` (the runtime, timeout and bulkhead policies, and the fetch adapter use `AbortSignal.any`, which landed in 20.3.0, so 20.0-20.2 installed cleanly and then failed at runtime); the published package now also ships `test/harness`, so the `@gkoos/caracal/testing` entry point has published source like every other entry point; and a local bulkhead's `bulkhead.released` event now reports its occupancy before the queued successor is admitted, instead of appearing to include it
|
|
13
|
+
|
|
14
|
+
## 0.1.0 - 2026-09-09
|
|
15
|
+
|
|
16
|
+
Initial public release.
|
package/README.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
<img src="docs/caracal.svg" alt="Caracal" width="140">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
[](https://scorecard.dev/viewer/?uri=github.com/gkoos/caracal)
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+

|
|
14
|
+
|
|
5
15
|
# Caracal
|
|
6
16
|
|
|
7
17
|
**Scoped distributed resilience for asynchronous operations.**
|
|
@@ -185,7 +185,12 @@ interface BreakerCoordinator {
|
|
|
185
185
|
}): Promise<AdmitProbeResult>;
|
|
186
186
|
settleProbe(identity: BreakerIdentity, params: {
|
|
187
187
|
readonly probeToken: string;
|
|
188
|
-
|
|
188
|
+
/**
|
|
189
|
+
* `"success"` and `"failure"` record an outcome; `"ignored"` releases the
|
|
190
|
+
* probe's slot without recording anything, which is what the policy sends
|
|
191
|
+
* when its classifier ignores the result.
|
|
192
|
+
*/
|
|
193
|
+
readonly outcome: "success" | "failure" | "ignored";
|
|
189
194
|
readonly generation: number;
|
|
190
195
|
readonly halfOpenSuccesses: number;
|
|
191
196
|
readonly openMs: number;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { A as AdmitProbeResult, B as BreakerClassifier, a as BreakerCoordinator, b as BreakerIdentity, c as BreakerOutcome, d as BreakerSnapshot, e as BreakerState, f as BulkheadCoordinator, g as BulkheadRejectedError, C as CircuitOpenError, D as DistributedBreakerOptions, h as DistributedBulkheadOptions, L as LocalBreakerOptions, i as LocalBulkheadOptions, O as ObserveResult, S as SettleProbeResult, j as bulkhead, k as circuitBreaker } from './circuit-breaker-
|
|
1
|
+
export { A as AdmitProbeResult, B as BreakerClassifier, a as BreakerCoordinator, b as BreakerIdentity, c as BreakerOutcome, d as BreakerSnapshot, e as BreakerState, f as BulkheadCoordinator, g as BulkheadRejectedError, C as CircuitOpenError, D as DistributedBreakerOptions, h as DistributedBulkheadOptions, L as LocalBreakerOptions, i as LocalBulkheadOptions, O as ObserveResult, S as SettleProbeResult, j as bulkhead, k as circuitBreaker } from './circuit-breaker-DHEz0YLV.js';
|
|
2
2
|
import { a as OperationOptions, b as Operation, P as Policy } from './types-Tf9T76C7.js';
|
|
3
3
|
export { A as Adapter, C as Classification, E as EventSink, c as EventSinks, d as ExecutionContext, e as ExecutionMetadata, N as Next, O as OperationCapabilities, f as OperationEvent, g as OperationExecuteOptions, h as Outcome, i as OutcomeClassifier } from './types-Tf9T76C7.js';
|
|
4
4
|
export { R as RetryContext, a as RetryDelay, b as RetryOptions, r as retry } from './retry-BFP_k3Hg.js';
|
package/dist/index.js
CHANGED
|
@@ -97,8 +97,8 @@ function local(options) {
|
|
|
97
97
|
return await next(context);
|
|
98
98
|
} finally {
|
|
99
99
|
occupancy--;
|
|
100
|
-
waiting[0]?.();
|
|
101
100
|
event(context, "local", name, "process", "released", occupancy);
|
|
101
|
+
waiting[0]?.();
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
});
|
|
@@ -320,6 +320,14 @@ var DEFAULT_OPEN_MS = 1e4;
|
|
|
320
320
|
var DEFAULT_HALF_OPEN_SUCCESSES = 1;
|
|
321
321
|
var DEFAULT_HALF_OPEN_PROBES = 1;
|
|
322
322
|
var DEFAULT_WINDOW_SIZE = 100;
|
|
323
|
+
var FAILURE_THRESHOLD_SCALE = 1e3;
|
|
324
|
+
function assertResolvableThreshold(failureThreshold) {
|
|
325
|
+
const numerator = Math.round(failureThreshold * FAILURE_THRESHOLD_SCALE);
|
|
326
|
+
if (numerator < 1 || numerator >= FAILURE_THRESHOLD_SCALE)
|
|
327
|
+
throw new RangeError(
|
|
328
|
+
`failureThreshold must be at least 0.0005 and below 0.9995 (thresholds are resolved to thousandths); got ${failureThreshold}`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
323
331
|
function validate2(opts) {
|
|
324
332
|
if (!opts.name.trim())
|
|
325
333
|
throw new RangeError("Circuit breaker name must not be empty");
|
|
@@ -329,6 +337,7 @@ function validate2(opts) {
|
|
|
329
337
|
const { failureThreshold = DEFAULT_FAILURE_THRESHOLD } = opts;
|
|
330
338
|
if (!Number.isFinite(failureThreshold) || failureThreshold <= 0 || failureThreshold >= 1)
|
|
331
339
|
throw new RangeError("failureThreshold must be a number in (0, 1)");
|
|
340
|
+
assertResolvableThreshold(failureThreshold);
|
|
332
341
|
const { openMs = DEFAULT_OPEN_MS } = opts;
|
|
333
342
|
if (!Number.isInteger(openMs) || openMs < 1)
|
|
334
343
|
throw new RangeError("openMs must be a positive integer");
|
|
@@ -571,6 +580,7 @@ function validateDistributed(opts) {
|
|
|
571
580
|
const { failureThreshold = DEFAULT_DIST_FAILURE_THRESHOLD } = opts;
|
|
572
581
|
if (!Number.isFinite(failureThreshold) || failureThreshold <= 0 || failureThreshold >= 1)
|
|
573
582
|
throw new RangeError("failureThreshold must be a number in (0, 1)");
|
|
583
|
+
assertResolvableThreshold(failureThreshold);
|
|
574
584
|
const { openMs = DEFAULT_DIST_OPEN_MS } = opts;
|
|
575
585
|
if (!Number.isInteger(openMs) || openMs < 1)
|
|
576
586
|
throw new RangeError("openMs must be a positive integer");
|
|
@@ -603,7 +613,9 @@ function distributed2(options) {
|
|
|
603
613
|
const resolveScope = options.scope;
|
|
604
614
|
const minimumThroughput = options.minimumThroughput ?? DEFAULT_DIST_MINIMUM_THROUGHPUT;
|
|
605
615
|
const failureThreshold = options.failureThreshold ?? DEFAULT_DIST_FAILURE_THRESHOLD;
|
|
606
|
-
const failureThresholdNumerator = Math.round(
|
|
616
|
+
const failureThresholdNumerator = Math.round(
|
|
617
|
+
failureThreshold * FAILURE_THRESHOLD_SCALE
|
|
618
|
+
);
|
|
607
619
|
const windowSize = options.windowSize ?? DEFAULT_DIST_WINDOW_SIZE;
|
|
608
620
|
const openMs = options.openMs ?? DEFAULT_DIST_OPEN_MS;
|
|
609
621
|
const windowTtlMs = options.windowTtlMs ?? Math.max(openMs * 3, 6e4);
|
|
@@ -767,8 +779,9 @@ function distributed2(options) {
|
|
|
767
779
|
classifier,
|
|
768
780
|
isSuccess ? { status: "success", value } : { status: "failure", error: thrownError }
|
|
769
781
|
);
|
|
770
|
-
if (breakerOutcome !== "ignored") {
|
|
782
|
+
if (breakerOutcome !== "ignored" || admission.kind === "probe") {
|
|
771
783
|
const outcomeStr = breakerOutcome === "success" ? "success" : "failure";
|
|
784
|
+
const settleOutcome = breakerOutcome === "ignored" ? "ignored" : outcomeStr;
|
|
772
785
|
if (admission.kind === "closed") {
|
|
773
786
|
try {
|
|
774
787
|
const result = await coordinator.observe(identity, {
|
|
@@ -826,49 +839,51 @@ function distributed2(options) {
|
|
|
826
839
|
try {
|
|
827
840
|
const result = await coordinator.settleProbe(identity, {
|
|
828
841
|
probeToken: admission.probeToken,
|
|
829
|
-
outcome:
|
|
842
|
+
outcome: settleOutcome,
|
|
830
843
|
generation: admission.generation,
|
|
831
844
|
halfOpenSuccesses,
|
|
832
845
|
openMs,
|
|
833
846
|
windowTtlMs
|
|
834
847
|
});
|
|
835
|
-
if (
|
|
836
|
-
|
|
837
|
-
type: "breaker.observation-stale",
|
|
838
|
-
coordination: "distributed",
|
|
839
|
-
policyName: name,
|
|
840
|
-
scope,
|
|
841
|
-
attemptGeneration: admission.generation,
|
|
842
|
-
currentGeneration: result.generation
|
|
843
|
-
});
|
|
844
|
-
} else {
|
|
845
|
-
emitRuntimeEvent(context, {
|
|
846
|
-
type: "breaker.observation",
|
|
847
|
-
coordination: "distributed",
|
|
848
|
-
policyName: name,
|
|
849
|
-
scope,
|
|
850
|
-
outcome: outcomeStr,
|
|
851
|
-
generation: admission.generation
|
|
852
|
-
});
|
|
853
|
-
if (result.type === "transitioned") {
|
|
854
|
-
if (result.newState === "closed") {
|
|
855
|
-
lastKnownState.forget(identity.operation, scope);
|
|
856
|
-
} else {
|
|
857
|
-
lastKnownState.remember(
|
|
858
|
-
identity.operation,
|
|
859
|
-
scope,
|
|
860
|
-
result.newState
|
|
861
|
-
);
|
|
862
|
-
}
|
|
848
|
+
if (breakerOutcome !== "ignored") {
|
|
849
|
+
if (result.type === "stale") {
|
|
863
850
|
emitRuntimeEvent(context, {
|
|
864
|
-
type: "breaker.
|
|
851
|
+
type: "breaker.observation-stale",
|
|
865
852
|
coordination: "distributed",
|
|
866
853
|
policyName: name,
|
|
867
854
|
scope,
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
855
|
+
attemptGeneration: admission.generation,
|
|
856
|
+
currentGeneration: result.generation
|
|
857
|
+
});
|
|
858
|
+
} else {
|
|
859
|
+
emitRuntimeEvent(context, {
|
|
860
|
+
type: "breaker.observation",
|
|
861
|
+
coordination: "distributed",
|
|
862
|
+
policyName: name,
|
|
863
|
+
scope,
|
|
864
|
+
outcome: outcomeStr,
|
|
865
|
+
generation: admission.generation
|
|
871
866
|
});
|
|
867
|
+
if (result.type === "transitioned") {
|
|
868
|
+
if (result.newState === "closed") {
|
|
869
|
+
lastKnownState.forget(identity.operation, scope);
|
|
870
|
+
} else {
|
|
871
|
+
lastKnownState.remember(
|
|
872
|
+
identity.operation,
|
|
873
|
+
scope,
|
|
874
|
+
result.newState
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
emitRuntimeEvent(context, {
|
|
878
|
+
type: "breaker.state-changed",
|
|
879
|
+
coordination: "distributed",
|
|
880
|
+
policyName: name,
|
|
881
|
+
scope,
|
|
882
|
+
state: result.newState,
|
|
883
|
+
previousState: "half-open",
|
|
884
|
+
generation: result.newGeneration
|
|
885
|
+
});
|
|
886
|
+
}
|
|
872
887
|
}
|
|
873
888
|
}
|
|
874
889
|
} catch (settleError) {
|