| Safe Haskell | None |
|---|---|
| Language | GHC2021 |
Ecluse.Rules.Effectful
Description
The effectful rule tier, layered on the pure one in Ecluse.Rules.
A pure rule reasons over the PackageDetails an adapter already fetched; an
effectful rule may do IO to learn its signal — consult a synced advisory index,
fetch and parse a gemspec, call an external policy check. That IO can fail or hang,
so each effectful rule is wrapped in a resilience harness before its verdict is
trusted: a timeout budget, bounded retry with backoff, and a per-source
circuit breaker. When a rule the evaluator needed still cannot be consulted,
it is fail-closed — the version is Undecidable, not
admitted — unless the rule opts into OnAbstain, where availability beats safety.
Tier is performance, not precedence
The two tiers are a performance ordering: the pure tier runs first because it is
cheap, and an effectful rule is consulted only where it could still change the
outcome. Once the pure tier yields a winner at precedence P, an effectful rule
ranked below P cannot outrank it and is skipped (its IO never runs), and the
effectful tier is skipped entirely when no effectful rule is ranked at or above
P. Precedence — not tier — still decides who wins: the surviving effectful
candidates compete against the pure winner under the same
(precedence, deny-before-allow) comparator the pure tier uses, so a higher-ranked
effectful deny overrides a lower pure allow and vice versa.
Resilience
Each rule carries an EffectfulConfig: a timeout per attempt, a bounded retry
count with a backoff schedule, and a breaker threshold/cooldown. The ecBackoff
schedule is compiled to a Control.Retry RetryPolicyM: the n-th retry waits the
n-th delay, and the list's length is the retry budget, so [] is a single attempt
and the schedule runs out rather than retrying forever. The breaker is the shared
Ecluse.Breaker state machine, kept per source as a TVar so repeated failures of
one advisory source fast-fail without latency or hammering, while a half-open probe
tests recovery. The breaker clock is read from the
EvalContext, so its timing is deterministic under test, and a
test that asserts the retry schedule does so without sleeping via
simulatePolicy over the same policy the harness runs.
See docs/architecture/rules-engine.md → "Effectful-rule failure".
Synopsis
- data EffectfulRule = EffectfulRule {}
- data PrecededEffectfulRule = PrecededEffectfulRule {}
- data FailurePolicy
- data EffectfulConfig = EffectfulConfig {}
- defaultEffectfulConfig :: EffectfulConfig
- backoffPolicy :: [Int] -> RetryPolicyM IO
- data Breaker
- newBreaker :: IO (TVar Breaker)
- newtype BreakerReporter = BreakerReporter (Breaker -> IO ())
- noBreakerReporter :: BreakerReporter
- evalRulesEffectful :: EvalContext -> [PrecededRule] -> [PrecededEffectfulRule] -> PackageDetails -> IO Decision
- runEffectfulRule :: EvalContext -> EffectfulRule -> PackageDetails -> IO RuleOutcome
Effectful rules
data EffectfulRule Source #
An effectful rule: its name, the IO that learns its verdict for one version, the resilience knobs, the failure policy, and its per-source circuit breaker.
The breaker is a TVar shared across every evaluation of this rule (it is the one
source's health), so repeated failures trip it once and fast-fail subsequent
evaluations until the cooldown elapses. erEval is the only part that touches a
network or other unreliable resource; everything else is policy.
Constructors
| EffectfulRule | |
Fields
| |
data PrecededEffectfulRule Source #
An EffectfulRule paired with the integer precedence at which it competes
(higher wins), mirroring PrecededRule for the pure tier. The
precedence is what decides whether the rule could still change the outcome and so
whether its IO runs at all.
Constructors
| PrecededEffectfulRule | |
Fields
| |
data FailurePolicy Source #
What to do when an effectful rule cannot be consulted after the harness has exhausted its timeout, retries, and the breaker. The default across the engine is fail-closed; a rule opts out only where availability must beat safety.
Constructors
| OnUnavailable | Fail closed. An exhausted rule yields |
| OnAbstain | Fail open. An exhausted rule |
Instances
| Show FailurePolicy Source # | |
Defined in Ecluse.Rules.Effectful Methods showsPrec :: Int -> FailurePolicy -> ShowS # show :: FailurePolicy -> String # showList :: [FailurePolicy] -> ShowS # | |
| Eq FailurePolicy Source # | |
Defined in Ecluse.Rules.Effectful Methods (==) :: FailurePolicy -> FailurePolicy -> Bool # (/=) :: FailurePolicy -> FailurePolicy -> Bool # | |
Resilience
data EffectfulConfig Source #
The resilience knobs around an effectful rule's IO: a per-attempt timeout,
how many retries to make on failure with the backoff before each, and the breaker
threshold and cooldown. The breaker clock is the EvalContext.
Constructors
| EffectfulConfig | |
Fields
| |
defaultEffectfulConfig :: EffectfulConfig Source #
Sensible defaults for the resilience knobs: a 2-second per-attempt timeout, two
retries at 100ms then 250ms, and a breaker tripping after 5 consecutive failures and
cooling for 30 seconds. The caller supplies the rule's erEval; the knobs are policy
with these defaults.
backoffPolicy :: [Int] -> RetryPolicyM IO Source #
An ecBackoff schedule compiled to a Control.Retry policy: the retry at
iteration n waits the n-th delay (microseconds) before it, and the policy stops
(yields Nothing) once the schedule is exhausted — so the list's length is the retry
budget. [] admits no retry (a single attempt); [a, b] admits up to two. Inspect
the resulting delays without sleeping with simulatePolicy.
The breaker's state, gating whether the guarded operation may be attempted.
A Closed breaker is healthy and counts consecutive failures towards the trip
threshold; an Open breaker fast-fails until its instant passes; a HalfOpen
breaker has admitted one recovery probe and is waiting on its outcome.
newBreaker :: IO (TVar Breaker) Source #
A fresh, healthy breaker (no failures recorded) in a new TVar.
newtype BreakerReporter Source #
An observer of breaker state changes: invoked with the breaker's new state after a transition commits, so a layer that cares (a state gauge) can record it.
Deliberately telemetry-agnostic — it is just a callback, so
the breaker and its callers (Ecluse.Rules.Effectful, the credential refresher) stay
free of any metric dependency; the composition root supplies the bridge to the
instruments. Breaker -> IO ()noBreakerReporter is the inert default: a breaker observed by it records
nothing, which is also how a breaker constructed before the telemetry substrate exists
behaves until the live observer is installed.
Constructors
| BreakerReporter (Breaker -> IO ()) |
noBreakerReporter :: BreakerReporter Source #
The inert reporter: discards the state, recording nothing.
Evaluation
evalRulesEffectful :: EvalContext -> [PrecededRule] -> [PrecededEffectfulRule] -> PackageDetails -> IO Decision Source #
Evaluate a package version against both tiers, the effectful tier layered on
the pure one. Returns the same Decision the pure evalRules would,
unless an effectful rule that could still change the outcome takes a position.
The pure tier runs first. Its winning precedence P then bounds the effectful work:
only effectful rules ranked at or above P are consulted — a lower-ranked one
cannot outrank the pure winner — and the effectful tier is skipped entirely (no IO)
when none qualifies, so a rule set with no effectful rules, or none ranked high
enough, behaves exactly as the pure tier. The qualifying effectful rules are run
through runEffectfulRule (timeout, retry, breaker), and the resulting candidates
compete against the pure winner under the same (precedence, deny-before-allow)
comparator. An effectful Unavailable that wins is fail-closed to Undecidable.
runEffectfulRule :: EvalContext -> EffectfulRule -> PackageDetails -> IO RuleOutcome Source #
Run one effectful rule through its resilience harness: the circuit breaker
gates the attempt, then the rule's IO runs under a per-attempt timeout with bounded
retry and backoff. A clean verdict (Allow/Deny/Abstain) resets the breaker
and is returned; an exhausted rule (timeout, exception, the breaker open, or the
rule itself yielding Unavailable on every attempt) advances the breaker and
resolves per the rule's erOnError — Unavailable (fail-closed) or Abstain
(fail-open).
The breaker timing reads the EvalContext clock, so it is deterministic under test.
Total — it never throws; a rule failure becomes an outcome.