ecluse
Safe HaskellNone
LanguageGHC2021

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

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 Unavailable (the version is not admitted). This is the default: a never-vetted package is not let in just because the scanner is down.

OnAbstain

Fail open. An exhausted rule Abstains instead, yielding the floor to other rules. For a rule (an allow direction, say) where a missing signal should not block availability — it simply does not fire.

Instances

Instances details
Show FailurePolicy Source # 
Instance details

Defined in Ecluse.Rules.Effectful

Eq FailurePolicy Source # 
Instance details

Defined in Ecluse.Rules.Effectful

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

  • ecTimeout :: Int

    The per-attempt timeout in microseconds. An attempt that does not return within it is treated as a failure (a transient, retryable cause).

  • ecBackoff :: [Int]

    The backoff delays in microseconds, one per retry, applied before the corresponding retry attempt. Its length is the retry budget: [] means the single initial attempt only, [100, 200] means up to two retries after it.

  • ecBreakerThreshold :: Int

    Consecutive exhausted-rule failures that trip the breaker.

  • ecBreakerCooldown :: NominalDiffTime

    How long the breaker stays open (fast-failing the rule) before a single half-open probe is allowed to test recovery.

  • ecRetryAfter :: Maybe RetryAfter

    The Retry-After delay to suggest to a client when this rule's unavailability surfaces on a concrete-artifact request; Nothing suggests none.

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.

data Breaker Source #

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.

Constructors

Closed Int

Healthy: the consecutive-failure count so far, up to the trip threshold.

Open UTCTime

Tripped until the given instant: attempts fast-fail until then.

HalfOpen

Cooldown elapsed: one probe attempt is admitted to test recovery.

Instances

Instances details
Show Breaker Source # 
Instance details

Defined in Ecluse.Breaker

Eq Breaker Source # 
Instance details

Defined in Ecluse.Breaker

Methods

(==) :: Breaker -> Breaker -> Bool #

(/=) :: Breaker -> Breaker -> Bool #

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 Breaker -> IO () 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. 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 erOnErrorUnavailable (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.