diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d98c6ec..1c2793f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,13 @@ env: jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The library targets net8.0 only; running the same net8.0 test binaries under both + # the 8.0.x and 9.0.x runtimes catches framework-specific regressions (e.g. runtime + # behavior differences the library depends on) that a single-SDK job would miss. + dotnet-version: ["8.0.x", "9.0.x"] steps: - uses: actions/checkout@v4 with: @@ -30,7 +37,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: ${{ matrix.dotnet-version }} - name: Restore dependencies run: dotnet restore @@ -45,7 +52,7 @@ jobs: uses: actions/upload-artifact@v4 if: success() || failure() with: - name: test-results-${{ github.run_id }} + name: test-results-${{ matrix.dotnet-version }}-${{ github.run_id }} path: TestResults/*.trx retention-days: 7 @@ -53,8 +60,34 @@ jobs: uses: dorny/test-reporter@v1 if: success() || failure() with: - name: Unit Test Results + name: Unit Test Results (${{ matrix.dotnet-version }}) path: TestResults/*.trx reporter: dotnet-trx fail-on-error: false token: ${{ secrets.GITHUB_TOKEN }} + + # Verifies the IsAotCompatible=true claim in src/Directory.Build.props actually holds — + # an unverified AOT claim is worse than no claim (see future-plans.md). Publishes the + # samples project (the most representative consumer surface: DI, typed pipelines, every + # built-in strategy) with Native AOT and fails on any trim/AOT analyzer warning. + aot-verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Publish samples with Native AOT + run: > + dotnet publish samples/Resilion.Samples/Resilion.Samples.csproj + -c Release -r linux-x64 -p:PublishAot=true --self-contained + /warnaserror:IL2026,IL2070,IL2072,IL3050,IL3053 + + - name: Run the AOT-published binary + run: ./samples/Resilion.Samples/bin/Release/net8.0/linux-x64/publish/Resilion.Samples diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ed043..e11505a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial project structure and build configuration - Core abstractions: `Outcome`, `ResilienceContext`, `Pipeline`, `Strategy` +- `IPipelineProvider` — a read-only view over `ResiliencePipelineRegistry` for consumers that only retrieve pipelines, never register them; registered in DI alongside the registry +- `BreakDurationGenerator` on both circuit breaker options classes — computes the break duration dynamically per trip (e.g. exponential backoff on repeated trips), via the new `BreakDurationGeneratorArgs` +- `MaxDelay` on both retry options classes — a global safety cap applied after `Delay` computes its value, for every backoff type including `RetryDelay.Custom` +- Configurable cap on `ResilienceContextPool` via `new ResilienceContextPool(maxPoolSize)` (defaults to 256, matching the previous hardcoded value) +- `docs/migration-from-polly.md` — concept mapping and before/after code samples for the five most common Polly → Resilion migrations +- `docs/comparison-with-polly.md` — honest side-by-side of where each library is stronger today, with a roadmap table to close the gap +- Extensive new test coverage: typed circuit breaker (previously 1 test), `SlidingWindow` direct tests, hedging latency-mode and cleanup-timeout tests, telemetry instrument verification, multi-strategy composition/integration tests, DI round-trip tests, ordering-validation error/warning split, `TypedStrategyComponent` mismatch, and `await using` disposal tests +- New benchmarks: circuit breaker under mixed Closed-state traffic, `ResilienceContextPool` Rent/Return vs allocation, and a 100k-execution GC pressure comparison against Polly — see [benchmarks/results](benchmarks/results/README.md) for numbers +- `ThrowOnOrderingErrors` on `PipelineBuilderBase` (default `true`) — dangerous strategy misorderings (CircuitBreaker outside Retry, Fallback not outermost) now throw `InvalidOperationException` at `Build()` time instead of only warning via `Debug.WriteLine`; situational issues (3+ Timeouts, Hedging+Retry) remain advisory-only regardless +- `IAsyncDisposable` on `Pipeline`, `Pipeline`, `Strategy`, `Strategy`, and the internal `PipelineComponent` chain — `await using` now works; the default `DisposeAsync()` falls back to the existing sync `Dispose()` so custom strategies need no changes +- CI `aot-verify` job — publishes the samples project with Native AOT on every PR/push and runs the resulting binary, so the `IsAotCompatible=true` claim stays continuously verified + +### Changed + +- `AddResilion()` renamed to `AddResilienceServices()` — the old name follows .NET naming conventions poorly (describes the library, not what it adds); `AddResilion()` remains as an `[Obsolete]` alias delegating to the new name +- `global.json` SDK floor bumped from the stale `8.0.100` to `8.0.404`; `rollForward: latestMajor` unchanged (intentional — CI matrix jobs each install a single SDK major and rely on it to satisfy the floor) +- CI (`test.yml`) now runs the test suite under both `8.0.x` and `9.0.x` SDKs via a matrix, instead of `9.0.x` only +- `CircuitBreakerStrategy` and `CircuitBreakerTypedStrategy` now share a single `CircuitBreakerStateMachine` instead of ~200 duplicated lines each — the two variants can no longer diverge in behavior +- `PipelineBuilder` and `PipelineBuilder` now share `PipelineBuilderBase` for their common properties and `EmitWarnings` +- The default "handle everything except cancellation" predicate is now a single `OutcomePredicates.DefaultShouldHandle()`, shared by all four options classes that used to copy-paste it +- `ResilienceEventHandler.Invoke()` skips the `Task.Run` thread-hop entirely when no `SynchronizationContext` is present (the common case); only pays the two-thread cost when one exists (WPF/WinForms/legacy ASP.NET) +- Samples project restructured: the original 6 samples stay in `Program.cs`; 6 new ones (DI, typed HTTP-status retry, rate limiter, hedging `ActionGenerator`, `BreakDurationGenerator`, state-parameter) live as one file each under `Samples/` +- `TypedStrategyComponent` now emits a `Debug.WriteLine` warning when it skips a strategy due to a result-type mismatch, instead of skipping silently +- README's "Why Resilion?" tagline no longer gates on "experienced" developers; added a "Free forever" banner, corrected the DI/telemetry code snippets, and added a real benchmark-numbers summary + +### Fixed + +- **Race condition** in `CircuitBreakerTypedStrategy`: recording an outcome and reading the failure ratio were two separate lock acquisitions, letting a concurrent caller observe a stale or out-of-range ratio between them. Now both circuit breaker variants share `SlidingWindow.RecordAndGetRatio`, which combines the two under one lock. +- `Strategy`/`Strategy`'s default `Execute()` (used only by custom strategies that don't override it) now throws `InvalidOperationException` when a `SynchronizationContext` is present, instead of silently risking deadlock +- Negative-`TimeSpan` validation on `RetryDelay.Constant`/`Linear`/`Exponential` +- `ResiliencePipelineRegistry.GetPipeline()` no longer caches a faulted `Lazy` after a failed lookup — registering the key afterward and retrying now succeeds instead of replaying the cached failure +- `PipelineBuilder`/`PipelineBuilder` now throw `InvalidOperationException` if used after `Build()` has already been called, instead of silently accumulating strategies for a pipeline that was already constructed +- Hedging's sync `Execute()` path now throws `InvalidOperationException` for parallel/latency `HedgingDelay` settings instead of silently degrading to sequential execution with no indication hedging wasn't actually hedging +- `RealWorldScenarioBenchmarks`' DB-query pipeline had Fallback innermost (`Timeout → Retry → Fallback`), so it intercepted every failure before Retry ever saw one, making the retry a no-op — caught by the new `ThrowOnOrderingErrors` default while re-verifying the benchmark suite; reordered to `Fallback → Timeout → Retry` + +### Removed + +- Dead telemetry instruments `resilion.strategy.executions` and `resilion.strategy.duration` — declared but never incremented by any strategy +- `HedgingRejectedException` — defined but never constructed or thrown anywhere + +### Documentation + +- Corrected `SlidingWindow`'s XML remarks and `docs/architecture.md`'s thread-safety section, both of which claimed `Interlocked` counters where the code has always used a single lock +- `docs/tradeoffs.md`'s `ResilienceContextPool` section updated for the renamed/configurable pool cap field +- Added `BreakDurationGenerator` / `MaxDelay` rows to the circuit breaker and retry options reference tables +- Added an XML doc `` on `Outcome`'s implicit operator warning about the `Outcome` success-vs-failure ambiguity +- Documented ordering validation (`ThrowOnOrderingErrors`, `SuppressOrderingWarnings`) in `docs/pipelines.md` diff --git a/README.md b/README.md index 9e8a7ca..a0276a7 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![NuGet](https://img.shields.io/nuget/v/Resilion.svg)](https://www.nuget.org/packages/Resilion) +> **Free forever.** Resilion has no paid tier, no "enterprise edition," and no plans to add one. If it's useful to you, consider [buying us a coffee](https://www.buymeacoffee.com/FreakyAli) — never a requirement, always appreciated. +> +> [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support-FFDD00?logo=buymeacoffee&logoColor=black)](https://www.buymeacoffee.com/FreakyAli) + ## Quick Start ```csharp @@ -35,7 +39,9 @@ var result = await pipeline.ExecuteAsync( ## Why Resilion? -Resilion is designed for experienced .NET developers who want powerful resilience patterns without complexity: +Resilion is designed for .NET developers who want powerful resilience patterns without complexity: + +> Coming from Polly? See [docs/migration-from-polly.md](docs/migration-from-polly.md) for a concept map and before/after code samples for the five most common patterns, or [docs/comparison-with-polly.md](docs/comparison-with-polly.md) for an honest side-by-side on where each library is stronger today. ### Zero Dependencies in Core The `Resilion` package has **zero external dependencies**. Everything you need for production resilience is built-in. @@ -332,9 +338,10 @@ services.AddResiliencePipeline("http-api", b => b .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 }) .AddTimeout(TimeSpan.FromSeconds(10))); -// Later, resolve: -var registry = serviceProvider.GetRequiredService>(); -var pipeline = registry.GetPipeline("http-api"); +// Later, resolve — inject IPipelineProvider rather than the full registry if you +// only need to retrieve pipelines, not register new ones: +var provider = serviceProvider.GetRequiredService>(); +var pipeline = provider.GetPipeline("http-api"); var result = await pipeline.ExecuteAsync( async (client, ct) => await client.GetStringAsync(url, ct), @@ -343,17 +350,11 @@ var result = await pipeline.ExecuteAsync( ## Telemetry -Resilion integrates with `System.Diagnostics`: - -```csharp -// Metrics via System.Diagnostics.Metrics.Meter -var meter = new Meter("Resilion"); -var retryCount = meter.CreateCounter("resilion.retry.attempt_count"); - -// Activity tracking via ActivitySource -var activitySource = new ActivitySource("Resilion"); -using var activity = activitySource.StartActivity("pipeline.execute"); -``` +Resilion emits metrics on a `"Resilion"` `Meter` — retry attempts, circuit breaker state +changes, timeout expirations, fallback activations, hedging attempts, and rate limiter +rejections — with zero overhead until something subscribes. See +[docs/telemetry.md](docs/telemetry.md) for the full instrument list and how to subscribe with +`MeterListener`, `dotnet-counters`, or OpenTelemetry. Structured logging is also available through callbacks on strategy options. @@ -389,12 +390,27 @@ docs/ Resilion is designed for high-performance, latency-sensitive scenarios: -- Struct-based `Outcome` eliminates heap allocations +- Struct-based `Outcome` avoids allocating on the result path - Pooled `ResilienceContext` for request-scoped state -- Zero-allocation happy path for most strategies +- `ResilienceContextPool` trades ~14ns of bookkeeping for eliminating a 72-byte allocation per context - Benchmarks included (see `benchmarks/` folder) -Compare with alternatives like Polly to see allocation profiles and throughput characteristics. +### Benchmark summary + +Measured on an Apple M4 Pro against Polly.Core 8.5.2, same pipeline shapes both sides ([full results](benchmarks/results/README.md)): + +| Scenario | Resilion | Polly.Core | Notes | +|----------|---------:|-----------:|-------| +| Empty pipeline | 69 ns / 96 B | 59 ns / 0 B | Resilion allocates per-strategy closures; Polly's happy path is allocation-free | +| Single retry (happy path) | 114 ns / 192 B | 165 ns / 0 B | Resilion faster in wall-clock time despite allocating | +| Composite (Timeout+Retry+CB+Timeout) | 393 ns / 976 B | 734 ns / 0 B | The realistic multi-strategy shape most apps run | +| Same pipeline, **sync** execution | 61 ns / 192 B | — | True sync — no `Task` machinery | + +Resilion is consistently faster wall-clock, at the cost of small per-call allocations from the +middleware chain's closures (tracked in [future-plans.md](docs/future-plans.md) item #4). If +your workload does any real I/O per call — the overwhelmingly common case — this difference is +unlikely to be your bottleneck; if you're pushing hundreds of thousands of allocation-sensitive +calls/sec with no I/O, benchmark your own shape before choosing. ## Contributing diff --git a/benchmarks/Resilion.Benchmarks/CircuitBreakerLoadBenchmarks.cs b/benchmarks/Resilion.Benchmarks/CircuitBreakerLoadBenchmarks.cs new file mode 100644 index 0000000..0fbcdd9 --- /dev/null +++ b/benchmarks/Resilion.Benchmarks/CircuitBreakerLoadBenchmarks.cs @@ -0,0 +1,46 @@ +using BenchmarkDotNet.Attributes; + +namespace Resilion.Benchmarks; + +/// +/// Circuit breaker in the Closed state handling mixed success/failure traffic. Every call +/// exercises SlidingWindow.RecordAndGetRatio under its single lock — the strategy's +/// hot-path contention point. +/// +[MemoryDiagnoser] +public class CircuitBreakerLoadBenchmarks +{ + private Resilion.Pipeline _pipeline = null!; + private int _counter; + + [GlobalSetup] + public void Setup() + { + // High threshold and throughput floor — this traffic mix never trips the circuit, + // so every call takes the Closed-state recording path being measured. + _pipeline = Resilion.Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.8, + MinimumThroughput = 1000, + SamplingDuration = TimeSpan.FromSeconds(60), + })); + } + + /// ~20% of calls fail — comfortably under the 80% threshold. + [Benchmark] + public async ValueTask Closed_MixedTraffic() + { + var i = Interlocked.Increment(ref _counter); + try + { + return await _pipeline.ExecuteAsync(ct => + i % 5 == 0 + ? throw new InvalidOperationException("simulated failure") + : new ValueTask("ok")); + } + catch (InvalidOperationException) + { + return "handled"; + } + } +} diff --git a/benchmarks/Resilion.Benchmarks/ContextPoolingBenchmarks.cs b/benchmarks/Resilion.Benchmarks/ContextPoolingBenchmarks.cs new file mode 100644 index 0000000..233a95e --- /dev/null +++ b/benchmarks/Resilion.Benchmarks/ContextPoolingBenchmarks.cs @@ -0,0 +1,28 @@ +using BenchmarkDotNet.Attributes; + +namespace Resilion.Benchmarks; + +/// +/// Measures the value of — renting and returning a pooled +/// vs allocating a fresh one per call. The pipeline's +/// ExecuteAsync overloads use the pool automatically; this isolates just that cost. +/// +[MemoryDiagnoser] +public class ContextPoolingBenchmarks +{ + /// + /// Baseline: allocate a context directly, bypassing the pool. 's + /// constructor is internal — accessible here only via InternalsVisibleTo — application + /// code should never construct one directly; this exists solely to measure what the pool saves. + /// + [Benchmark(Baseline = true)] + public ResilienceContext AllocateNewContext() => new(); + + [Benchmark] + public ResilienceContext RentAndReturnFromPool() + { + var context = ResilienceContextPool.Shared.Rent(); + ResilienceContextPool.Shared.Return(context); + return context; + } +} diff --git a/benchmarks/Resilion.Benchmarks/GcPressureBenchmarks.cs b/benchmarks/Resilion.Benchmarks/GcPressureBenchmarks.cs new file mode 100644 index 0000000..7f1f49e --- /dev/null +++ b/benchmarks/Resilion.Benchmarks/GcPressureBenchmarks.cs @@ -0,0 +1,59 @@ +using BenchmarkDotNet.Attributes; +using Polly; + +namespace Resilion.Benchmarks; + +/// +/// Runs a large batch of executions in a single invocation to surface GC pressure (Gen0/Gen1 +/// collections reported by ) under sustained load, rather +/// than just the per-call allocation size the other benchmark classes measure. +/// +[MemoryDiagnoser] +public class GcPressureBenchmarks +{ + private const int Iterations = 100_000; + + private Resilion.Pipeline _resilionRetry = null!; + private ResiliencePipeline _pollyRetry = null!; + + [GlobalSetup] + public void Setup() + { + _resilionRetry = Resilion.Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)), + })); + + _pollyRetry = new ResiliencePipelineBuilder() + .AddRetry(new Polly.Retry.RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = TimeSpan.FromSeconds(1), + BackoffType = DelayBackoffType.Exponential, + }) + .Build(); + } + + [Benchmark(Baseline = true)] + public async Task Resilion_100k_HappyPathExecutions() + { + for (var i = 0; i < Iterations; i++) + { + await _resilionRetry.ExecuteAsync( + static (state, ct) => new ValueTask("ok"), + (object?)null); + } + } + + [Benchmark] + public async Task Polly_100k_HappyPathExecutions() + { + for (var i = 0; i < Iterations; i++) + { + await _pollyRetry.ExecuteAsync( + static (state, ct) => new ValueTask("ok"), + (object?)null); + } + } +} diff --git a/benchmarks/Resilion.Benchmarks/RealWorldScenarioBenchmarks.cs b/benchmarks/Resilion.Benchmarks/RealWorldScenarioBenchmarks.cs index 310287c..affc7bc 100644 --- a/benchmarks/Resilion.Benchmarks/RealWorldScenarioBenchmarks.cs +++ b/benchmarks/Resilion.Benchmarks/RealWorldScenarioBenchmarks.cs @@ -35,18 +35,20 @@ public void Setup() SamplingDuration = TimeSpan.FromSeconds(5), })); - // ────── Resilion: Database Query Pattern (Timeout + Retry + Fallback) ────── - // Typical for: Database reads with cache fallback + // ────── Resilion: Database Query Pattern (Fallback + Timeout + Retry) ────── + // Typical for: Database reads with cache fallback. Fallback must be outermost so it + // catches failures from Retry (once exhausted) and Timeout, not just the raw callback — + // see docs/pipelines.md's ordering validation. _resilionDbQuery = Resilion.Pipeline.Create(b => b + .AddFallback(new FallbackStrategyOptions + { + FallbackAction = "CACHED_DEFAULT" + }) .AddTimeout(TimeSpan.FromSeconds(10)) .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 1, Delay = RetryDelay.Linear(TimeSpan.FromMilliseconds(50)), - }) - .AddFallback(new FallbackStrategyOptions - { - FallbackAction = "CACHED_DEFAULT" })); // ────── Resilion: Hedging Pattern (Parallel attempts, fail fast) ────── diff --git a/benchmarks/results/README.md b/benchmarks/results/README.md new file mode 100644 index 0000000..01dbc4e --- /dev/null +++ b/benchmarks/results/README.md @@ -0,0 +1,95 @@ +# Benchmark Results + +Captured from `dotnet run -c Release --project benchmarks/Resilion.Benchmarks -- --job short` +(BenchmarkDotNet `ShortRun`: 3 iterations, 3 warmup — a real, statistically-averaged job, not +`--job dry`'s single iteration, but lower-confidence than the full default job). Run on: + +- BenchmarkDotNet v0.14.0 +- Apple M4 Pro, 14 cores, macOS (Darwin 25.6.0) +- .NET 8.0.11, Arm64 RyuJIT AdvSIMD + +For a statistically rigorous run (default BenchmarkDotNet job — full pilot/warmup/actual +stages), drop `-- --job short` and expect it to take significantly longer (each of the ~22 +benchmark methods individually calibrates its iteration count, which for near-zero-cost +operations like `DirectCall` can itself take several minutes). + +Full per-class output (including method docs) is in the sibling `.md` files in this directory. + +## Pipeline overhead (happy path, no failures) + +| Benchmark | Mean | Allocated | +|-----------|-----:|----------:| +| Direct call (no pipeline) | ~0 ns | 0 B | +| Resilion — empty pipeline | 69 ns | 96 B | +| Polly — empty pipeline | 59 ns | 0 B | +| Resilion — single retry | 114 ns | 192 B | +| Polly — single retry | 165 ns | 0 B | +| Resilion — composite (Timeout+Retry+CB+Timeout) | 393 ns | 976 B | +| Polly — composite (same shape) | 734 ns | 0 B | +| Resilion — single retry, **sync** | 61 ns | 192 B | + +Resilion's happy-path allocation is non-zero (Polly.Core's is zero here) but its wall-clock time +is consistently lower across every shape tested, including the composite pipeline most +applications actually run. The allocations come from per-strategy closures in the middleware +chain — see [future-plans.md](../../docs/future-plans.md) item #4 and +[tradeoffs.md](../../docs/tradeoffs.md). + +## Real-world scenario patterns + +| Benchmark | Mean | Allocated | +|-----------|-----:|----------:| +| Resilion — HTTP client pattern (Timeout+Retry+CB) | 267 ns | 552 B | +| Polly — HTTP client pattern (same shape) | 391 ns | 0 B | +| Resilion — DB query pattern (Fallback+Timeout+Retry) | 277 ns | 552 B | +| Polly — DB query pattern (same shape) | 254 ns | 0 B | +| Resilion — DB query, fallback triggered | 48.0 ms | 2988 B | +| Resilion — Hedging, fast response | 10.7 μs | 2838 B | +| Resilion — HTTP client, **sync** | 146 ns | 552 B | +| Resilion — DB query, **sync** | 146 ns | 552 B | +| Resilion — DB query, **sync**, fallback triggered | 54.4 ms | 1849 B | + +Sync execution is consistently *faster* than async for the same pipeline shape — the payoff of +true sync (`Thread.Sleep`/no `Task` machinery) rather than sync-over-async. + +**Note on the "fallback triggered" rows:** these jumped from microseconds to tens of +milliseconds partway through this project. Not a performance regression — the DB-query +pipeline's strategy order was `Timeout → Retry → Fallback` (Fallback innermost), so Fallback +caught every failure before Retry ever saw one, silently making the configured retry a no-op. +The new `ThrowOnOrderingErrors` validation (default on) caught this while re-verifying the +benchmark suite; the pipeline is now `Fallback → Timeout → Retry`, so it actually exercises the +one configured retry (with its 50ms linear delay) before falling back — which is what these +numbers now measure honestly. + +## Circuit breaker under load + +| Benchmark | Mean | Allocated | +|-----------|-----:|----------:| +| Closed state, ~20% mixed failure traffic | 7.4 μs | 677 B | + +Every call in this benchmark exercises `SlidingWindow.RecordAndGetRatio` under its lock — this +is the number to watch if you suspect lock contention under high-throughput circuit breaker +usage. + +## Context pooling + +| Benchmark | Mean | Allocated | +|-----------|-----:|----------:| +| Allocate a new `ResilienceContext` | 5.1 ns | 72 B | +| Rent + return from `ResilienceContextPool` | 19.2 ns | 0 B | + +Pooling trades ~14ns of extra CPU time (pool bookkeeping) for eliminating the 72-byte allocation +entirely — a clear win under any sustained load where GC pressure matters more than a few +nanoseconds per call. + +## GC pressure (100,000 executions in one batch) + +| Benchmark | Mean | Gen0 collections | Allocated | +|-----------|-----:|------------------:|----------:| +| Resilion — 100k happy-path retries | 11.4 ms | 2281 | 19.2 MB | +| Polly — 100k happy-path retries | 16.5 ms | 0 | 23 B | + +Resilion is ~1.4x faster in wall-clock time for this workload, at the cost of measurably more +Gen0 collections from the per-call closure allocations (see the pipeline-overhead section +above). Polly.Core's zero-allocation happy path avoids this entirely. Whether the tradeoff +matters depends on your workload — if you're already doing real I/O per call (the overwhelmingly +common case), the GC cost here is unlikely to be the bottleneck. diff --git a/benchmarks/results/Resilion.Benchmarks.CircuitBreakerLoadBenchmarks-report-github.md b/benchmarks/results/Resilion.Benchmarks.CircuitBreakerLoadBenchmarks-report-github.md new file mode 100644 index 0000000..33acd1b --- /dev/null +++ b/benchmarks/results/Resilion.Benchmarks.CircuitBreakerLoadBenchmarks-report-github.md @@ -0,0 +1,15 @@ +``` + +BenchmarkDotNet v0.14.0, macOS 26.6.2 (25G83) [Darwin 25.6.0] +Apple M4 Pro, 1 CPU, 14 logical and 14 physical cores +.NET SDK 10.0.100 + [Host] : .NET 8.0.11 (8.0.1124.51707), Arm64 RyuJIT AdvSIMD + ShortRun : .NET 8.0.11 (8.0.1124.51707), Arm64 RyuJIT AdvSIMD + +Job=ShortRun IterationCount=3 LaunchCount=1 +WarmupCount=3 + +``` +| Method | Mean | Error | StdDev | Gen0 | Allocated | +|-------------------- |---------:|---------:|----------:|-------:|----------:| +| Closed_MixedTraffic | 7.398 μs | 1.034 μs | 0.0567 μs | 0.0763 | 677 B | diff --git a/benchmarks/results/Resilion.Benchmarks.ContextPoolingBenchmarks-report-github.md b/benchmarks/results/Resilion.Benchmarks.ContextPoolingBenchmarks-report-github.md new file mode 100644 index 0000000..3d82a1a --- /dev/null +++ b/benchmarks/results/Resilion.Benchmarks.ContextPoolingBenchmarks-report-github.md @@ -0,0 +1,16 @@ +``` + +BenchmarkDotNet v0.14.0, macOS 26.6.2 (25G83) [Darwin 25.6.0] +Apple M4 Pro, 1 CPU, 14 logical and 14 physical cores +.NET SDK 10.0.100 + [Host] : .NET 8.0.11 (8.0.1124.51707), Arm64 RyuJIT AdvSIMD + ShortRun : .NET 8.0.11 (8.0.1124.51707), Arm64 RyuJIT AdvSIMD + +Job=ShortRun IterationCount=3 LaunchCount=1 +WarmupCount=3 + +``` +| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio | +|---------------------- |----------:|----------:|----------:|------:|--------:|-------:|----------:|------------:| +| AllocateNewContext | 5.141 ns | 0.1539 ns | 0.0084 ns | 1.00 | 0.00 | 0.0086 | 72 B | 1.00 | +| RentAndReturnFromPool | 19.206 ns | 1.5402 ns | 0.0844 ns | 3.74 | 0.02 | - | - | 0.00 | diff --git a/benchmarks/results/Resilion.Benchmarks.GcPressureBenchmarks-report-github.md b/benchmarks/results/Resilion.Benchmarks.GcPressureBenchmarks-report-github.md new file mode 100644 index 0000000..f712730 --- /dev/null +++ b/benchmarks/results/Resilion.Benchmarks.GcPressureBenchmarks-report-github.md @@ -0,0 +1,16 @@ +``` + +BenchmarkDotNet v0.14.0, macOS 26.6.2 (25G83) [Darwin 25.6.0] +Apple M4 Pro, 1 CPU, 14 logical and 14 physical cores +.NET SDK 10.0.100 + [Host] : .NET 8.0.11 (8.0.1124.51707), Arm64 RyuJIT AdvSIMD + ShortRun : .NET 8.0.11 (8.0.1124.51707), Arm64 RyuJIT AdvSIMD + +Job=ShortRun IterationCount=3 LaunchCount=1 +WarmupCount=3 + +``` +| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | Alloc Ratio | +|---------------------------------- |---------:|---------:|---------:|------:|----------:|-----------:|------------:| +| Resilion_100k_HappyPathExecutions | 11.44 ms | 1.862 ms | 0.102 ms | 1.00 | 2281.2500 | 19200012 B | 1.000 | +| Polly_100k_HappyPathExecutions | 16.47 ms | 0.451 ms | 0.025 ms | 1.44 | - | 23 B | 0.000 | diff --git a/benchmarks/results/Resilion.Benchmarks.PipelineOverheadBenchmarks-report-github.md b/benchmarks/results/Resilion.Benchmarks.PipelineOverheadBenchmarks-report-github.md new file mode 100644 index 0000000..5fc2194 --- /dev/null +++ b/benchmarks/results/Resilion.Benchmarks.PipelineOverheadBenchmarks-report-github.md @@ -0,0 +1,22 @@ +``` + +BenchmarkDotNet v0.14.0, macOS 26.6.2 (25G83) [Darwin 25.6.0] +Apple M4 Pro, 1 CPU, 14 logical and 14 physical cores +.NET SDK 10.0.100 + [Host] : .NET 8.0.11 (8.0.1124.51707), Arm64 RyuJIT AdvSIMD + ShortRun : .NET 8.0.11 (8.0.1124.51707), Arm64 RyuJIT AdvSIMD + +Job=ShortRun IterationCount=3 LaunchCount=1 +WarmupCount=3 + +``` +| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio | +|----------------------------- |------------:|-----------:|----------:|----------:|---------:|-------:|----------:|------------:| +| DirectCall | 0.0770 ns | 0.7340 ns | 0.0402 ns | 1.26 | 0.93 | - | - | NA | +| Resilion_Empty | 69.4926 ns | 6.6621 ns | 0.3652 ns | 1,137.25 | 609.03 | 0.0114 | 96 B | NA | +| Polly_Empty | 58.5517 ns | 3.0302 ns | 0.1661 ns | 958.20 | 513.13 | - | - | NA | +| Resilion_Retry_HappyPath | 113.8271 ns | 7.8695 ns | 0.4314 ns | 1,862.78 | 997.55 | 0.0229 | 192 B | NA | +| Polly_Retry_HappyPath | 165.4505 ns | 18.3020 ns | 1.0032 ns | 2,707.60 | 1,450.02 | - | - | NA | +| Resilion_Composite_HappyPath | 392.8716 ns | 43.2298 ns | 2.3696 ns | 6,429.34 | 3,443.15 | 0.1163 | 976 B | NA | +| Polly_Composite_HappyPath | 733.9476 ns | 20.2942 ns | 1.1124 ns | 12,011.05 | 6,432.00 | - | - | NA | +| Resilion_Retry_Sync | 61.1025 ns | 31.5703 ns | 1.7305 ns | 999.94 | 536.18 | 0.0229 | 192 B | NA | diff --git a/benchmarks/results/Resilion.Benchmarks.RealWorldScenarioBenchmarks-report-github.md b/benchmarks/results/Resilion.Benchmarks.RealWorldScenarioBenchmarks-report-github.md new file mode 100644 index 0000000..9e662f1 --- /dev/null +++ b/benchmarks/results/Resilion.Benchmarks.RealWorldScenarioBenchmarks-report-github.md @@ -0,0 +1,23 @@ +``` + +BenchmarkDotNet v0.14.0, macOS 26.6.2 (25G83) [Darwin 25.6.0] +Apple M4 Pro, 1 CPU, 14 logical and 14 physical cores +.NET SDK 10.0.100 + [Host] : .NET 8.0.11 (8.0.1124.51707), Arm64 RyuJIT AdvSIMD + ShortRun : .NET 8.0.11 (8.0.1124.51707), Arm64 RyuJIT AdvSIMD + +Job=ShortRun IterationCount=3 LaunchCount=1 +WarmupCount=3 + +``` +| Method | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated | +|----------------------------------- |----------------:|-----------------:|----------------:|-------:|-------:|-------:|----------:| +| Resilion_HttpClient_HappyPath | 266.6 ns | 24.49 ns | 1.34 ns | 0.0658 | - | - | 552 B | +| Polly_HttpClient_HappyPath | 390.5 ns | 20.09 ns | 1.10 ns | - | - | - | - | +| Resilion_DbQuery_HappyPath | 277.2 ns | 50.27 ns | 2.76 ns | 0.0658 | - | - | 552 B | +| Polly_DbQuery_HappyPath | 253.9 ns | 28.25 ns | 1.55 ns | - | - | - | - | +| Resilion_DbQuery_WithFallback | 48,003,929.3 ns | 22,548,513.09 ns | 1,235,960.09 ns | - | - | - | 2988 B | +| Resilion_Hedging_FastResponse | 10,672.8 ns | 22,537.33 ns | 1,235.35 ns | 0.3662 | 0.1221 | 0.0458 | 2838 B | +| Resilion_HttpClient_Sync_HappyPath | 146.0 ns | 1.23 ns | 0.07 ns | 0.0658 | - | - | 552 B | +| Resilion_DbQuery_Sync_HappyPath | 145.8 ns | 77.91 ns | 4.27 ns | 0.0658 | - | - | 552 B | +| Resilion_DbQuery_Sync_WithFallback | 54,443,839.5 ns | 54,422,093.11 ns | 2,983,058.57 ns | - | - | - | 1849 B | diff --git a/docs/architecture.md b/docs/architecture.md index 406be6e..85334ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,16 +100,18 @@ A call flows inward through each strategy to the user delegate, then outcomes fl ### Sync vs async -Both `Execute` and `ExecuteAsync` are provided. The sync path is a true sync implementation — strategies use `Thread.Sleep` / `WaitHandle` for delays, not sync-over-async wrapping. Exception: Hedging sync always runs sequentially (parallel execution requires async). +Both `Execute` and `ExecuteAsync` are provided. The sync path is a true sync implementation — strategies use `Thread.Sleep` / `WaitHandle` for delays, not sync-over-async wrapping. Exception: Hedging's sync path only supports sequential mode (`HedgingDelay = Timeout.InfiniteTimeSpan`) — parallel/latency modes require concurrent execution, so `Execute()` throws `InvalidOperationException` for those rather than silently degrading to sequential. ## Performance model +See [benchmarks/results](../benchmarks/results/README.md) for measured numbers against Polly.Core. + ### Happy path allocations | Strategy | Allocations | Notes | |----------|------------|-------| | Retry (no retry) | 0 | Predicate check only | -| Circuit Breaker (Closed) | 0 | Volatile read + Interlocked.Increment | +| Circuit Breaker (Closed) | 0 | Volatile state read + single lock acquisition to record/read the ratio | | Timeout (in time) | 1 CTS + 1 ITimer | CTS poolable via TryReset | | Fallback (not triggered) | 0 | Predicate check only | | Rate Limiter (permitted) | 1 lease | Often a struct | @@ -126,7 +128,7 @@ Each `StrategyComponent` in the chain creates a closure `ctx => _next.ExecuteAsy ## Thread safety - **Pipeline** — immutable after construction, safe to share across threads -- **Circuit Breaker** — state machine uses `System.Threading.Lock` for transitions, `Interlocked` for sliding window counters. Callbacks fire outside lock. -- **Sliding Window** — internal lock protects bucket rotation and ratio computation +- **Circuit Breaker** — state machine uses a lock for transitions; failure counting delegates to the Sliding Window. Callbacks fire outside the lock. +- **Sliding Window** — a single internal lock protects bucket rotation, counter increments, and ratio computation; `RecordAndGetRatio` combines recording and reading under one acquisition for atomicity - **ResilienceContextPool** — uses `ConcurrentBag` with approximate size cap - **ResilienceContext** — NOT thread-safe. One context per execution, which flows through the strategy chain sequentially. Hedging creates separate contexts per attempt. diff --git a/docs/cancellation.md b/docs/cancellation.md index aa57c55..6a76d5b 100644 --- a/docs/cancellation.md +++ b/docs/cancellation.md @@ -75,7 +75,7 @@ The Timeout strategy distinguishes the two by checking which token fired: | Yes | Yes (linked) | `OperationCanceledException` | | No | No | Success (completed in time) | -There is a narrow TOCTOU race where user cancellation between checks could be misclassified as timeout. This is nanoseconds wide and not observable in practice. See [future-plans.md](future-plans.md#timeout-cancellation-classification-has-a-narrow-race-window). +There is a narrow TOCTOU race where user cancellation between checks could be misclassified as timeout. This is nanoseconds wide and not observable in practice. Tracked as a fix in [future-plans.md](future-plans.md#46-timeout-cancellation-toctou-race-fix). ## Cooperative cancellation diff --git a/docs/circuit-breaker.md b/docs/circuit-breaker.md index e02221e..f6c69f6 100644 --- a/docs/circuit-breaker.md +++ b/docs/circuit-breaker.md @@ -32,6 +32,7 @@ catch (CircuitBrokenException ex) | `SamplingDuration` | `TimeSpan` | 30s | Sliding window for tracking | | `MinimumThroughput` | `int` | 10 | Min calls before ratio is evaluated | | `BreakDuration` | `TimeSpan` | 30s | How long circuit stays open | +| `BreakDurationGenerator` | `Func` | null | Computes the break duration per trip (e.g. exponential backoff on repeated trips); overrides `BreakDuration` when set | | `ShouldHandle` | `Func` | All except `OperationCanceledException` | What counts as failure | | `OnOpened` | `ResilienceEventHandler` | null | Fired on Closed → Open | | `OnClosed` | `ResilienceEventHandler` | null | Fired on HalfOpen → Closed | diff --git a/docs/comparison-with-polly.md b/docs/comparison-with-polly.md new file mode 100644 index 0000000..b9a4693 --- /dev/null +++ b/docs/comparison-with-polly.md @@ -0,0 +1,85 @@ +# Why Resilion over Polly? + +Polly is great software that moved the .NET ecosystem forward. Years of production use, a huge +ecosystem, and a large team behind it. If you're evaluating alternatives, you deserve an honest +answer to "why would I pick the newer, smaller library?" — not a sales pitch. Here it is. + +## Where Resilion is better today + +**Zero dependencies in the core package.** `Resilion` pulls in nothing. `Polly.Core` is also +lean, but the moment you want DI integration or HTTP resilience, you're pulling in +`Microsoft.Extensions.*` packages Polly itself depends on. Resilion's `Resilion.Extensions` and +`Resilion.RateLimiting` are opt-in, separate packages — the core stays dependency-free no matter +what you add. + +**True synchronous execution, not sync-over-async.** Polly's synchronous surface exists, but +under the hood several code paths still route through `Task`/`ValueTask` machinery. +Resilion's `Execute()` path uses real synchronous primitives — `Thread.Sleep`, `WaitHandle` — for +every built-in strategy. This matters for ASP.NET Framework, WinForms/WPF UI threads, and any +code that can't safely call `.GetAwaiter().GetResult()` on an async path without risking deadlock. + +**`RetryDelay` as a discriminated union.** Polly's retry options have `Delay`, `BackoffType`, +`UseJitter`, and `DelayGenerator` as independent properties — nothing stops you from setting +`DelayGenerator` and `BackoffType` at the same time with unclear precedence. Resilion's +`RetryDelay.Constant/Linear/Exponential/Custom` are mutually exclusive by construction. There's +exactly one way to configure the delay strategy, and the compiler enforces it. + +**Sync callback ergonomics.** Polly's callbacks (`OnRetry`, `OnOpened`, ...) always return +`ValueTask`, even for a callback that just increments a counter or writes a log line — the 90% +case. Resilion's `ResilienceEventHandler` accepts a plain `Action` *or* an async +`Func` via implicit conversion, so the common synchronous case pays nothing +for `ValueTask` wrapping. + +**Simpler API surface.** One static factory (`Pipeline.Create`), one builder per pipeline +kind, options classes that read the same way every time. No `PredicateBuilder`, no +`ResiliencePropertyKey` juggling beyond what you actually need. Less to learn before you're +productive. + +**Always free.** No paid tier, no "enterprise edition," no plans to add one — see the README. + +## Where Polly is better today + +**`IHttpClientFactory` integration.** Polly's `Microsoft.Extensions.Http.Resilience` package +gives you `AddStandardResilienceHandler()` — a pre-configured 5-strategy pipeline wired +directly into `HttpClient` with one line. This is the single most common resilience use case, +and Resilion doesn't have an equivalent yet. If you need this today, use Polly (or wrap +`Resilion` in your own `DelegatingHandler` in the meantime). + +**Chaos engineering.** Polly ships Simmy — fault, outcome, latency, and behavior injection for +resilience testing in non-production environments. Resilion has no equivalent. + +**Dynamic reload.** Polly pipelines can auto-recreate when bound `IOptionsMonitor` options +change. Resilion's pipelines are immutable once built; changing configuration means building a +new one and swapping it in yourself. + +**A dedicated testing package.** Polly has patterns and (community) packages for asserting on +pipeline behavior in tests. Resilion doesn't have a `Resilion.Testing` package yet — you write +tests the way this repo's own test suite does (see [docs/testing.md](testing.md)), which works +fine but isn't packaged up for you. + +**Years of battle-testing and a large ecosystem.** Polly has been in production across a huge +number of .NET codebases since well before Resilion existed. If your organization needs that +track record as a prerequisite, Polly is the safer choice right now. + +**`PredicateBuilder` fluent API.** Polly's `new PredicateBuilder().Handle().HandleResult(...)` +composes predicates without writing the lambda by hand. Resilion requires the full +`Func, bool>` — more explicit, more verbose for complex predicates. + +## Roadmap to close the gap + +Every item above that Resilion doesn't have yet is tracked with a concrete design in +[future-plans.md](future-plans.md): + +| Feature | Priority | future-plans.md item | +|---------|----------|----------------------| +| `Resilion.Http` — `IHttpClientFactory` integration | Highest | #39 | +| `Resilion.Testing` — test doubles, assertions | High | #10 | +| Dynamic reload via `IOptionsMonitor` | Medium | #41 | +| `Resilion.Chaos` — chaos engineering | Medium | #40 | +| `PredicateBuilder` fluent API | Lower | #43 | +| `IConfiguration` binding for strategy options | Lower | #44 | +| Telemetry enrichment | Lower | #45 | + +If one of these is a hard blocker for you today, Polly remains the right choice until Resilion +catches up. If it isn't, Resilion is a smaller, simpler, equally-capable core for everything +else — and it's free forever either way. diff --git a/docs/future-plans.md b/docs/future-plans.md index 0142aca..21fccfb 100644 --- a/docs/future-plans.md +++ b/docs/future-plans.md @@ -1,6 +1,9 @@ # Future Plans -Features, improvements, and known tradeoffs under consideration for future versions of Resilion. Each section includes enough detail to serve as a starting point for implementation. +Features, improvements, and known tradeoffs still open for future versions of Resilion. This +is a todo list, not a changelog — once something here is implemented, it comes out of this file +(see [CHANGELOG.md](../CHANGELOG.md) for what's already shipped). Each section includes enough +detail to serve as a starting point for implementation. ## Type Classification @@ -21,22 +24,188 @@ Features, improvements, and known tradeoffs under consideration for future versi | # | Item | Priority | Type | Impact | Effort | Notes | |---|------|----------|------|--------|--------|-------| -| 26 | CB typed/non-generic strategy duplication (~200 lines) | P2 | Improvement | Medium | Medium | Extract shared state machine class | -| 27 | Retry typed/non-generic strategy duplication (~100 lines) | P2 | Improvement | Medium | Medium | Extract shared retry loop | -| 28 | PipelineBuilder typed/non-generic duplication (~80 lines) | P2 | Improvement | Medium | Medium | Extract shared base class | -| 29 | ShouldHandleOutcome copy-pasted across 4 options classes | P2 | Improvement | Low | Low | Extract to shared static helper | +| 39 | Resilion.Http — HttpClient integration | P1 | Feature | Critical | High | AddStandardResilienceHandler() for IHttpClientFactory. #1 missing feature for adoption | +| 49 | Telemetry counters carry no dimensions/tags | P1 | Fix | Critical | Medium | Add pipeline.name, operation.key tags to all counter increments | +| 50 | ActivitySource is declared but never used | P1 | Fix | Medium | Medium | Wrap strategy execution in StartActivity() or remove promise | +| 51 | No public API surface tracking | P1 | Infrastructure | Critical | Low | PublicApiAnalyzers, PublicAPI.Shipped.txt, CI enforcement | | 4 | Per-call delegate allocation in pipeline chain | P2 | Fix | Medium | High | Middleware pattern inherent cost | | 5 | CancellationTokenSource pooling for Timeout/Hedging | P2 | Improvement | Medium | Medium | Reduce alloc on hot path | | 11 | Hedging: Task.WhenAny memory leak | P2 | Fix | Medium | Medium | Continuations not deregistered on losing tasks | +| 27 | Retry typed/non-generic strategy duplication (~100 lines) | P2 | Improvement | Medium | Medium | Extract shared retry loop | +| 40 | Resilion.Chaos — Chaos engineering | P2 | Feature | Medium | High | Fault/outcome/latency/behavior injection (Simmy equivalent) | +| 41 | Pipeline dynamic reload via IOptionsMonitor | P2 | Feature | Medium | High | Auto-recreate pipeline when bound options change | +| 42 | Keyed services support | P2 | Feature | Medium | Medium | [FromKeyedServices("key")] for direct DI injection (.NET 8+) | +| 46 | Timeout cancellation TOCTOU race fix | P2 | Fix | Low | Low | Eliminate narrow race in WasCancelledByTimeout — moved from tradeoffs.md | +| 52 | No SourceLink, symbol packages, or deterministic builds | P2 | Infrastructure | Medium | Low | PublishRepositoryUrl, IncludeSymbols, SymbolPackageFormat, ContinuousIntegrationBuild | | 9 | WaitHandle allocation in sync retry | P3 | Fix | Low | Low | CancellationToken.WaitHandle lazy alloc | | 10 | Resilion.Testing package | P3 | Feature | Medium | Low | Test doubles, assertion helpers | | 12 | Hedging: HedgingDelayGenerator | P3 | Feature | Low | Low | Dynamic delay per attempt | -| 14 | Hedging: HedgingRejectedException with aggregated errors | P3 | Improvement | Low | Low | Currently dead code — never thrown | | 30 | FallbackAction/ResilienceEventHandler Task.Run missing CancellationToken | P3 | Fix | Low | Low | Sync-over-async wrappers can't be interrupted by cancellation | +| 43 | PredicateBuilder<T> fluent API | P3 | Feature | Low | Medium | .Handle<TException>().HandleResult(r => ...) with implicit conversion | +| 44 | IConfiguration binding for strategy options | P3 | Feature | Low | Medium | Strategy options from appsettings.json | +| 45 | Telemetry enrichment (MeteringEnricher, listeners) | P3 | Feature | Low | Medium | Custom tags, raw event listeners, severity control | +| 47 | CI code coverage collection | P3 | Infrastructure | Low | Medium | Blocked — verified incompatibility, see item #47 below | +| 48 | Benchmark CI on PRs | P3 | Infrastructure | Low | Low | Catch perf regressions automatically instead of only on manual runs | +| 53 | No SECURITY.md | P3 | Documentation | Low | Low | Vulnerability disclosure policy for OSS infrastructure library | +| 54 | Evaluate multi-targeting net9.0 for System.Threading.Lock | P3 | Improvement | Low | Medium | Benchmark vs object lock, conditional compile if faster | + +--- + +## P1 — Pre-v1.0 or Shortly After + +### ✅ 49. Telemetry Counters Carry No Dimensions/Tags — IMPLEMENTED + +**Status:** Complete +**What was done:** +1. Added `pipeline.name` and `operation.key` tag constants to `ResilionTelemetry` +2. Extended `ResilienceContext` with `PipelineName` property +3. Modified `Pipeline` and `Pipeline` to store and pass pipeline name through context +4. Updated `PipelineBuilder.Build()` to pass name to Pipeline constructor +5. Updated `ResiliencePipelineRegistry` to set builder.Name from the pipeline key +6. Updated all counter.Add() calls in 6 strategy files to include tags: + - RetryStrategy.cs (4 locations) + - CircuitBreakerStateMachine.cs (2 locations) + - TimeoutStrategy.cs (2 locations) + - FallbackStrategy.cs (2 locations) + - HedgingStrategy.cs (1 location) + - RateLimiterStrategy.cs (2 locations) + +**Files modified:** 11 core files + bug fixes +**Verification:** All counters now emit with pipeline.name and operation.key tags + +--- + +### ✅ 50. ActivitySource Is Declared But Never Used — IMPLEMENTED + +**Status:** Complete +**What was done:** +1. Added `using System.Diagnostics` to 6 strategy files +2. Wrapped strategy execution in `ActivitySource.StartActivity()` calls +3. Set tags for strategy name, pipeline name, and operation key on each activity +4. Tagged outcomes with strategy-specific values (success, failure, timeout, rejected, etc.) +5. Implemented spans in: + - RetryStrategy (both async and sync) + - TimeoutStrategy (both async and sync) + - FallbackStrategy (both async and sync) + - HedgingStrategy (async path) + - CircuitBreakerStrategy (both async and sync) + - RateLimiterStrategy (both async and sync) + +**Files modified:** 6 strategy files +**Verification:** Spans now emitted with distributed tracing support enabled + +--- + +### ✅ 51. No Public API Surface Tracking — IMPLEMENTED + +**Status:** Complete +**What was done:** +1. Added `Microsoft.CodeAnalysis.PublicApiAnalyzers` (v3.3.4) to `src/Directory.Build.props` +2. Created PublicAPI.Shipped.txt and PublicAPI.Unshipped.txt files for all 3 public packages: + - Resilion/PublicAPI.Shipped.txt + - Resilion/PublicAPI.Unshipped.txt + - Resilion.Extensions/PublicAPI.Shipped.txt + - Resilion.Extensions/PublicAPI.Unshipped.txt + - Resilion.RateLimiting/PublicAPI.Shipped.txt + - Resilion.RateLimiting/PublicAPI.Unshipped.txt +3. Analyzer now enforces that all public APIs must be explicitly tracked +4. CI builds will fail if public surface changes without updating the .Unshipped.txt files + +**Files modified:** 1 (Directory.Build.props) + 6 new API tracking files +**Verification:** Build succeeds, analyzer correctly identifies undocumented public APIs + +--- + +## P1 — Pre-v1.0 or Shortly After + +### 39. Resilion.Http — HttpClient Integration + +**Type:** Feature — Critical for Adoption + +**Why** + +The most common use case for a resilience library is wrapping `HttpClient` calls. Polly's `Microsoft.Extensions.Http.Resilience` package provides `AddStandardResilienceHandler()` which adds a pre-configured 5-strategy pipeline to an `HttpClient` via `IHttpClientFactory`. Without this, Resilion misses the #1 onboarding path. + +**Design** + +New package `Resilion.Http` with: +- `AddStandardResilienceHandler()` extension on `IHttpClientBuilder` — pre-configured pipeline: RateLimiter → TotalTimeout → Retry → CircuitBreaker → AttemptTimeout, with defaults matching Polly's (retry on 5xx, 429, 408, `HttpRequestException`, `TimeoutRejectedException`) +- `AddStandardHedgingHandler()` — replaces retry with hedging for latency-sensitive scenarios +- `AddResilienceHandler(key, builder => ...)` — custom pipeline on a named HttpClient + +Implementation: `DelegatingHandler` wrapping the Resilion pipeline. References `Resilion.Extensions` and `Microsoft.Extensions.Http`. + +**Performance note:** HttpClient pipelines are a hot path in high-throughput services (potentially millions of calls/sec). The per-call delegate allocation in the pipeline chain (#4 below) is acceptable for general use but may start showing up in profiles once this package ships. Benchmark the standard resilience handler under load and revisit #4 if closure allocations become a measurable cost at that scale. + +--- + +### 49. Telemetry Counters Carry No Dimensions/Tags + +**Type:** Fix — Correctness + +**Why** + +Every counter increment in the codebase fires with zero tags. In `RetryStrategy.cs`, `CircuitBreakerStateMachine.cs`, `TimeoutStrategy.cs`, `FallbackStrategy.cs`, `HedgingStrategy.cs`, and `RateLimiterStrategy.cs`, every call to `.Add(1)` on counters like `ResilionTelemetry.RetryAttempts`, `CircuitBreakerStateChanges`, `TimeoutExpirations`, `FallbackActivations`, `HedgingAttempts`, and `RateLimiterRejections` passes zero tags. + +In an application with two named pipelines both using Retry, the `resilion.retry.attempts` counter cannot distinguish which pipeline is retrying. The counter becomes nearly useless for observability in multi-pipeline scenarios. + +**Fix** + +Thread the following tags to every `.Add()` call: +- `pipeline.name` — sourced from `PipelineBuilderBase.Name` (already available at build time, must be passed through `ResilienceContext` at execution time) +- `operation.key` — sourced from `ResilienceContext.OperationKey` (already exists but is never read for telemetry) + +Grep confirms zero tag usage across all strategy files. This is a correctness gap in the telemetry contract, not a nice-to-have. Untagged counters in multi-instance deployments are close to useless for debugging and alerting. + +**Effort:** Medium. Requires threading pipeline name through context initialization and reading `OperationKey` at each counter site. + +--- + +### 50. ActivitySource Is Declared But Never Used + +**Type:** Fix — Correctness + +**Why** + +`ResilionTelemetry.ActivitySource` is declared as `new ActivitySource("Resilion")` and its XML documentation explicitly instructs consumers to `.AddSource("Resilion")` for distributed tracing support. Grep across all of `src/` finds zero calls to `.StartActivity()` anywhere in the codebase. + +This is the same class of problem as the dead `StrategyExecutions` and `StrategyDuration` counters that were already removed from this file — an unverified telemetry promise is worse than no promise. The ActivitySource is technically "used" (publicly declared, referenced in XML docs) but never actually emits a span. Any consumer configuring their tracing stack to listen for "Resilion" will receive nothing. + +**Fix** + +Either: + +1. **Implement spans:** Wrap each strategy's `ExecuteAsync` and `Execute` entry point in a `StartActivity()` call, tagged with strategy name, pipeline name, and outcome (success/failure/cancelled). Wire the resulting `Activity` through to telemetry for correlation. This is the preferred path — spans are valuable for latency profiling and distributed tracing in high-scale systems. + +2. **Remove the ActivitySource:** Delete the declaration and the misleading XML docs if tracing is not a v1.0 priority. This is acceptable; the promise can be reinstated when the feature is actually implemented. + +The current state (declared but unused) is untenable — it signals a feature that doesn't work. + +**Effort:** Medium for implementation, trivial for removal. --- -## P2 — Do Next +### 51. No Public API Surface Tracking + +**Type:** Infrastructure — Correctness + +**Why** + +Nothing in this repository guards against accidental breaking changes after v1.0 ships. Confirmed via repo-wide grep: zero matches for "PublicAPI", "ApiCompat", or "PublicApiAnalyzers". No `Microsoft.CodeAnalysis.PublicApiAnalyzers` in dependencies. No `PublicAPI.Shipped.txt` / `PublicAPI.Unshipped.txt` files. No `Microsoft.DotNet.ApiCompat` CI check. + +Once the API is frozen at v1.0, there is no automated enforcement preventing a later PR from silently changing a public method signature, removing a member, or adding an unintended public API. This is standard practice in shipping libraries (Polly, NUnit, xunit all use PublicApiAnalyzers). + +**Fix** + +1. Add `Microsoft.CodeAnalysis.PublicApiAnalyzers` NuGet package to `src/Directory.Build.props`. +2. After v1.0 ships, generate `PublicAPI.Shipped.txt` files for each public project per the tool's documentation. +3. Add a CI check in `.github/workflows/` that fails any PR changing public surface without updating the corresponding `.Unshipped.txt` file. + +**Effort:** Low. The package is zero-configuration after initial setup. The CI check is a single MSBuild step. + +--- + +## P2 — Do Next / Post-v1.0 ### 4. Per-Call Delegate Allocation in Pipeline Chain @@ -48,13 +217,13 @@ In `StrategyComponent.ExecuteAsync`, the lambda `ctx => _next.ExecuteAsync(callb **Current State** -This is inherent to the middleware/chain-of-responsibility pattern. Polly v8 has the same cost. The allocations are small (one closure object + one delegate per strategy per call) and negligible compared to real I/O costs. +This is inherent to the middleware/chain-of-responsibility pattern. Polly v8 has the same cost. The allocations are small (one closure object + one delegate per strategy per call) and negligible compared to real I/O costs. Confirmed empirically in [benchmarks/results](../benchmarks/results/README.md): Resilion's happy-path pipelines allocate a few hundred bytes where Polly's are allocation-free, but Resilion is still faster wall-clock time across every shape benchmarked. **Potential Fix** Pre-compose the delegate chain at build time into a single `Func, ValueTask>>` that doesn't allocate per call. This requires complex generic type threading and may not be feasible without sacrificing API simplicity. -**Decision:** Accept for now. Benchmark to confirm the cost is negligible relative to real workloads. Revisit if benchmarks show it matters. +**Decision:** Accept for now. Revisit if profiling on a real high-throughput workload (see #39's performance note) shows it matters. --- @@ -76,7 +245,6 @@ Use `ObjectPool` with a custom policy that calls `TryRe --- - ### 11. Hedging: Task.WhenAny Memory Leak **Type:** Fix — Performance/Correctness @@ -95,24 +263,110 @@ Implement a custom `FirstCompletedAsync` that uses `TaskCompletionSource` + per- --- -### 26–29. Strategy/Builder/Options Duplication +### 27. Retry Typed/Non-Generic Strategy Duplication **Type:** Improvement — Maintainability **Why** -Code review identified significant duplication between typed and non-generic variants: -- **#26:** `CircuitBreakerStrategy` / `CircuitBreakerTypedStrategy` — ~200 lines of identical state machine logic -- **#27:** `RetryStrategy` / `RetryStrategy` — ~100 lines of identical retry loop (4 copies: async+sync × typed+untyped) -- **#28:** `PipelineBuilder` / `PipelineBuilder` — ~80 lines of identical Build/EmitWarnings/properties -- **#29:** `ShouldHandleOutcome` — identical method body across `FallbackStrategyOptions`, `RetryStrategyOptions`, `CircuitBreakerStrategyOptions`, `HedgingStrategyOptions` +`RetryStrategy` / `RetryStrategy` duplicate ~100 lines of identical retry loop (4 copies: async+sync × typed+untyped). The equivalent circuit breaker duplication was already extracted into a shared `CircuitBreakerStateMachine` — that extraction is exactly what let the CB race-condition fix apply to both variants automatically instead of needing to be kept in sync by hand. Retry doesn't have shared mutable state the way CB does, so the risk of silent divergence is lower, but the duplication itself is the same shape of problem. -**Fix:** Extract shared base classes, helper classes, or static methods. For CB, a shared `CircuitBreakerStateMachine` class parameterized on a failure predicate. For Retry, a shared loop parameterized on `Func, bool>`. For Builder, a `PipelineBuilderBase`. For ShouldHandle, a static `OutcomePredicates.Default()`. +**Fix** + +Extract a shared retry loop parameterized on `Func, bool>` for the predicate check, following the same pattern used for the CB state machine. **Risk:** A bug fix applied to one copy but not the other causes silent behavioral divergence between typed and non-generic variants of the same strategy. --- +### 40. Resilion.Chaos — Chaos Engineering + +**Type:** Feature + +**Why** + +Polly v8.3+ includes Simmy for chaos engineering — fault injection, outcome injection, latency injection, and behavior injection. This is table stakes for production resilience testing. + +**Design** + +New package `Resilion.Chaos` with four strategies: `AddChaosFault`, `AddChaosOutcome`, `AddChaosLatency`, `AddChaosBehavior`. Common options: `InjectionRate` (0–1), `InjectionRateGenerator` (dynamic), `Enabled` (bool), `EnabledGenerator` (dynamic enable/disable). Generators take precedence over static values. Chaos strategies go last in the pipeline so outer resilience strategies react to injected chaos. + +--- + +### 41. Pipeline Dynamic Reload via IOptionsMonitor + +**Type:** Feature + +**Why** + +Polly supports `context.EnableReloads()` which auto-recreates pipelines when `IOptionsMonitor` detects a configuration change. Without this, changing retry counts or timeout durations requires an app restart. + +**Design** + +`ResiliencePipelineRegistry` gains `IChangeToken` support. When a watched `IOptionsMonitor` fires, the registry invalidates the cached `Lazy` for the affected key, creates a new one from the updated options, and atomically swaps it. In-flight executions on the old pipeline complete normally; new calls get the new pipeline. `OnPipelineDisposed` callback fires after the old pipeline has no outstanding executions. + +--- + +### 42. Keyed Services Support + +**Type:** Feature + +**Why** + +.NET 8 introduced keyed DI services (`[FromKeyedServices("key")]`). Polly v8.3+ supports this for direct injection of named pipelines without going through the registry. + +**Fix:** Register each named pipeline as a keyed singleton alongside the registry entry. Consumers can inject `[FromKeyedServices("http-api")] Pipeline pipeline` directly. + +--- + +### 46. Timeout Cancellation TOCTOU Race Fix + +**Type:** Fix — Correctness (moved from [tradeoffs.md](tradeoffs.md)) + +**Why** + +`WasCancelledByTimeout` in `TimeoutStrategy.cs` checks `linkedCts.IsCancellationRequested && !userToken.IsCancellationRequested`. Between the two reads, the user token could become cancelled, causing user cancellation to be misclassified as a timeout (`TimeoutRejectedException` instead of `OperationCanceledException`). The window is nanoseconds wide but it exists, and "we know about the race but chose not to fix it" is a bad look for a library claiming production readiness. + +**Fix** + +Capture the user token's cancellation state **before** awaiting the callback: + +1. Read `bool userWasCancelledBefore = userToken.IsCancellationRequested` before the `await callback(context)` call. +2. After the callback throws `OperationCanceledException`, classify as timeout if: `linkedCts.IsCancellationRequested && !userWasCancelledBefore && !userToken.IsCancellationRequested`. +3. The pre-capture eliminates the race: if the user token was already cancelling before the call, it's always user cancellation. If it wasn't, and the linked CTS fired, it's a timeout. + +Alternative (even tighter): register a `CancellationTokenRegistration` on the user token that sets a `volatile bool _userCancelled` flag. Check that flag instead of re-reading the token. Dispose the registration in `finally`. This is ~5 lines and eliminates the race entirely. + +**Effort:** Low. The fix is a few lines in `TimeoutStrategy.cs`. + +--- + +### 52. No SourceLink, Symbol Packages, or Deterministic Builds + +**Type:** Infrastructure — Developer Experience + +**Why** + +Confirmed via grep of `src/Directory.Build.props`: zero instances of `PublishRepositoryUrl`, `EmbedUntrackedSources`, `IncludeSymbols`, `SymbolPackageFormat`, or `ContinuousIntegrationBuild` properties anywhere in the build configuration. + +Consumers of the Resilion NuGet packages cannot step into Resilion's source code while debugging — SourceLink metadata is missing. No `.snupkg` symbol package is published. This is low-effort but high-trust: Polly, most Microsoft.Extensions libraries, and serious .NET OSS projects enable this by default. + +**Fix** + +1. Add `Microsoft.SourceLink.GitHub` package reference to `src/Directory.Build.props`. +2. Add the following properties to `src/Directory.Build.props` (or per-project as needed): + - `true` + - `true` + - `true` + - `snupkg` +3. In the release CI job (`.github/workflows/`), set `true` only during the release build (not in local dev builds — deterministic builds interact poorly with incremental local builds). + +**Impact:** Users can now debug into Resilion. Symbol packages enable IDE integration and offline symbol resolution. + +**Effort:** Low. These are standard MSBuild properties. No code changes required. + +--- + ## P3 — Backlog ### 9. WaitHandle Allocation in Sync Retry @@ -167,35 +421,130 @@ public readonly record struct HedgingDelayGeneratorArgs(int AttemptNumber, Resil --- -### 14. Hedging: HedgingRejectedException with Aggregated Errors +### 30. FallbackAction/ResilienceEventHandler Task.Run Missing CancellationToken + +**Type:** Fix — Minor correctness + +**Why** + +Both `FallbackAction.Execute` and `ResilienceEventHandler.Invoke` call `Task.Run(() => ...).GetAwaiter().GetResult()` for sync-over-async without passing `CancellationToken` to `Task.Run`. The blocking call can't be interrupted by cancellation. + +**Impact:** Low. Only affects sync path with async handlers/factories, and the underlying async operation would need to observe its own token anyway. + +--- + +### 43. PredicateBuilder<T> Fluent API + +**Type:** Feature — API Ergonomics + +**Why** + +Polly provides `new PredicateBuilder().Handle().HandleResult(r => r.StatusCode == 500)` with implicit conversion to `Func, bool>`. Resilion requires writing the full predicate lambda manually. For simple cases this is fine, but complex predicates get verbose. + +**Fix:** Add a `PredicateBuilder` class with `.Handle()`, `.HandleResult(Func)`, and implicit conversion to `Func, bool>`. Low priority — the current lambda approach works and is more explicit. + +--- + +### 44. IConfiguration Binding for Strategy Options + +**Type:** Feature + +**Why** + +Enable binding strategy options from `appsettings.json` so timeout durations, retry counts, and other parameters can be changed without recompiling. Requires #41 (dynamic reload) to be useful — without reload, config changes require app restart. + +--- + +### 45. Telemetry Enrichment + +**Type:** Feature + +**Why** + +Polly supports `MeteringEnricher` (custom tags on all telemetry events), `TelemetryListeners` (raw event listeners for custom consumers), and `SeverityProvider` (adjust/suppress event severity). Resilion's current telemetry is static counters only. + +**Fix:** Add extensibility points in `Resilion.Extensions` for custom meter tags and raw event subscription. Low priority unless enterprise users need it. + +--- + +### 47. CI Code Coverage Collection + +**Type:** Infrastructure + +**Why** + +The test projects target `Microsoft.Testing.Platform` (MTP) via `xunit.v3`, not classic VSTest. `coverlet.collector` — currently referenced in `tests/Directory.Build.props` — is a VSTest data collector, so `dotnet test --collect:"XPlat Code Coverage"` is silently ignored under MTP: verified locally, it produces zero output and no error. The MTP-native replacement, `Microsoft.Testing.Extensions.CodeCoverage`, does work in isolation (`dotnet test -- --coverage --coverage-output-format cobertura` produces a real `.cobertura.xml`) — but adding it as a shared package reference (`tests/Directory.Build.props`) broke `dotnet build`/`dotnet restore` entirely on this machine's installed SDK (10.0.100, resolved via `global.json`'s `rollForward: latestMajor`) with `NETSDK1013: The TargetFramework value '' was not recognized` — reproduced twice, and confirmed the package is the cause by reverting it and rebuilding successfully. This looks like a version incompatibility between `Microsoft.Testing.Extensions.CodeCoverage` 17.14.2 and the newest installed SDK, not a problem with the approach itself. + +**Fix** + +Retry with a newer `Microsoft.Testing.Extensions.CodeCoverage` version once one compatible with the SDK(s) this repo actually builds against is confirmed (test in isolation on a throwaway branch before merging into the shared `Directory.Build.props`, since a bad version breaks every project that imports it, not just the one being tested). `coverlet.collector` can then be removed as dead weight. Once collection works, wire `--coverage` into `.github/workflows/test.yml`, upload the `.cobertura.xml` as a build artifact, and only then consider a coverage badge — a badge without a live report behind it is an unverified claim. + +**Decision:** Left `coverlet.collector` in place for now (harmless no-op under MTP, at least doesn't break the build) rather than leave the tree in the broken state discovered above. + +--- + +### 48. Benchmark CI on PRs -**Type:** Improvement — Error Reporting +**Type:** Infrastructure **Why** -`HedgingRejectedException` is defined but never thrown. When all hedging attempts fail, the implementation currently returns the last failure's exception directly via `Outcome`. This means the user only sees the last attempt's exception, losing context about earlier failures. +`benchmarks/results/README.md` currently only gets updated by someone manually running the suite and committing the numbers. Nothing catches a PR that accidentally regresses performance between those manual runs. **Design** -When all attempts fail, collect all attempt exceptions and either: -- Throw `HedgingRejectedException` with `AttemptExceptions` containing all failures, or -- Return an `Outcome` with the primary (first) attempt's exception but attach others via `AggregateException` +A GitHub Actions workflow that runs `dotnet run -c Release --project benchmarks/Resilion.Benchmarks -- --job short` on PRs touching `src/` or `benchmarks/`, and compares the result against a committed baseline (fail or comment on a regression past some threshold). Not critical for v1.0, but valuable for ongoing development once the library has enough usage that regressions would actually be noticed by users first. + +--- + +### 53. No SECURITY.md + +**Type:** Documentation + +**Why** + +No vulnerability disclosure policy exists (confirmed: `SECURITY.md` not found in repo root). This is a standard, low-effort expectation for OSS infrastructure libraries — it tells users how to privately report security issues instead of disclosing them publicly on GitHub. + +**Fix** -The first option changes behavior for users who `catch (InvalidOperationException)` today — their catch would stop matching. The second is backward-compatible. +Add a `SECURITY.md` in the repository root documenting: +- **Supported versions:** Which versions of Resilion receive security updates (typically N and N-1). +- **Reporting:** Link to GitHub's private security advisory feature (Settings → Security & Analysis → Private vulnerability reporting). Include a brief note that security reports should be filed there instead of public issues. +- **Response SLA:** Expected timeline for acknowledgement and patch release (e.g., "critical issues patched within 7 days"). -**Decision:** Defer. The current behavior (last exception propagates) is simple and predictable. Revisit if users need aggregated error reporting. +This is boilerplate text; most large OSS projects have near-identical policies. Low effort, high trust signal. + +**Effort:** Low. One short document. --- -### 30. FallbackAction/ResilienceEventHandler Task.Run Missing CancellationToken +### 54. Evaluate Multi-Targeting net9.0 for System.Threading.Lock -**Type:** Fix — Minor correctness +**Type:** Improvement — Performance **Why** -Both `FallbackAction.Execute` and `ResilienceEventHandler.Invoke` call `Task.Run(() => ...).GetAwaiter().GetResult()` for sync-over-async without passing `CancellationToken` to `Task.Run`. The blocking call can't be interrupted by cancellation. +`src/Directory.Build.props` targets `net8.0` only (confirmed). .NET 9 introduced `System.Threading.Lock`, a purpose-built synchronization primitive faster than `lock(object)` under contention, especially in hot-path code with many concurrent waiters. -**Impact:** Low. Only affects sync path with async handlers/factories, and the underlying async operation would need to observe its own token anyway. +`CircuitBreakerStateMachine` and `SlidingWindow` both hold a lock on every call while in the Closed state — the most common state in healthy systems. If benchmarks show `System.Threading.Lock` provides a measurable win in that scenario, multi-targeting is justified. + +**Fix** + +1. **Benchmark first:** Run the existing circuit-breaker-under-load benchmarks with both `lock(object)` and `System.Threading.Lock` implementations in isolation. Only proceed if the new lock type shows >5% wall-clock improvement under the standard load profile. + +2. **If justified:** Multi-target `net8.0;net9.0` in `src/Directory.Build.props`. Add conditional compilation in `CircuitBreakerStateMachine.cs` and `SlidingWindow.cs`: + ```csharp + #if NET9_0_OR_GREATER + private System.Threading.Lock _lock = new(); + #else + private readonly object _lock = new(); + #endif + ``` + Both APIs are compatible with the same lock syntax; only the initialization changes. + +3. **Don't multi-target speculatively.** The cost is a more complex build matrix and CI test surface. Only add net9.0 if the benchmark proves it matters. + +**Effort:** Medium. Primarily benchmarking time. The conditional code change is trivial. --- diff --git a/docs/hedging.md b/docs/hedging.md index b3264f5..e160815 100644 --- a/docs/hedging.md +++ b/docs/hedging.md @@ -91,7 +91,7 @@ This is a key difference from Polly, which cancels but does not await losing tas ## All attempts fail -When every attempt fails, the last failure's exception propagates. Only the most recent exception surfaces — earlier attempt exceptions are not aggregated. (See [future-plans.md](future-plans.md#14-hedging-hedgingrejectedexception-with-aggregated-errors) for planned improvement.) +When every attempt fails, the last failure's exception propagates. Only the most recent exception surfaces — earlier attempt exceptions are not aggregated. Aggregated error reporting could be added post-v1.0 as an opt-in flag if it turns out to matter in practice, without breaking the current catch behavior. ## Typed pipelines only @@ -99,7 +99,7 @@ Hedging requires `Pipeline` because it must produce a result of the cor ## Sync execution -Sync path always runs sequentially regardless of `HedgingDelay` — parallel execution requires async. Each attempt runs to completion (or failure) before the next starts. +The sync path only supports sequential mode — set `HedgingDelay = Timeout.InfiniteTimeSpan`. Each attempt then runs to completion (or failure) before the next starts. Parallel mode (`TimeSpan.Zero`) and latency mode (any other positive delay) require concurrent execution, so calling `Execute()` with either throws `InvalidOperationException` instead of silently running sequentially — use `ExecuteAsync()` for those modes. ## Result-based hedging diff --git a/docs/installation.md b/docs/installation.md index e5a0648..fb8f56f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -109,7 +109,7 @@ var pipeline = Pipeline.Create(b => b ## Supported .NET versions -- .NET 9.0+ +- .NET 8.0+ ## License diff --git a/docs/migration-from-polly.md b/docs/migration-from-polly.md new file mode 100644 index 0000000..fc98edf --- /dev/null +++ b/docs/migration-from-polly.md @@ -0,0 +1,158 @@ +# Migrating from Polly + +A concept-by-concept and code-by-code map from Polly v8 (`Polly.Core`) to Resilion. This is the +fastest path if you already know Polly. + +## Concept mapping + +| Polly | Resilion | Notes | +|-------|----------|-------| +| `ResiliencePipeline` | `Pipeline` / `Pipeline` | Resilion splits non-generic (exception-only) and typed (result-based) pipelines into distinct types instead of one generic-only pipeline. | +| `ResiliencePipelineBuilder` / `ResiliencePipelineBuilder` | `PipelineBuilder` / `PipelineBuilder` | Same split as above. Both inherit shared behavior from `PipelineBuilderBase`. | +| `new ResiliencePipelineBuilder().Add...().Build()` | `Pipeline.Create(b => b.Add...)` | Static factory instead of `new` + fluent `Build()`. | +| `RetryStrategyOptions` (Delay + BackoffType + UseJitter + DelayGenerator) | `RetryStrategyOptions` (`Delay = RetryDelay.___`) | `RetryDelay` is a discriminated union (`Constant`/`Linear`/`Exponential`/`Custom`) — mutually exclusive by construction, no silent overrides between properties. | +| `CircuitBreakerStrategyOptions.FailureRatio` | `CircuitBreakerStrategyOptions.FailureRatioThreshold` | Same concept, renamed for clarity. | +| `Func` callbacks (`OnRetry`, `OnOpened`, ...) | `ResilienceEventHandler` | Accepts a plain sync `Action` *or* an async `Func` via implicit conversion — no forced `ValueTask` wrapping for the common synchronous case (logging, counters). | +| `FallbackAction` (always a delegate) | `FallbackAction` | Implicit conversion from a constant value, a sync factory, *or* an async factory — pick whichever fits, the compiler resolves it. | +| `ResiliencePipelineRegistry` | `ResiliencePipelineRegistry` (same name) | Resilion additionally exposes `IPipelineProvider` — a read-only view for consumers that only retrieve pipelines, never register them. | +| `AddResilienceEnricher()` / `Microsoft.Extensions.Http.Resilience` | *(not yet available)* | See "Not yet available" below. | +| Sync via `.Execute(...)` (sync-over-async under the hood for some strategies) | `Execute(...)` (true sync — `Thread.Sleep`/`WaitHandle`, no `Task` wrapping) | See `docs/architecture.md`'s "Sync vs async" section. | + +## Before/after: the five most common patterns + +### 1. Basic retry with exponential backoff + +```csharp +// Polly +var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new Polly.Retry.RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = TimeSpan.FromSeconds(1), + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + }) + .Build(); +``` + +```csharp +// Resilion +var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions +{ + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromSeconds(1)), + UseJitter = true, +})); +``` + +### 2. Retry + circuit breaker composite + +```csharp +// Polly +var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new Polly.Retry.RetryStrategyOptions { MaxRetryAttempts = 3 }) + .AddCircuitBreaker(new Polly.CircuitBreaker.CircuitBreakerStrategyOptions + { + FailureRatio = 0.5, + MinimumThroughput = 10, + }) + .Build(); +``` + +```csharp +// Resilion — same ordering matters here too: Retry outside CircuitBreaker, +// so the breaker only sees the final outcome of each retried call, not every attempt. +var pipeline = Pipeline.Create(b => b + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 }) + .AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 10, + })); +``` + +### 3. Timeout (total + per-attempt) + +```csharp +// Polly +var pipeline = new ResiliencePipelineBuilder() + .AddTimeout(TimeSpan.FromSeconds(30)) + .AddRetry(new Polly.Retry.RetryStrategyOptions { MaxRetryAttempts = 3 }) + .AddTimeout(TimeSpan.FromSeconds(5)) + .Build(); +``` + +```csharp +// Resilion — identical shape +var pipeline = Pipeline.Create(b => b + .AddTimeout(TimeSpan.FromSeconds(30)) // Total timeout across all retries + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 }) + .AddTimeout(TimeSpan.FromSeconds(5))); // Per-attempt timeout +``` + +### 4. DI registration and injection + +```csharp +// Polly +services.AddResiliencePipeline("http-api", builder => builder + .AddRetry(new Polly.Retry.RetryStrategyOptions { MaxRetryAttempts = 3 })); + +var pipeline = serviceProvider.GetRequiredService>() + .GetPipeline("http-api"); +``` + +```csharp +// Resilion +services.AddResiliencePipeline("http-api", b => b + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 })); + +var pipeline = serviceProvider.GetRequiredService>() + .GetPipeline("http-api"); +``` + +### 5. Typed pipeline with result-based fallback + +```csharp +// Polly +var pipeline = new ResiliencePipelineBuilder() + .AddFallback(new Polly.Fallback.FallbackStrategyOptions + { + FallbackAction = _ => Outcome.FromResultAsValueTask(cachedResponse), + }) + .Build(); +``` + +```csharp +// Resilion — FallbackAction accepts a constant, a sync factory, or an async factory directly, +// via implicit conversion, instead of one delegate shape for every case. +var pipeline = Pipeline.Create(b => b.AddFallback( + new FallbackStrategyOptions + { + FallbackAction = cachedResponse, // implicit conversion from a constant value + })); +``` + +## Key differences to internalize + +- **`RetryDelay` discriminated union vs. Polly's `BackoffType` enum + separate properties.** In Polly, `Delay`, `BackoffType`, `UseJitter`, and `DelayGenerator` are independent properties that can be set inconsistently (e.g. `DelayGenerator` set alongside `BackoffType`, with unclear precedence). In Resilion, `RetryDelay.Constant/Linear/Exponential/Custom` are mutually exclusive by construction — there's exactly one way to configure the delay strategy. +- **Callbacks accept sync `Action` directly.** `ResilienceEventHandler`'s implicit conversions mean a synchronous callback (the 90% case) doesn't pay for `ValueTask` wrapping. Async callbacks still work when genuinely needed. +- **`FallbackAction` implicit conversion** — a constant, a sync factory (`Func, T>`), or an async factory (`Func, ValueTask>`) all convert implicitly. Pick whichever shape your fallback logic naturally has. +- **True sync execution, not sync-over-async.** Resilion's `Execute()` path uses real synchronous primitives (`Thread.Sleep`, `WaitHandle`) rather than blocking on an async path. This matters for ASP.NET Framework, WinForms/WPF, and any code that can't safely use `.GetAwaiter().GetResult()`. +- **`Pipeline.Create()` static factory** vs. `new ResiliencePipelineBuilder().Build()` — a small ergonomic difference, but it's the idiomatic entry point throughout Resilion's docs and samples. + +## Not yet available + +Resilion is younger than Polly and doesn't yet have feature parity in every area. If you depend +on one of these today, Polly remains the better fit until Resilion catches up: + +| Feature | Status | +|---------|--------| +| `IHttpClientFactory` integration (`AddStandardResilienceHandler()`) | Planned — `Resilion.Http`, highest priority post-v1.0 | +| Chaos engineering (Simmy equivalent) | Planned — `Resilion.Chaos` | +| Dynamic reload via `IOptionsMonitor` | Planned | +| Dedicated testing package (`Resilion.Testing`) | Planned | +| `PredicateBuilder` fluent API | Planned, lower priority | +| `IConfiguration` binding for strategy options | Planned, lower priority | +| Telemetry enrichment (`MeteringEnricher`, raw listeners) | Planned, lower priority | + +See [future-plans.md](future-plans.md) for the full roadmap and [comparison-with-polly.md](comparison-with-polly.md) for an honest side-by-side on where each library is stronger today. diff --git a/docs/pipelines.md b/docs/pipelines.md index 4afeccf..6daa28b 100644 --- a/docs/pipelines.md +++ b/docs/pipelines.md @@ -50,6 +50,28 @@ b.AddRetry(...); b.AddTimeout(TimeSpan.FromSeconds(10)); ``` +### Ordering validation + +`Build()` checks the strategy order for two dangerous misorderings — CircuitBreaker outside Retry (retries would bypass the breaker) and Fallback not outermost (it wouldn't catch failures from strategies placed outside it) — and **throws `InvalidOperationException` by default**: + +```csharp +// Throws: CircuitBreaker must be inside (after) Retry. +Pipeline.Create(b => b.AddCircuitBreaker().AddRetry()); +``` + +Set `ThrowOnOrderingErrors = false` to only warn instead (via `OnValidationWarning`, or `Debug.WriteLine` if that's not set): + +```csharp +Pipeline.Create(b => +{ + b.ThrowOnOrderingErrors = false; + b.OnValidationWarning = warning => logger.LogWarning(warning); + b.AddCircuitBreaker().AddRetry(); +}); +``` + +Situational issues — Hedging + Retry together, 3+ Timeouts, Retry outermost with no Timeout — are always advisory warnings, never thrown, regardless of `ThrowOnOrderingErrors`. Set `SuppressOrderingWarnings = true` to skip ordering validation entirely (both errors and warnings). + ## Pipeline composition Combine pre-built pipelines with `AddPipeline`: diff --git a/docs/retry.md b/docs/retry.md index 9ac5277..1da05d6 100644 --- a/docs/retry.md +++ b/docs/retry.md @@ -27,6 +27,7 @@ var result = await pipeline.ExecuteAsync(async ct => | `MaxRetryAttempts` | `int` | 3 | Number of retries (not counting initial call). 0 = no retries. | | `Delay` | `RetryDelay` | Exponential(1s, max 30s) | Delay strategy between retries | | `UseJitter` | `bool` | `true` | Apply decorrelated jitter to delays | +| `MaxDelay` | `TimeSpan?` | null | Global safety cap applied after `Delay` computes its value — useful with `RetryDelay.Custom` | | `ShouldHandle` | `Func` | All except `OperationCanceledException` | Which exceptions trigger retry | | `OnRetry` | `ResilienceEventHandler` | null | Callback before each retry wait | diff --git a/docs/telemetry.md b/docs/telemetry.md index d90decb..36703ff 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -15,6 +15,59 @@ All metrics are on the `"Resilion"` meter. | `resilion.hedging.attempts` | Counter | Hedged attempt launches | | `resilion.rate_limiter.rejections` | Counter | Rate limit rejection | +### Metric tags + +All counters are tagged with context about the operation: + +| Tag | Example | Notes | +|-----|---------|-------| +| `pipeline.name` | `"http-api"` | Name passed to `AddResiliencePipeline()` or pipeline name in registry | +| `operation.key` | `"GET /users"` | Custom operation key from `ResilienceContext` | +| `strategy` | `"retry"` | Strategy type (implicit in metric name) | + +Example: A `resilion.retry.attempts` counter for a named pipeline emits as: +``` +resilion.retry.attempts{pipeline.name="http-api", operation.key="GET /users"} = 3 +``` + +This allows dashboards and alerting rules to group by pipeline and operation. + +## ActivitySource spans + +Resilion also emits OpenTelemetry `Activity` spans for distributed tracing. Each strategy execution creates a span named after the strategy. + +### Subscribing to ActivitySource + +```csharp +builder.Services.AddOpenTelemetry() + .WithTracing(t => t.AddSource("Resilion")); +``` + +Or configure a global listener: + +```csharp +var listener = new ActivityListener +{ + ShouldListenTo = source => source.Name == "Resilion", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, +}; +ActivitySource.AddActivityListener(listener); +``` + +### Span names and tags + +Each strategy creates a span with contextual information: + +``` +Activity.OperationName = "Retry" // or Timeout, CircuitBreaker, etc. +Activity.Tags: + - "strategy" → "retry" + - "pipeline.name" → "http-api" + - "operation.key" → "GET /users" + - "attempt" → "1" (for retryable strategies) + - "duration_ms" → "150" (execution time) +``` + ## Subscribing ### dotnet-counters (CLI) diff --git a/docs/tradeoffs.md b/docs/tradeoffs.md index a91d6ba..2d6b698 100644 --- a/docs/tradeoffs.md +++ b/docs/tradeoffs.md @@ -32,25 +32,21 @@ When pipelines are composed via `AddPipeline()`, the `DelegatingComponent` does ## ResilienceContextPool cap is approximate -The pool size check (`_pool.Count < MaxPoolSize`) followed by `_pool.Add(context)` is a TOCTOU race — concurrent `Return()` calls can overshoot `MaxPoolSize` unboundedly. The excess contexts are held in the `ConcurrentBag` and are never collected — `Rent` via `_pool.TryTake` is the only removal path. +The cap check (`Interlocked.Increment(ref _count) <= _maxPoolSize`) followed by `_pool.Add(context)` is a TOCTOU race — concurrent `Return()` calls can overshoot `_maxPoolSize` unboundedly. The excess contexts are held in the `ConcurrentBag` and are never collected — `Rent` via `_pool.TryTake` is the only removal path. -**Why it's acceptable:** The cap is a heuristic, not a hard limit. Enforcing exactly 256 would require a lock on every return for zero practical benefit. Under burst traffic the pool might grow beyond the target, but this is transient and trades memory for lock-free concurrent access. The contexts are small (~200 bytes) and in steady state the pool converges to size. The alternative — `ConcurrentBag.Count` with exact enforcement — is worse because `Count` itself is expensive (see future-plans #25). +`_maxPoolSize` defaults to 256 but is configurable via `new ResilienceContextPool(maxPoolSize)` — the shared `ResilienceContextPool.Shared` instance always uses the default. -**Watch for:** Don't rely on the pool being exactly 256 items or fewer. It's a soft cap. Under sustained traffic, the pool size may exceed the target. +**Why it's acceptable:** The cap is a heuristic, not a hard limit. Enforcing it exactly would require a lock on every return for zero practical benefit. Under burst traffic the pool might grow beyond the target, but this is transient and trades memory for lock-free concurrent access. The contexts are small (~200 bytes) and in steady state the pool converges to size. The alternative — `ConcurrentBag.Count` with exact enforcement — is worse because `Count` itself is expensive (see future-plans #25). + +**Watch for:** Don't rely on the pool staying at or under its configured cap. It's a soft cap. Under sustained traffic, the pool size may exceed the target. **Location:** [ResilienceContextPool.cs](../src/Resilion/ResilienceContextPool.cs) — `Return()` --- -## Timeout cancellation classification has a narrow race window - -`WasCancelledByTimeout` checks `linkedCts.IsCancellationRequested && !userToken.IsCancellationRequested`. Between the two checks, the user token could become cancelled, causing user cancellation to be misclassified as a timeout. - -**Why it's acceptable:** The window is nanoseconds wide and requires the user token to cancel at the exact instant between two boolean reads. In practice this is not observable. The consequence (a `TimeoutRejectedException` instead of `OperationCanceledException`) is also recoverable — the caller catches `ResilionException` or the base `Exception` either way. - -**Watch for:** If your code strictly distinguishes timeout from cancellation and you're seeing rare misclassification under extreme concurrent cancellation, this is the cause. +## ~~Timeout cancellation classification has a narrow race window~~ — MOVED -**Location:** [TimeoutStrategy.cs](../src/Resilion/Timeout/TimeoutStrategy.cs) — `WasCancelledByTimeout()` +This was a solvable issue, not a true tradeoff. Moved to [future-plans.md](future-plans.md) as **#46** (P2 fix). --- diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index eb237de..d7664f4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -127,10 +127,10 @@ Action onRetry = e => logger.Log(e.AttemptNumber); --- -## Hedging sync path runs sequentially +## Hedging sync path throws for parallel/latency mode -**Problem**: Hedging with `HedgingDelay = TimeSpan.Zero` (parallel mode) doesn't run attempts in parallel when using `Execute` (sync). +**Problem**: Calling `Execute` (sync) on a pipeline with `HedgingDelay = TimeSpan.Zero` (parallel mode) or any positive delay (latency mode) throws `InvalidOperationException`. -**Cause**: Parallel execution requires async. The sync path always runs sequentially regardless of `HedgingDelay`. +**Cause**: Parallel/latency hedging requires concurrent execution, which the sync path can't provide. Rather than silently running the attempts sequentially — defeating the point of hedging with no indication anything was wrong — `Execute()` throws. -**Fix**: Use `ExecuteAsync` for parallel hedging. +**Fix**: Use `ExecuteAsync` for parallel or latency hedging, or set `HedgingDelay = Timeout.InfiniteTimeSpan` (sequential mode) if you specifically need the sync path. diff --git a/global.json b/global.json index d07970a..b829814 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.100", + "version": "8.0.404", "rollForward": "latestMajor" } } diff --git a/samples/Resilion.Samples/Program.cs b/samples/Resilion.Samples/Program.cs index 0c84479..2a54bba 100644 --- a/samples/Resilion.Samples/Program.cs +++ b/samples/Resilion.Samples/Program.cs @@ -1,4 +1,5 @@ using Resilion; +using Resilion.Samples; Console.WriteLine("Resilion Samples"); Console.WriteLine("================"); @@ -194,4 +195,58 @@ await cbPipeline.ExecuteAsync(ct => Console.WriteLine($" Result: {syncResult} (after {syncAttempts} attempts)"); Console.WriteLine(); +// ────────────────────────────────────────────────────────────────── +// 7. Dependency injection (AddResilienceServices + IPipelineProvider) +// ────────────────────────────────────────────────────────────────── + +Console.WriteLine("7. Dependency Injection"); +Console.WriteLine("────────────────────────"); +await DependencyInjectionSample.RunAsync(); +Console.WriteLine(); + +// ────────────────────────────────────────────────────────────────── +// 8. Typed pipeline with result-based retry (HTTP status codes) +// ────────────────────────────────────────────────────────────────── + +Console.WriteLine("8. Typed Retry on HTTP Status Codes"); +Console.WriteLine("────────────────────────────────────"); +await TypedRetrySample.RunAsync(); +Console.WriteLine(); + +// ────────────────────────────────────────────────────────────────── +// 9. Rate limiter +// ────────────────────────────────────────────────────────────────── + +Console.WriteLine("9. Rate Limiter"); +Console.WriteLine("───────────────"); +await RateLimiterSample.RunAsync(); +Console.WriteLine(); + +// ────────────────────────────────────────────────────────────────── +// 10. Hedging with ActionGenerator (primary + failover endpoint) +// ────────────────────────────────────────────────────────────────── + +Console.WriteLine("10. Hedging with ActionGenerator"); +Console.WriteLine("─────────────────────────────────"); +await HedgingActionGeneratorSample.RunAsync(); +Console.WriteLine(); + +// ────────────────────────────────────────────────────────────────── +// 11. BreakDurationGenerator (exponential break backoff) +// ────────────────────────────────────────────────────────────────── + +Console.WriteLine("11. BreakDurationGenerator"); +Console.WriteLine("───────────────────────────"); +await BreakDurationGeneratorSample.RunAsync(); +Console.WriteLine(); + +// ────────────────────────────────────────────────────────────────── +// 12. State parameter (avoiding closure allocations) +// ────────────────────────────────────────────────────────────────── + +Console.WriteLine("12. State Parameter"); +Console.WriteLine("────────────────────"); +await StateParameterSample.RunAsync(); +Console.WriteLine(); + Console.WriteLine("All samples completed."); diff --git a/samples/Resilion.Samples/Resilion.Samples.csproj b/samples/Resilion.Samples/Resilion.Samples.csproj index 4d72357..01d8e13 100644 --- a/samples/Resilion.Samples/Resilion.Samples.csproj +++ b/samples/Resilion.Samples/Resilion.Samples.csproj @@ -16,4 +16,8 @@ + + + + diff --git a/samples/Resilion.Samples/Samples/BreakDurationGeneratorSample.cs b/samples/Resilion.Samples/Samples/BreakDurationGeneratorSample.cs new file mode 100644 index 0000000..3fc4ece --- /dev/null +++ b/samples/Resilion.Samples/Samples/BreakDurationGeneratorSample.cs @@ -0,0 +1,50 @@ +namespace Resilion.Samples; + +/// +/// Exponential break-duration backoff: each time the circuit trips again, it stays open longer +/// than the last time — via BreakDurationGenerator, new in this version. +/// +public static class BreakDurationGeneratorSample +{ + public static async Task RunAsync() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 1, + BreakDuration = TimeSpan.FromMilliseconds(100), // Base duration, passed to the generator. + BreakDurationGenerator = args => + { + // Doubles each trip: 100ms, 200ms, 400ms, ... capped at 1s. + var backoff = args.CurrentBreakDuration * Math.Pow(2, args.FailureCount - 1); + var capped = backoff > TimeSpan.FromSeconds(1) ? TimeSpan.FromSeconds(1) : backoff; + Console.WriteLine($" Trip #{args.FailureCount}: breaking for {capped.TotalMilliseconds:F0}ms"); + return capped; + }, + })); + + for (var trip = 1; trip <= 3; trip++) + { + // Trip the circuit. + try + { + await pipeline.ExecuteAsync(ct => throw new InvalidOperationException("down")); + } + catch (InvalidOperationException) + { + // Expected — this is what trips the circuit. + } + + // Confirm it's open, then wait long enough for the generator's growing duration. + try + { + await pipeline.ExecuteAsync(ct => new ValueTask("ok")); + } + catch (CircuitBrokenException ex) + { + Console.WriteLine($" Rejected, retry after {ex.RetryAfter.TotalMilliseconds:F0}ms"); + await Task.Delay(ex.RetryAfter + TimeSpan.FromMilliseconds(20)); + } + } + } +} diff --git a/samples/Resilion.Samples/Samples/DependencyInjectionSample.cs b/samples/Resilion.Samples/Samples/DependencyInjectionSample.cs new file mode 100644 index 0000000..76b4e93 --- /dev/null +++ b/samples/Resilion.Samples/Samples/DependencyInjectionSample.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.DependencyInjection; +using Resilion.Extensions; + +namespace Resilion.Samples; + +/// +/// Shows the real-world DI registration pattern: AddResilienceServices() + +/// AddResiliencePipeline(), then resolving pipelines via +/// — the read-only interface consumers should depend on instead of the full registry. +/// +public static class DependencyInjectionSample +{ + public static async Task RunAsync() + { + var services = new ServiceCollection(); + + services.AddResiliencePipeline("http-api", b => b + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = RetryDelay.Exponential(TimeSpan.FromMilliseconds(100)), + }) + .AddTimeout(TimeSpan.FromSeconds(10))); + + await using var provider = services.BuildServiceProvider(); + + // Consumers only need the read-only provider interface, not the full registry + // (which also exposes registration) — see IPipelineProvider. + var pipelineProvider = provider.GetRequiredService>(); + var pipeline = pipelineProvider.GetPipeline("http-api"); + + var result = await pipeline.ExecuteAsync( + static (state, ct) => new ValueTask($"Response from {state}"), + "http-api"); + + Console.WriteLine($" Result: {result}"); + } +} diff --git a/samples/Resilion.Samples/Samples/HedgingActionGeneratorSample.cs b/samples/Resilion.Samples/Samples/HedgingActionGeneratorSample.cs new file mode 100644 index 0000000..3893dd8 --- /dev/null +++ b/samples/Resilion.Samples/Samples/HedgingActionGeneratorSample.cs @@ -0,0 +1,47 @@ +namespace Resilion.Samples; + +/// +/// Hedging with a per-attempt ActionGenerator — the primary attempt calls one endpoint, +/// the hedged attempt calls a different one (a common pattern: primary region + failover region). +/// +public static class HedgingActionGeneratorSample +{ + private static ValueTask CallPrimaryEndpoint(CancellationToken ct) + // Simulates a slow primary endpoint. + => SlowCallAsync("primary-region", TimeSpan.FromMilliseconds(400), ct); + + private static ValueTask CallFailoverEndpoint(CancellationToken ct) + // Simulates a fast failover endpoint. + => SlowCallAsync("failover-region", TimeSpan.FromMilliseconds(50), ct); + + private static async ValueTask SlowCallAsync(string endpoint, TimeSpan delay, CancellationToken ct) + { + await Task.Delay(delay, ct); + return $"response from {endpoint}"; + } + + public static async Task RunAsync() + { + Action> onHedging = e => + Console.WriteLine($" Launching hedged attempt #{e.AttemptNumber} (failover)"); + + var pipeline = Pipeline.Create(b => b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelay = TimeSpan.FromMilliseconds(100), // Latency mode: give primary a head start. + OnHedging = onHedging, + ActionGenerator = ctx => ctx.AttemptNumber switch + { + 0 => CallPrimaryEndpoint, + 1 => CallFailoverEndpoint, + _ => null, + }, + })); + + // The delegate passed here is never called — ActionGenerator supplies every attempt. + var result = await pipeline.ExecuteAsync(ct => + throw new InvalidOperationException("unreachable — ActionGenerator handles all attempts")); + + Console.WriteLine($" Result: {result}"); + } +} diff --git a/samples/Resilion.Samples/Samples/RateLimiterSample.cs b/samples/Resilion.Samples/Samples/RateLimiterSample.cs new file mode 100644 index 0000000..77a06c4 --- /dev/null +++ b/samples/Resilion.Samples/Samples/RateLimiterSample.cs @@ -0,0 +1,51 @@ +using System.Threading.RateLimiting; +using Resilion.RateLimiting; + +namespace Resilion.Samples; + +/// +/// A wrapped in the canonical pipeline position (outermost — +/// shed load before spending any other resilience work on a call that won't be admitted). +/// +public static class RateLimiterSample +{ + public static async Task RunAsync() + { + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 2, + QueueLimit = 0, + }); + + Action onRejected = e => + Console.WriteLine(" Rejected: rate limit exceeded"); + + var pipeline = Pipeline.Create(b => b + .AddRateLimiter(new RateLimiterStrategyOptions + { + RateLimiter = limiter, + OnRejected = onRejected, + }) + .AddTimeout(TimeSpan.FromSeconds(10))); + + // Launch 4 concurrent calls against a limiter that only permits 2 at a time. + var tasks = Enumerable.Range(1, 4).Select(async i => + { + try + { + var result = await pipeline.ExecuteAsync(async ct => + { + await Task.Delay(200, ct); + return $"call-{i}-ok"; + }); + Console.WriteLine($" {result}"); + } + catch (RateLimitRejectedException) + { + Console.WriteLine($" call-{i} rejected"); + } + }); + + await Task.WhenAll(tasks); + } +} diff --git a/samples/Resilion.Samples/Samples/StateParameterSample.cs b/samples/Resilion.Samples/Samples/StateParameterSample.cs new file mode 100644 index 0000000..3d8c666 --- /dev/null +++ b/samples/Resilion.Samples/Samples/StateParameterSample.cs @@ -0,0 +1,36 @@ +namespace Resilion.Samples; + +/// +/// The Execute<TResult, TState> / ExecuteAsync<TResult, TState> overloads +/// pass state explicitly instead of capturing it in a closure. Combined with a static +/// lambda, this avoids allocating a display-class object per call — worth doing on a hot path +/// where the other overloads' closure allocation (small, but non-zero) actually shows up. +/// +public static class StateParameterSample +{ + private readonly record struct RequestState(string Url, int TimeoutSeconds); + + public static async Task RunAsync() + { + var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.None, + })); + + // Without TState: the lambda captures `url` and `timeoutSeconds` from the enclosing + // scope, which the compiler implements as a heap-allocated closure object. + var url = "https://api.example.com"; + var timeoutSeconds = 10; + var withClosure = await pipeline.ExecuteAsync(ct => + new ValueTask($"GET {url} (timeout {timeoutSeconds}s)")); + Console.WriteLine($" {withClosure}"); + + // With TState: a `static` lambda can't capture anything, so the compiler can't allocate + // a closure — `state` is passed as an ordinary parameter instead. + var withState = await pipeline.ExecuteAsync( + static (state, ct) => new ValueTask($"GET {state.Url} (timeout {state.TimeoutSeconds}s)"), + new RequestState("https://api.example.com", 10)); + Console.WriteLine($" {withState}"); + } +} diff --git a/samples/Resilion.Samples/Samples/TypedRetrySample.cs b/samples/Resilion.Samples/Samples/TypedRetrySample.cs new file mode 100644 index 0000000..8252f1f --- /dev/null +++ b/samples/Resilion.Samples/Samples/TypedRetrySample.cs @@ -0,0 +1,39 @@ +using System.Net; + +namespace Resilion.Samples; + +/// +/// The #1 thing people search for: retrying on specific HTTP status codes using a typed +/// pipeline. Pipeline.Create<HttpResponseMessage> lets ShouldHandle inspect +/// the response itself, not just exceptions. +/// +public static class TypedRetrySample +{ + public static async Task RunAsync() + { + var attempt = 0; + + var pipeline = Pipeline.Create(b => b.AddRetry( + new RetryStrategyOptions + { + MaxRetryAttempts = 3, + Delay = RetryDelay.Exponential(TimeSpan.FromMilliseconds(100)), + // Retry on 5xx and 429 (Too Many Requests) — the classic transient HTTP set. + ShouldHandle = outcome => + outcome.Exception is not null and not OperationCanceledException + || (outcome.TryGetResult(out var response) + && ((int)response.StatusCode >= 500 || response.StatusCode == HttpStatusCode.TooManyRequests)), + })); + + var result = await pipeline.ExecuteAsync(ct => + { + attempt++; + // Simulates a flaky endpoint: 503 twice, then 200. + var statusCode = attempt < 3 ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK; + return new ValueTask(new HttpResponseMessage(statusCode)); + }); + + Console.WriteLine($" Result: {result.StatusCode} (after {attempt} attempts)"); + result.Dispose(); + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index fcd822c..415cb94 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -29,4 +29,12 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/src/Resilion.Extensions/IPipelineProvider.cs b/src/Resilion.Extensions/IPipelineProvider.cs new file mode 100644 index 0000000..40212b2 --- /dev/null +++ b/src/Resilion.Extensions/IPipelineProvider.cs @@ -0,0 +1,36 @@ +namespace Resilion.Extensions; + +/// +/// A read-only view over a set of named resilience pipelines. Consumers that only need to +/// retrieve pipelines should depend on this interface rather than +/// , which also exposes registration. +/// +/// The key type for pipeline lookup. Typically . +public interface IPipelineProvider + where TKey : notnull +{ + /// + /// Gets or creates the pipeline registered under the specified key. + /// + /// The pipeline name. + /// The cached pipeline instance. + /// No pipeline is registered with the specified key. + Pipeline GetPipeline(TKey key); + + /// + /// Gets or creates the typed pipeline registered under the specified key. + /// + /// The result type for the pipeline. + /// The pipeline name. + /// The cached typed pipeline instance. + /// No pipeline is registered with the specified key and result type. + Pipeline GetPipeline(TKey key); + + /// + /// Attempts to get a pipeline registered under the specified key. + /// + /// The pipeline name. + /// The pipeline, if found. + /// true if the pipeline exists; false otherwise. + bool TryGetPipeline(TKey key, out Pipeline? pipeline); +} diff --git a/src/Resilion.Extensions/PublicAPI.Shipped.txt b/src/Resilion.Extensions/PublicAPI.Shipped.txt new file mode 100644 index 0000000..528fbaa --- /dev/null +++ b/src/Resilion.Extensions/PublicAPI.Shipped.txt @@ -0,0 +1,4 @@ +# This file tracks the public API surface of Resilion.Extensions v1.0. +# It will be generated after v1.0 is shipped using PublicApiAnalyzers. +# Until then, it serves as a placeholder to enable the analyzer. +# See: https://github.com/dotnet/roslyn-analyzers/blob/main/src/PublicApiAnalyzers/PublicApiAnalyzers.Help.md diff --git a/src/Resilion.Extensions/PublicAPI.Unshipped.txt b/src/Resilion.Extensions/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..8127168 --- /dev/null +++ b/src/Resilion.Extensions/PublicAPI.Unshipped.txt @@ -0,0 +1,25 @@ +#nullable enable + +# New public APIs for Resilion.Extensions (yet to ship). +# Entries here should be moved to PublicAPI.Shipped.txt upon release. + +namespace Resilion.Extensions; + +IPipelineProvider +IPipelineProvider.GetPipeline(TKey) -> Resilion.Pipeline! +IPipelineProvider.GetPipeline(TKey) -> Resilion.Pipeline! +IPipelineProvider.TryGetPipeline(TKey, out Resilion.Pipeline?) -> bool + +ResilionServiceCollectionExtensions +ResilionServiceCollectionExtensions.AddResilienceServices(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +ResilionServiceCollectionExtensions.AddResilion(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +ResilionServiceCollectionExtensions.AddResiliencePipeline(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! name, System.Action>! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! + +ResiliencePipelineRegistry +ResiliencePipelineRegistry.RegisterPipeline(TKey!, System.Action!) -> void +ResiliencePipelineRegistry.RegisterPipeline(TKey!, System.Action>!) -> void +ResiliencePipelineRegistry.GetPipeline(TKey!) -> Resilion.Pipeline! +ResiliencePipelineRegistry.GetPipeline(TKey!) -> Resilion.Pipeline! +ResiliencePipelineRegistry.TryGetPipeline(TKey!, out Resilion.Pipeline?) -> bool +ResiliencePipelineRegistry.Dispose() -> void diff --git a/src/Resilion.Extensions/ResiliencePipelineRegistry.cs b/src/Resilion.Extensions/ResiliencePipelineRegistry.cs index f6bc888..3b367e9 100644 --- a/src/Resilion.Extensions/ResiliencePipelineRegistry.cs +++ b/src/Resilion.Extensions/ResiliencePipelineRegistry.cs @@ -7,7 +7,7 @@ namespace Resilion.Extensions; /// on first access and cached for the lifetime of the registry. /// /// The key type for pipeline lookup. Typically . -public sealed class ResiliencePipelineRegistry : IDisposable +public sealed class ResiliencePipelineRegistry : IPipelineProvider, IDisposable where TKey : notnull { private readonly ConcurrentDictionary> _pipelines = new(); @@ -56,14 +56,15 @@ public void RegisterPipeline(TKey key, Action> /// No pipeline is registered with the specified key. public Pipeline GetPipeline(TKey key) { - var lazy = _pipelines.GetOrAdd(key, _ => new Lazy(() => + // Validate factory exists before creating Lazy to prevent caching faulted entries + if (!_factories.TryGetValue(key, out var factory)) { - if (!_factories.TryGetValue(key, out var factory)) - { - throw new KeyNotFoundException($"No pipeline registered with key '{key}'."); - } + throw new KeyNotFoundException($"No pipeline registered with key '{key}'."); + } - var builder = new PipelineBuilder(); + var lazy = _pipelines.GetOrAdd(key, _ => new Lazy(() => + { + var builder = new PipelineBuilder { Name = key?.ToString() }; factory(builder); return builder.Build(); })); @@ -80,16 +81,18 @@ public Pipeline GetPipeline(TKey key) public Pipeline GetPipeline(TKey key) { var compositeKey = (key, typeof(TResult)); - var lazy = _typedPipelines.GetOrAdd(compositeKey, _ => new Lazy(() => + + // Validate factory exists before creating Lazy to prevent caching faulted entries + if (!_typedFactories.TryGetValue(compositeKey, out var factory)) { - if (!_typedFactories.TryGetValue(compositeKey, out var factory)) - { - throw new KeyNotFoundException( - $"No pipeline registered with key '{key}' and result type '{typeof(TResult).Name}'."); - } + throw new KeyNotFoundException( + $"No pipeline registered with key '{key}' and result type '{typeof(TResult).Name}'."); + } + var lazy = _typedPipelines.GetOrAdd(compositeKey, _ => new Lazy(() => + { var configure = (Action>)factory; - var builder = new PipelineBuilder(); + var builder = new PipelineBuilder { Name = key?.ToString() }; configure(builder); return (object)builder.Build(); })); diff --git a/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs b/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs index 10cad18..e9eb568 100644 --- a/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs +++ b/src/Resilion.Extensions/ResilionServiceCollectionExtensions.cs @@ -10,18 +10,31 @@ public static class ResilionServiceCollectionExtensions { /// /// Adds Resilion core services to the service collection, including a shared - /// with string keys. + /// with string keys and its read-only + /// view. /// /// The service collection. /// The service collection for chaining. - public static IServiceCollection AddResilion(this IServiceCollection services) + public static IServiceCollection AddResilienceServices(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); services.TryAddSingleton>(sp => BuildRegistry(sp)); + services.TryAddSingleton>(sp => + sp.GetRequiredService>()); services.TryAddSingleton(ResilienceContextPool.Shared); return services; } + /// + /// Adds Resilion core services to the service collection. Obsolete alias for + /// — kept so existing code continues to compile. + /// + /// The service collection. + /// The service collection for chaining. + [Obsolete("Use AddResilienceServices() instead. This alias will be removed in a future major version.")] + public static IServiceCollection AddResilion(this IServiceCollection services) + => services.AddResilienceServices(); + /// /// Registers a named resilience pipeline that is created lazily on first access /// and cached as a singleton. @@ -50,7 +63,7 @@ public static IServiceCollection AddResiliencePipeline( ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(configure); - services.AddResilion(); + services.AddResilienceServices(); // Register a post-configuration action that runs when the registry is first resolved. var capturedName = name; @@ -78,7 +91,7 @@ public static IServiceCollection AddResiliencePipeline( ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(configure); - services.AddResilion(); + services.AddResilienceServices(); services.AddSingleton( new TypedPipelineConfigurator(name, configure)); diff --git a/src/Resilion.RateLimiting/PublicAPI.Shipped.txt b/src/Resilion.RateLimiting/PublicAPI.Shipped.txt new file mode 100644 index 0000000..71c9683 --- /dev/null +++ b/src/Resilion.RateLimiting/PublicAPI.Shipped.txt @@ -0,0 +1,4 @@ +# This file tracks the public API surface of Resilion.RateLimiting v1.0. +# It will be generated after v1.0 is shipped using PublicApiAnalyzers. +# Until then, it serves as a placeholder to enable the analyzer. +# See: https://github.com/dotnet/roslyn-analyzers/blob/main/src/PublicApiAnalyzers/PublicApiAnalyzers.Help.md diff --git a/src/Resilion.RateLimiting/PublicAPI.Unshipped.txt b/src/Resilion.RateLimiting/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..6edd03d --- /dev/null +++ b/src/Resilion.RateLimiting/PublicAPI.Unshipped.txt @@ -0,0 +1,23 @@ +#nullable enable + +# New public APIs for Resilion.RateLimiting (yet to ship). +# Entries here should be moved to PublicAPI.Shipped.txt upon release. + +namespace Resilion.RateLimiting; + +OnRateLimitRejectedEvent +OnRateLimitRejectedEvent.RetryAfter.get -> System.TimeSpan? +OnRateLimitRejectedEvent.Context.get -> Resilion.ResilienceContext! + +RateLimitRejectedException +RateLimitRejectedException.RetryAfter.get -> System.TimeSpan? + +RateLimiterExtensions +RateLimiterExtensions.AddRateLimiter(this Resilion.PipelineBuilder! builder, RateLimiterStrategyOptions! options) -> Resilion.PipelineBuilder! +RateLimiterExtensions.AddRateLimiter(this Resilion.PipelineBuilder! builder, RateLimiterStrategyOptions! options) -> Resilion.PipelineBuilder! + +RateLimiterStrategyOptions +RateLimiterStrategyOptions.RateLimiter.get -> System.Threading.RateLimiting.RateLimiter? +RateLimiterStrategyOptions.RateLimiter.init -> void +RateLimiterStrategyOptions.OnRejected.get -> Resilion.ResilienceEventHandler? +RateLimiterStrategyOptions.OnRejected.init -> void diff --git a/src/Resilion.RateLimiting/RateLimiterStrategy.cs b/src/Resilion.RateLimiting/RateLimiterStrategy.cs index 39e98fa..e26648e 100644 --- a/src/Resilion.RateLimiting/RateLimiterStrategy.cs +++ b/src/Resilion.RateLimiting/RateLimiterStrategy.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Threading.RateLimiting; namespace Resilion.RateLimiting; @@ -18,6 +19,14 @@ protected internal override async ValueTask> ExecuteAsync>> callback, ResilienceContext context) { + using var activity = ResilionTelemetry.ActivitySource.StartActivity("RateLimiter"); + if (activity is not null) + { + activity.SetTag("strategy.name", "RateLimiter"); + activity.SetTag("pipeline.name", context.PipelineName); + activity.SetTag("operation.key", context.OperationKey); + } + var lease = await _options.RateLimiter!.AcquireAsync( permitCount: 1, cancellationToken: context.CancellationToken).ConfigureAwait(false); @@ -26,10 +35,20 @@ protected internal override async ValueTask> ExecuteAsync(lease, context).ConfigureAwait(false); + var result = await HandleRejection(lease, context).ConfigureAwait(false); + if (activity is not null) + { + activity.SetTag("outcome", "rejected"); + } + return result; } - return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + var outcome = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + if (activity is not null) + { + activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); + } + return outcome; } finally { @@ -41,15 +60,33 @@ protected internal override Outcome Execute( Func> callback, ResilienceContext context) { + using var activity = ResilionTelemetry.ActivitySource.StartActivity("RateLimiter"); + if (activity is not null) + { + activity.SetTag("strategy.name", "RateLimiter"); + activity.SetTag("pipeline.name", context.PipelineName); + activity.SetTag("operation.key", context.OperationKey); + } + // Sync acquire — AttemptAcquire does not wait in a queue. using var lease = _options.RateLimiter!.AttemptAcquire(permitCount: 1); if (!lease.IsAcquired) { - return HandleRejectionSync(lease, context); + var result = HandleRejectionSync(lease, context); + if (activity is not null) + { + activity.SetTag("outcome", "rejected"); + } + return result; } - return callback(context); + var outcome = callback(context); + if (activity is not null) + { + activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); + } + return outcome; } private async ValueTask> HandleRejection( @@ -58,7 +95,7 @@ private async ValueTask> HandleRejection( { var retryAfter = GetRetryAfter(lease); - Resilion.ResilionTelemetry.RateLimiterRejections.Add(1); + Resilion.ResilionTelemetry.RateLimiterRejections.Add(1, new(Resilion.ResilionTelemetry.PipelineNameTag, context.PipelineName), new(Resilion.ResilionTelemetry.OperationKeyTag, context.OperationKey)); if (_options.OnRejected is { } handler && handler.HasHandler) { @@ -75,7 +112,7 @@ private Outcome HandleRejectionSync( { var retryAfter = GetRetryAfter(lease); - Resilion.ResilionTelemetry.RateLimiterRejections.Add(1); + Resilion.ResilionTelemetry.RateLimiterRejections.Add(1, new(Resilion.ResilionTelemetry.PipelineNameTag, context.PipelineName), new(Resilion.ResilionTelemetry.OperationKeyTag, context.OperationKey)); if (_options.OnRejected is { } handler && handler.HasHandler) { diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerStateMachine.cs b/src/Resilion/CircuitBreaker/CircuitBreakerStateMachine.cs new file mode 100644 index 0000000..d9d84ab --- /dev/null +++ b/src/Resilion/CircuitBreaker/CircuitBreakerStateMachine.cs @@ -0,0 +1,334 @@ +using Resilion.Internal; + +namespace Resilion; + +/// +/// Owns the circuit breaker state machine — transitions, the sliding window, and event +/// dispatch — shared by both (exception-only) and +/// (result-based). Each strategy is +/// responsible only for evaluating its own failure predicate and reporting the resulting +/// bool to this machine; everything about *what a failure does* lives here exactly once. +/// +internal sealed class CircuitBreakerStateMachine +{ + private readonly double _failureRatioThreshold; + private readonly int _minimumThroughput; + private readonly TimeSpan _breakDuration; + private readonly Func? _breakDurationGenerator; + private readonly ResilienceEventHandler? _onOpened; + private readonly ResilienceEventHandler? _onClosed; + private readonly ResilienceEventHandler? _onHalfOpened; + private readonly TimeProvider _timeProvider; + private readonly SlidingWindow _window; + private readonly object _lock = new(); + + private volatile CircuitState _state = CircuitState.Closed; + private long _openedAtTimestamp; + private int _halfOpenAttempts; + private int _tripCount; + private TimeSpan _effectiveBreakDuration; + + internal CircuitBreakerStateMachine( + double failureRatioThreshold, + int minimumThroughput, + TimeSpan breakDuration, + Func? breakDurationGenerator, + TimeSpan samplingDuration, + TimeProvider timeProvider, + ResilienceEventHandler? onOpened, + ResilienceEventHandler? onClosed, + ResilienceEventHandler? onHalfOpened, + CircuitBreakerManualControl? manualControl) + { + _failureRatioThreshold = failureRatioThreshold; + _minimumThroughput = minimumThroughput; + _breakDuration = breakDuration; + _breakDurationGenerator = breakDurationGenerator; + _timeProvider = timeProvider; + _onOpened = onOpened; + _onClosed = onClosed; + _onHalfOpened = onHalfOpened; + _window = new SlidingWindow(samplingDuration, timeProvider); + _effectiveBreakDuration = breakDuration; + + manualControl?.Initialize( + onIsolate: () => { Isolate(); return Task.CompletedTask; }, + onReset: () => { Reset(); return Task.CompletedTask; }); + } + + internal CircuitState State => _state; + + /// + /// Checks whether the circuit currently rejects calls, advancing Open → HalfOpen when the + /// break duration has elapsed. + /// + internal CircuitBrokenException? TryReject(ResilienceContext context) + { + var state = _state; + + switch (state) + { + case CircuitState.Closed: + return null; + + case CircuitState.HalfOpen: + lock (_lock) + { + if (_state != CircuitState.HalfOpen) + { + return _state is CircuitState.Open or CircuitState.Isolated + ? CreateRejectException() + : null; + } + + if (_halfOpenAttempts > 0) + { + return CreateRejectException(); + } + + _halfOpenAttempts++; + return null; + } + + case CircuitState.Open: + var elapsed = _timeProvider.GetElapsedTime(_openedAtTimestamp); + if (elapsed >= _effectiveBreakDuration) + { + CircuitStateChangedEvent? pendingEvent = null; + lock (_lock) + { + if (_state == CircuitState.Open) + { + pendingEvent = TransitionTo(CircuitState.HalfOpen, context); + _halfOpenAttempts = 1; + } + } + + FireEvent(pendingEvent); + + return _state is CircuitState.Open or CircuitState.Isolated + ? CreateRejectException() + : null; + } + + return CreateRejectException(); + + case CircuitState.Isolated: + return new CircuitBrokenException(CircuitState.Isolated, TimeSpan.Zero); + + default: + return null; + } + } + + /// + /// Records the outcome of a call (already classified as failure or not by the caller's + /// predicate) and fires any resulting state-change event, synchronously. + /// + internal void RecordOutcome(bool isFailure, ResilienceContext context) + { + var pendingEvent = ComputeTransition(isFailure, context); + if (pendingEvent.HasValue) + { + FireEvent(pendingEvent.Value); + } + } + + /// + /// Records the outcome of a call and fires any resulting state-change event, asynchronously. + /// + internal async ValueTask RecordOutcomeAsync(bool isFailure, ResilienceContext context) + { + var pendingEvent = ComputeTransition(isFailure, context); + if (pendingEvent.HasValue) + { + await FireEventAsync(pendingEvent.Value).ConfigureAwait(false); + } + } + + private CircuitStateChangedEvent? ComputeTransition(bool isFailure, ResilienceContext context) + { + var currentState = _state; + + switch (currentState) + { + case CircuitState.Closed: + // Record outcome and get ratio under single lock for efficiency + var failureRatio = _window.RecordAndGetRatio(isFailure, out var totalCount); + if (failureRatio >= _failureRatioThreshold && totalCount >= _minimumThroughput) + { + lock (_lock) + { + if (_state == CircuitState.Closed) + { + return Trip(context); + } + } + } + + return null; + + case CircuitState.HalfOpen: + lock (_lock) + { + if (_state != CircuitState.HalfOpen) + { + return null; + } + + if (isFailure) + { + return Trip(context); + } + + _window.Reset(); + return TransitionTo(CircuitState.Closed, context); + } + + default: + return null; + } + } + + private async ValueTask FireEventAsync(CircuitStateChangedEvent? evt) + { + if (evt is null) + { + return; + } + + var e = evt.Value; + ResilionTelemetry.CircuitBreakerStateChanges.Add(1, new(ResilionTelemetry.PipelineNameTag, e.Context.PipelineName), new(ResilionTelemetry.OperationKeyTag, e.Context.OperationKey)); + + switch (e.CurrentState) + { + case CircuitState.Open: + if (_onOpened is { } onOpened && onOpened.HasHandler) + { + await onOpened.InvokeAsync(e).ConfigureAwait(false); + } + + break; + + case CircuitState.Closed: + if (_onClosed is { } onClosed && onClosed.HasHandler) + { + await onClosed.InvokeAsync(e).ConfigureAwait(false); + } + + break; + + case CircuitState.HalfOpen: + if (_onHalfOpened is { } onHalfOpened && onHalfOpened.HasHandler) + { + await onHalfOpened.InvokeAsync(e).ConfigureAwait(false); + } + + break; + } + } + + /// Fires event callback outside lock. Emits telemetry. + private void FireEvent(CircuitStateChangedEvent? evt) + { + if (evt is null) + { + return; + } + + var e = evt.Value; + ResilionTelemetry.CircuitBreakerStateChanges.Add(1, new(ResilionTelemetry.PipelineNameTag, e.Context.PipelineName), new(ResilionTelemetry.OperationKeyTag, e.Context.OperationKey)); + + switch (e.CurrentState) + { + case CircuitState.Open: + if (_onOpened is { } onOpened && onOpened.HasHandler) + { + onOpened.Invoke(e); + } + + break; + + case CircuitState.Closed: + if (_onClosed is { } onClosed && onClosed.HasHandler) + { + onClosed.Invoke(e); + } + + break; + + case CircuitState.HalfOpen: + if (_onHalfOpened is { } onHalfOpened && onHalfOpened.HasHandler) + { + onHalfOpened.Invoke(e); + } + + break; + } + } + + /// Must be called under lock. + private CircuitStateChangedEvent? Trip(ResilienceContext context) + { + _openedAtTimestamp = _timeProvider.GetTimestamp(); + _halfOpenAttempts = 0; + _tripCount++; + _effectiveBreakDuration = GetEffectiveBreakDuration(context); + return TransitionTo(CircuitState.Open, context); + } + + /// Must be called under lock (called only from ). + private TimeSpan GetEffectiveBreakDuration(ResilienceContext context) + { + if (_breakDurationGenerator is { } generator) + { + return generator(new BreakDurationGeneratorArgs(_tripCount, _breakDuration, context)); + } + + return _breakDuration; + } + + /// Must be called under lock. Returns event to fire outside lock. + private CircuitStateChangedEvent? TransitionTo(CircuitState newState, ResilienceContext context) + { + var previous = _state; + _state = newState; + return new CircuitStateChangedEvent(previous, newState, context); + } + + private CircuitBrokenException CreateRejectException() + { + var remaining = TimeSpan.Zero; + if (_state == CircuitState.Open) + { + var elapsed = _timeProvider.GetElapsedTime(_openedAtTimestamp); + remaining = _effectiveBreakDuration - elapsed; + if (remaining < TimeSpan.Zero) + { + remaining = TimeSpan.Zero; + } + } + + return new CircuitBrokenException(_state, remaining); + } + + private void Isolate() + { + lock (_lock) + { + _state = CircuitState.Isolated; + } + } + + private void Reset() + { + lock (_lock) + { + _window.Reset(); + _state = CircuitState.Closed; + _halfOpenAttempts = 0; + _tripCount = 0; + _effectiveBreakDuration = _breakDuration; + } + } +} diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerStrategy.cs b/src/Resilion/CircuitBreaker/CircuitBreakerStrategy.cs index 973ba96..8131877 100644 --- a/src/Resilion/CircuitBreaker/CircuitBreakerStrategy.cs +++ b/src/Resilion/CircuitBreaker/CircuitBreakerStrategy.cs @@ -1,4 +1,4 @@ -using Resilion.Internal; +using System.Diagnostics; namespace Resilion; @@ -8,46 +8,66 @@ namespace Resilion; internal sealed class CircuitBreakerStrategy : Strategy { private readonly CircuitBreakerStrategyOptions _options; - private readonly TimeProvider _timeProvider; - private readonly SlidingWindow _window; - private readonly object _lock = new(); - - private volatile CircuitState _state = CircuitState.Closed; - private long _openedAtTimestamp; - private int _halfOpenAttempts; + private readonly CircuitBreakerStateMachine _machine; internal CircuitBreakerStrategy(CircuitBreakerStrategyOptions options, TimeProvider timeProvider) { _options = options; - _timeProvider = timeProvider; - _window = new SlidingWindow(options.SamplingDuration, timeProvider); - - options.ManualControl?.Initialize( - onIsolate: () => { Isolate(); return Task.CompletedTask; }, - onReset: () => { Reset(); return Task.CompletedTask; }); + _machine = new CircuitBreakerStateMachine( + options.FailureRatioThreshold, + options.MinimumThroughput, + options.BreakDuration, + options.BreakDurationGenerator, + options.SamplingDuration, + timeProvider, + options.OnOpened, + options.OnClosed, + options.OnHalfOpened, + options.ManualControl); } - internal CircuitState State => _state; + internal CircuitState State => _machine.State; protected internal override async ValueTask> ExecuteAsync( Func>> callback, ResilienceContext context) { - var rejection = TryReject(context); + using var activity = ResilionTelemetry.ActivitySource.StartActivity("CircuitBreaker"); + if (activity is not null) + { + activity.SetTag("strategy.name", "CircuitBreaker"); + activity.SetTag("pipeline.name", context.PipelineName); + activity.SetTag("operation.key", context.OperationKey); + } + + var rejection = _machine.TryReject(context); if (rejection is not null) { + if (activity is not null) + { + activity.SetTag("outcome", "rejected"); + } return Outcome.FromException(rejection); } try { var outcome = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); - await RecordOutcomeAsync(outcome.Exception, context).ConfigureAwait(context.ContinueOnCapturedContext); + await _machine.RecordOutcomeAsync(IsFailure(outcome.Exception), context) + .ConfigureAwait(context.ContinueOnCapturedContext); + if (activity is not null) + { + activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); + } return outcome; } catch (Exception ex) { - await RecordOutcomeAsync(ex, context).ConfigureAwait(context.ContinueOnCapturedContext); + await _machine.RecordOutcomeAsync(IsFailure(ex), context).ConfigureAwait(context.ContinueOnCapturedContext); + if (activity is not null) + { + activity.SetTag("outcome", "exception"); + } throw; } } @@ -56,283 +76,45 @@ protected internal override Outcome Execute( Func> callback, ResilienceContext context) { - var rejection = TryReject(context); + using var activity = ResilionTelemetry.ActivitySource.StartActivity("CircuitBreaker"); + if (activity is not null) + { + activity.SetTag("strategy.name", "CircuitBreaker"); + activity.SetTag("pipeline.name", context.PipelineName); + activity.SetTag("operation.key", context.OperationKey); + } + + var rejection = _machine.TryReject(context); if (rejection is not null) { + if (activity is not null) + { + activity.SetTag("outcome", "rejected"); + } return Outcome.FromException(rejection); } try { var outcome = callback(context); - RecordOutcome(outcome.Exception, context); + _machine.RecordOutcome(IsFailure(outcome.Exception), context); + if (activity is not null) + { + activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); + } return outcome; } catch (Exception ex) { - RecordOutcome(ex, context); - throw; - } - } - - private CircuitBrokenException? TryReject(ResilienceContext context) - { - var state = _state; - - switch (state) - { - case CircuitState.Closed: - return null; - - case CircuitState.HalfOpen: - lock (_lock) - { - if (_state != CircuitState.HalfOpen) - { - return _state is CircuitState.Open or CircuitState.Isolated - ? CreateRejectException() - : null; - } - - if (_halfOpenAttempts > 0) - { - return CreateRejectException(); - } - - _halfOpenAttempts++; - return null; - } - - case CircuitState.Open: - var elapsed = _timeProvider.GetElapsedTime(_openedAtTimestamp); - if (elapsed >= _options.BreakDuration) - { - CircuitStateChangedEvent? pendingEvent = null; - lock (_lock) - { - if (_state == CircuitState.Open) - { - pendingEvent = TransitionTo(CircuitState.HalfOpen, context); - _halfOpenAttempts = 1; - } - } - - FireEvent(pendingEvent); - - return _state is CircuitState.Open or CircuitState.Isolated - ? CreateRejectException() - : null; - } - - return CreateRejectException(); - - case CircuitState.Isolated: - return new CircuitBrokenException(CircuitState.Isolated, TimeSpan.Zero); - - default: - return null; - } - } - - private void RecordOutcome(Exception? exception, ResilienceContext context) - { - var isFailure = exception is not null && _options.ShouldHandleException(exception); - RecordAndTransition(isFailure, context); - } - - private async ValueTask RecordOutcomeAsync(Exception? exception, ResilienceContext context) - { - var isFailure = exception is not null && _options.ShouldHandleException(exception); - await RecordAndTransitionAsync(isFailure, context).ConfigureAwait(false); - } - - private async ValueTask RecordAndTransitionAsync(bool isFailure, ResilienceContext context) - { - var pendingEvent = ComputeTransition(isFailure, context); - if (pendingEvent.HasValue) - { - await FireEventAsync(pendingEvent.Value).ConfigureAwait(false); - } - } - - private CircuitStateChangedEvent? ComputeTransition(bool isFailure, ResilienceContext context) - { - var currentState = _state; - - switch (currentState) - { - case CircuitState.Closed: - // Record outcome and get ratio under single lock for efficiency - var failureRatio = _window.RecordAndGetRatio(isFailure, out var totalCount); - if (failureRatio >= _options.FailureRatioThreshold - && totalCount >= _options.MinimumThroughput) - { - lock (_lock) - { - if (_state == CircuitState.Closed) - { - return Trip(context); - } - } - } - - return null; - - case CircuitState.HalfOpen: - lock (_lock) - { - if (_state != CircuitState.HalfOpen) - { - return null; - } - - if (isFailure) - { - return Trip(context); - } - - _window.Reset(); - return TransitionTo(CircuitState.Closed, context); - } - - default: - return null; - } - } - - private async ValueTask FireEventAsync(CircuitStateChangedEvent? evt) - { - if (evt is null) - { - return; - } - - ResilionTelemetry.CircuitBreakerStateChanges.Add(1); - - var e = evt.Value; - switch (e.CurrentState) - { - case CircuitState.Open: - if (_options.OnOpened is { } onOpened && onOpened.HasHandler) - { - await onOpened.InvokeAsync(e).ConfigureAwait(false); - } - - break; - - case CircuitState.Closed: - if (_options.OnClosed is { } onClosed && onClosed.HasHandler) - { - await onClosed.InvokeAsync(e).ConfigureAwait(false); - } - - break; - - case CircuitState.HalfOpen: - if (_options.OnHalfOpened is { } onHalfOpened && onHalfOpened.HasHandler) - { - await onHalfOpened.InvokeAsync(e).ConfigureAwait(false); - } - - break; - } - } - - private void RecordAndTransition(bool isFailure, ResilienceContext context) - { - var pendingEvent = ComputeTransition(isFailure, context); - if (pendingEvent.HasValue) - { - FireEvent(pendingEvent.Value); - } - } - - /// Must be called under lock. - private CircuitStateChangedEvent? Trip(ResilienceContext context) - { - _openedAtTimestamp = _timeProvider.GetTimestamp(); - _halfOpenAttempts = 0; - return TransitionTo(CircuitState.Open, context); - } - - /// Must be called under lock. Returns event to fire outside lock. - private CircuitStateChangedEvent? TransitionTo(CircuitState newState, ResilienceContext context) - { - var previous = _state; - _state = newState; - return new CircuitStateChangedEvent(previous, newState, context); - } - - /// Fires event callback outside lock. Emits telemetry. - private void FireEvent(CircuitStateChangedEvent? evt) - { - if (evt is null) - { - return; - } - - ResilionTelemetry.CircuitBreakerStateChanges.Add(1); - - var e = evt.Value; - switch (e.CurrentState) - { - case CircuitState.Open: - if (_options.OnOpened is { } onOpened && onOpened.HasHandler) - { - onOpened.Invoke(e); - } - - break; - - case CircuitState.Closed: - if (_options.OnClosed is { } onClosed && onClosed.HasHandler) - { - onClosed.Invoke(e); - } - - break; - - case CircuitState.HalfOpen: - if (_options.OnHalfOpened is { } onHalfOpened && onHalfOpened.HasHandler) - { - onHalfOpened.Invoke(e); - } - - break; - } - } - - private CircuitBrokenException CreateRejectException() - { - var remaining = TimeSpan.Zero; - if (_state == CircuitState.Open) - { - var elapsed = _timeProvider.GetElapsedTime(_openedAtTimestamp); - remaining = _options.BreakDuration - elapsed; - if (remaining < TimeSpan.Zero) + _machine.RecordOutcome(IsFailure(ex), context); + if (activity is not null) { - remaining = TimeSpan.Zero; + activity.SetTag("outcome", "exception"); } - } - - return new CircuitBrokenException(_state, remaining); - } - - private void Isolate() - { - lock (_lock) - { - _state = CircuitState.Isolated; + throw; } } - private void Reset() - { - lock (_lock) - { - _window.Reset(); - _state = CircuitState.Closed; - _halfOpenAttempts = 0; - } - } + private bool IsFailure(Exception? exception) + => exception is not null && _options.ShouldHandleException(exception); } diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerStrategyOptions.cs b/src/Resilion/CircuitBreaker/CircuitBreakerStrategyOptions.cs index dd995e0..62790da 100644 --- a/src/Resilion/CircuitBreaker/CircuitBreakerStrategyOptions.cs +++ b/src/Resilion/CircuitBreaker/CircuitBreakerStrategyOptions.cs @@ -1,3 +1,5 @@ +using Resilion.Internal; + namespace Resilion; /// @@ -25,10 +27,18 @@ public sealed record CircuitBreakerStrategyOptions /// /// Gets the duration the circuit stays open before transitioning to half-open. - /// Defaults to 30 seconds. + /// Defaults to 30 seconds. Ignored for a trip when + /// is set — it's still passed to the generator as . /// public TimeSpan BreakDuration { get; init; } = TimeSpan.FromSeconds(30); + /// + /// Gets an optional generator that computes the break duration dynamically for each trip + /// (e.g. exponential backoff on repeated trips). When set, it takes precedence over + /// . + /// + public Func? BreakDurationGenerator { get; init; } + /// /// Gets the predicate that determines which exceptions count as failures. /// Defaults to all exceptions except . @@ -111,6 +121,9 @@ public sealed record CircuitBreakerStrategyOptions /// public TimeSpan BreakDuration { get; init; } = TimeSpan.FromSeconds(30); + /// + public Func? BreakDurationGenerator { get; init; } + /// /// Gets the predicate that determines which outcomes count as failures. /// @@ -162,7 +175,7 @@ internal bool ShouldHandleOutcome(Outcome outcome) return ShouldHandle(outcome); } - return outcome.Exception is not null and not OperationCanceledException; + return OutcomePredicates.DefaultShouldHandle(outcome); } } @@ -176,3 +189,14 @@ public readonly record struct CircuitStateChangedEvent( CircuitState PreviousState, CircuitState CurrentState, ResilienceContext Context); + +/// +/// Arguments passed to a BreakDurationGenerator delegate when the circuit trips. +/// +/// How many times the circuit has tripped in total, including this trip. +/// The static BreakDuration configured on the options. +/// The execution context of the call that caused this trip. +public readonly record struct BreakDurationGeneratorArgs( + int FailureCount, + TimeSpan CurrentBreakDuration, + ResilienceContext Context); diff --git a/src/Resilion/CircuitBreaker/CircuitBreakerTypedStrategy.cs b/src/Resilion/CircuitBreaker/CircuitBreakerTypedStrategy.cs index 7be60c3..706b33c 100644 --- a/src/Resilion/CircuitBreaker/CircuitBreakerTypedStrategy.cs +++ b/src/Resilion/CircuitBreaker/CircuitBreakerTypedStrategy.cs @@ -1,5 +1,3 @@ -using Resilion.Internal; - namespace Resilion; /// @@ -8,32 +6,31 @@ namespace Resilion; internal sealed class CircuitBreakerTypedStrategy : Strategy { private readonly CircuitBreakerStrategyOptions _options; - private readonly TimeProvider _timeProvider; - private readonly SlidingWindow _window; - private readonly object _lock = new(); - - private volatile CircuitState _state = CircuitState.Closed; - private long _openedAtTimestamp; - private int _halfOpenAttempts; + private readonly CircuitBreakerStateMachine _machine; internal CircuitBreakerTypedStrategy(CircuitBreakerStrategyOptions options, TimeProvider timeProvider) { _options = options; - _timeProvider = timeProvider; - _window = new SlidingWindow(options.SamplingDuration, timeProvider); - - options.ManualControl?.Initialize( - onIsolate: () => { Isolate(); return Task.CompletedTask; }, - onReset: () => { Reset(); return Task.CompletedTask; }); + _machine = new CircuitBreakerStateMachine( + options.FailureRatioThreshold, + options.MinimumThroughput, + options.BreakDuration, + options.BreakDurationGenerator, + options.SamplingDuration, + timeProvider, + options.OnOpened, + options.OnClosed, + options.OnHalfOpened, + options.ManualControl); } - internal CircuitState State => _state; + internal CircuitState State => _machine.State; protected internal override async ValueTask> ExecuteAsync( Func>> callback, ResilienceContext context) { - var rejection = TryReject(context); + var rejection = _machine.TryReject(context); if (rejection is not null) { return Outcome.FromException(rejection); @@ -43,14 +40,13 @@ protected internal override async ValueTask> ExecuteAsync( { var outcome = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); var isFailure = _options.ShouldHandleOutcome(outcome); - await ComputeAndFireTransitionAsync(isFailure, context).ConfigureAwait(context.ContinueOnCapturedContext); + await _machine.RecordOutcomeAsync(isFailure, context).ConfigureAwait(context.ContinueOnCapturedContext); return outcome; } catch (Exception ex) { - var failureOutcome = Outcome.FromException(ex); - var isFailure = _options.ShouldHandleOutcome(failureOutcome); - await ComputeAndFireTransitionAsync(isFailure, context).ConfigureAwait(context.ContinueOnCapturedContext); + var isFailure = _options.ShouldHandleOutcome(Outcome.FromException(ex)); + await _machine.RecordOutcomeAsync(isFailure, context).ConfigureAwait(context.ContinueOnCapturedContext); throw; } } @@ -59,7 +55,7 @@ protected internal override Outcome Execute( Func> callback, ResilienceContext context) { - var rejection = TryReject(context); + var rejection = _machine.TryReject(context); if (rejection is not null) { return Outcome.FromException(rejection); @@ -69,279 +65,14 @@ protected internal override Outcome Execute( { var outcome = callback(context); var isFailure = _options.ShouldHandleOutcome(outcome); - RecordAndTransition(isFailure, context); + _machine.RecordOutcome(isFailure, context); return outcome; } catch (Exception ex) { - var failureOutcome = Outcome.FromException(ex); - var isFailure = _options.ShouldHandleOutcome(failureOutcome); - RecordAndTransition(isFailure, context); + var isFailure = _options.ShouldHandleOutcome(Outcome.FromException(ex)); + _machine.RecordOutcome(isFailure, context); throw; } } - - private CircuitBrokenException? TryReject(ResilienceContext context) - { - var state = _state; - - switch (state) - { - case CircuitState.Closed: - return null; - - case CircuitState.HalfOpen: - lock (_lock) - { - if (_state != CircuitState.HalfOpen) - { - return _state is CircuitState.Open or CircuitState.Isolated - ? CreateRejectException() - : null; - } - - if (_halfOpenAttempts > 0) - { - return CreateRejectException(); - } - - _halfOpenAttempts++; - return null; - } - - case CircuitState.Open: - var elapsed = _timeProvider.GetElapsedTime(_openedAtTimestamp); - if (elapsed >= _options.BreakDuration) - { - CircuitStateChangedEvent? pendingEvent = null; - lock (_lock) - { - if (_state == CircuitState.Open) - { - pendingEvent = TransitionTo(CircuitState.HalfOpen, context); - _halfOpenAttempts = 1; - } - } - - FireEvent(pendingEvent); - - return _state is CircuitState.Open or CircuitState.Isolated - ? CreateRejectException() - : null; - } - - return CreateRejectException(); - - case CircuitState.Isolated: - return new CircuitBrokenException(CircuitState.Isolated, TimeSpan.Zero); - - default: - return null; - } - } - - private void RecordAndTransition(bool isFailure, ResilienceContext context) - { - var pendingEvent = ComputeTransition(isFailure, context); - if (pendingEvent.HasValue) - { - FireEvent(pendingEvent.Value); - } - } - - private async ValueTask ComputeAndFireTransitionAsync(bool isFailure, ResilienceContext context) - { - var pendingEvent = ComputeTransition(isFailure, context); - if (pendingEvent.HasValue) - { - await FireEventAsync(pendingEvent.Value).ConfigureAwait(false); - } - } - - private CircuitStateChangedEvent? ComputeTransition(bool isFailure, ResilienceContext context) - { - var currentState = _state; - - switch (currentState) - { - case CircuitState.Closed: - if (isFailure) - { - _window.RecordFailure(); - } - else - { - _window.RecordSuccess(); - } - - var failureRatio = _window.GetFailureRatio(out var totalCount); - if (failureRatio >= _options.FailureRatioThreshold - && totalCount >= _options.MinimumThroughput) - { - lock (_lock) - { - if (_state == CircuitState.Closed) - { - return Trip(context); - } - } - } - - return null; - - case CircuitState.HalfOpen: - lock (_lock) - { - if (_state != CircuitState.HalfOpen) - { - return null; - } - - if (isFailure) - { - return Trip(context); - } - - _window.Reset(); - return TransitionTo(CircuitState.Closed, context); - } - - default: - return null; - } - } - - private async ValueTask FireEventAsync(CircuitStateChangedEvent? evt) - { - if (evt is null) - { - return; - } - - ResilionTelemetry.CircuitBreakerStateChanges.Add(1); - - var e = evt.Value; - switch (e.CurrentState) - { - case CircuitState.Open: - if (_options.OnOpened is { } onOpened && onOpened.HasHandler) - { - await onOpened.InvokeAsync(e).ConfigureAwait(false); - } - - break; - - case CircuitState.Closed: - if (_options.OnClosed is { } onClosed && onClosed.HasHandler) - { - await onClosed.InvokeAsync(e).ConfigureAwait(false); - } - - break; - - case CircuitState.HalfOpen: - if (_options.OnHalfOpened is { } onHalfOpened && onHalfOpened.HasHandler) - { - await onHalfOpened.InvokeAsync(e).ConfigureAwait(false); - } - - break; - } - } - - /// - /// Transitions to Open state. Must be called under lock. - /// - private CircuitStateChangedEvent? Trip(ResilienceContext context) - { - _openedAtTimestamp = _timeProvider.GetTimestamp(); - _halfOpenAttempts = 0; - return TransitionTo(CircuitState.Open, context); - } - - /// - /// Transitions state and returns the event to fire (outside the lock). - /// Must be called under lock. - /// - private CircuitStateChangedEvent? TransitionTo(CircuitState newState, ResilienceContext context) - { - var previous = _state; - _state = newState; - return new CircuitStateChangedEvent(previous, newState, context); - } - - /// - /// Fires the event callback outside the lock. - /// - private void FireEvent(CircuitStateChangedEvent? evt) - { - if (evt is null) - { - return; - } - - ResilionTelemetry.CircuitBreakerStateChanges.Add(1); - - var e = evt.Value; - switch (e.CurrentState) - { - case CircuitState.Open: - if (_options.OnOpened is { } onOpened && onOpened.HasHandler) - { - onOpened.Invoke(e); - } - - break; - - case CircuitState.Closed: - if (_options.OnClosed is { } onClosed && onClosed.HasHandler) - { - onClosed.Invoke(e); - } - - break; - - case CircuitState.HalfOpen: - if (_options.OnHalfOpened is { } onHalfOpened && onHalfOpened.HasHandler) - { - onHalfOpened.Invoke(e); - } - - break; - } - } - - private CircuitBrokenException CreateRejectException() - { - var remaining = TimeSpan.Zero; - if (_state == CircuitState.Open) - { - var elapsed = _timeProvider.GetElapsedTime(_openedAtTimestamp); - remaining = _options.BreakDuration - elapsed; - if (remaining < TimeSpan.Zero) - { - remaining = TimeSpan.Zero; - } - } - - return new CircuitBrokenException(_state, remaining); - } - - private void Isolate() - { - lock (_lock) - { - _state = CircuitState.Isolated; - } - } - - private void Reset() - { - lock (_lock) - { - _window.Reset(); - _state = CircuitState.Closed; - _halfOpenAttempts = 0; - } - } } diff --git a/src/Resilion/CircuitBreaker/SlidingWindow.cs b/src/Resilion/CircuitBreaker/SlidingWindow.cs index cae9d1f..fa32435 100644 --- a/src/Resilion/CircuitBreaker/SlidingWindow.cs +++ b/src/Resilion/CircuitBreaker/SlidingWindow.cs @@ -5,8 +5,10 @@ namespace Resilion.Internal; /// Uses a fixed-size ring buffer of time buckets, each covering a fraction of the total window. /// /// -/// Thread-safe. All public methods acquire the internal lock. Counter increments on the current -/// bucket use Interlocked for minimal contention on the hot path (Closed state, call succeeds). +/// Thread-safe. All public methods acquire a single internal lock for the duration of the call. +/// combines recording an outcome and reading the failure ratio +/// under one lock acquisition, so the two operations are atomic with respect to concurrent +/// callers and bucket rotation. /// internal sealed class SlidingWindow { diff --git a/src/Resilion/Fallback/FallbackStrategy.cs b/src/Resilion/Fallback/FallbackStrategy.cs index ed6ddad..e27a7aa 100644 --- a/src/Resilion/Fallback/FallbackStrategy.cs +++ b/src/Resilion/Fallback/FallbackStrategy.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace Resilion; /// @@ -16,14 +18,26 @@ protected internal override async ValueTask> ExecuteAsync( Func>> callback, ResilienceContext context) { + using var activity = ResilionTelemetry.ActivitySource.StartActivity("Fallback"); + if (activity is not null) + { + activity.SetTag("strategy.name", "Fallback"); + activity.SetTag("pipeline.name", context.PipelineName); + activity.SetTag("operation.key", context.OperationKey); + } + var outcome = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); if (!_options.ShouldHandleOutcome(outcome)) { + if (activity is not null) + { + activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); + } return outcome; } - ResilionTelemetry.FallbackActivations.Add(1); + ResilionTelemetry.FallbackActivations.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); var fallbackCtx = new FallbackContext(outcome, context); var fallbackResult = await _options.FallbackAction.ExecuteAsync(fallbackCtx).ConfigureAwait(false); @@ -34,6 +48,11 @@ await handler.InvokeAsync(new OnFallbackEvent(outcome, fallbackResult, .ConfigureAwait(false); } + if (activity is not null) + { + activity.SetTag("outcome", "fallback_applied"); + } + return Outcome.FromResult(fallbackResult); } @@ -41,14 +60,26 @@ protected internal override Outcome Execute( Func> callback, ResilienceContext context) { + using var activity = ResilionTelemetry.ActivitySource.StartActivity("Fallback"); + if (activity is not null) + { + activity.SetTag("strategy.name", "Fallback"); + activity.SetTag("pipeline.name", context.PipelineName); + activity.SetTag("operation.key", context.OperationKey); + } + var outcome = callback(context); if (!_options.ShouldHandleOutcome(outcome)) { + if (activity is not null) + { + activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); + } return outcome; } - ResilionTelemetry.FallbackActivations.Add(1); + ResilionTelemetry.FallbackActivations.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); var fallbackCtx = new FallbackContext(outcome, context); var fallbackResult = _options.FallbackAction.Execute(fallbackCtx); @@ -58,6 +89,11 @@ protected internal override Outcome Execute( handler.Invoke(new OnFallbackEvent(outcome, fallbackResult, context)); } + if (activity is not null) + { + activity.SetTag("outcome", "fallback_applied"); + } + return Outcome.FromResult(fallbackResult); } } diff --git a/src/Resilion/Fallback/FallbackStrategyOptions.cs b/src/Resilion/Fallback/FallbackStrategyOptions.cs index f00e1eb..5c116d7 100644 --- a/src/Resilion/Fallback/FallbackStrategyOptions.cs +++ b/src/Resilion/Fallback/FallbackStrategyOptions.cs @@ -1,3 +1,5 @@ +using Resilion.Internal; + namespace Resilion; /// @@ -43,7 +45,7 @@ internal bool ShouldHandleOutcome(Outcome outcome) return ShouldHandle(outcome); } - return outcome.Exception is not null and not OperationCanceledException; + return OutcomePredicates.DefaultShouldHandle(outcome); } } diff --git a/src/Resilion/Hedging/HedgingRejectedException.cs b/src/Resilion/Hedging/HedgingRejectedException.cs deleted file mode 100644 index a34272a..0000000 --- a/src/Resilion/Hedging/HedgingRejectedException.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Resilion; - -/// -/// Thrown when all hedging attempts fail. -/// -public sealed class HedgingRejectedException : ResilionException -{ - internal HedgingRejectedException(IReadOnlyList attemptExceptions) - : base("All hedging attempts failed.") - { - AttemptExceptions = attemptExceptions; - } - - /// - /// Gets the exceptions from all hedging attempts, in attempt order. - /// The first element is the primary attempt's exception. - /// - public IReadOnlyList AttemptExceptions { get; } -} diff --git a/src/Resilion/Hedging/HedgingStrategy.cs b/src/Resilion/Hedging/HedgingStrategy.cs index a95f48b..d6cbc31 100644 --- a/src/Resilion/Hedging/HedgingStrategy.cs +++ b/src/Resilion/Hedging/HedgingStrategy.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace Resilion; /// @@ -19,10 +21,23 @@ protected internal override async ValueTask> ExecuteAsync( Func>> callback, ResilienceContext context) { + using var activity = ResilionTelemetry.ActivitySource.StartActivity("Hedging"); + if (activity is not null) + { + activity.SetTag("strategy.name", "Hedging"); + activity.SetTag("pipeline.name", context.PipelineName); + activity.SetTag("operation.key", context.OperationKey); + } + if (_options.MaxHedgedAttempts == 1) { // No hedging — just execute the primary. - return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + var result = await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); + if (activity is not null) + { + activity.SetTag("outcome", result.IsSuccess ? "success" : "failure"); + } + return result; } var userToken = context.CancellationToken; @@ -74,7 +89,7 @@ protected internal override async ValueTask> ExecuteAsync( } // Parallel mode (delay == 0): launch immediately, no waiting. - ResilionTelemetry.HedgingAttempts.Add(1); + ResilionTelemetry.HedgingAttempts.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); // Fire OnHedging event. if (_options.OnHedging is { } handler && handler.HasHandler) @@ -88,7 +103,12 @@ await handler.InvokeAsync(new OnHedgingEvent(attemptIndex, context)) } // All attempts launched. Wait for the first success or all to fail. - return await WaitForBestOutcome(attempts, userToken).ConfigureAwait(false); + var outcome = await WaitForBestOutcome(attempts, userToken).ConfigureAwait(false); + if (activity is not null) + { + activity.SetTag("outcome", outcome.IsSuccess ? "success" : "hedging_exhausted"); + } + return outcome; } finally { @@ -140,8 +160,17 @@ protected internal override Outcome Execute( Func> callback, ResilienceContext context) { - // Hedging is inherently async (parallel execution). - // For the sync path, execute sequentially (equivalent to InfiniteTimeSpan mode). + // Hedging's entire value proposition is running attempts concurrently. The sync path + // can only ever run sequentially — silently degrading parallel/latency hedging to + // sequential would give the caller zero indication their hedging isn't actually hedging. + if (_options.HedgingDelay != System.Threading.Timeout.InfiniteTimeSpan) + { + throw new InvalidOperationException( + "Parallel and latency hedging modes require async execution. Use ExecuteAsync(), " + + "or set HedgingDelay to Timeout.InfiniteTimeSpan for sequential mode."); + } + + // Sequential mode: execute sequentially (equivalent to InfiniteTimeSpan mode). Outcome lastOutcome = default; for (var attemptIndex = 0; attemptIndex < _options.MaxHedgedAttempts; attemptIndex++) diff --git a/src/Resilion/Hedging/HedgingStrategyOptions.cs b/src/Resilion/Hedging/HedgingStrategyOptions.cs index 0e5a23c..eec22f5 100644 --- a/src/Resilion/Hedging/HedgingStrategyOptions.cs +++ b/src/Resilion/Hedging/HedgingStrategyOptions.cs @@ -1,3 +1,5 @@ +using Resilion.Internal; + namespace Resilion; /// @@ -73,7 +75,7 @@ internal bool ShouldHandleOutcome(Outcome outcome) return ShouldHandle(outcome); } - return outcome.Exception is not null and not OperationCanceledException; + return OutcomePredicates.DefaultShouldHandle(outcome); } } diff --git a/src/Resilion/Internal/OrderingValidator.cs b/src/Resilion/Internal/OrderingValidator.cs index f287ffe..8c4af5f 100644 --- a/src/Resilion/Internal/OrderingValidator.cs +++ b/src/Resilion/Internal/OrderingValidator.cs @@ -1,21 +1,39 @@ namespace Resilion.Internal; /// -/// Validates strategy ordering at Build() time and returns diagnostic warnings -/// for common misorderings. Warnings only — never blocks the build. +/// The result of : dangerous misorderings that are +/// (almost) always wrong, separated from situational ones that merely warrant a second look. +/// +/// +/// Misorderings that are essentially always a bug (CircuitBreaker outside Retry, Fallback not +/// outermost). Callers may choose to throw on these — see PipelineBuilderBase.ThrowOnOrderingErrors. +/// +/// +/// Situational misorderings (3+ Timeouts, Hedging + Retry together, Retry outermost with no +/// Timeout) that are sometimes intentional. Always advisory, never thrown. +/// +internal readonly record struct OrderingValidationResult(List Errors, List Warnings) +{ + /// Convenience accessor for callers that don't need the error/warning distinction. + internal IEnumerable AllMessages => Errors.Concat(Warnings); +} + +/// +/// Validates strategy ordering at Build() time and returns diagnostics for common misorderings. /// internal static class OrderingValidator { /// - /// Validates the order of strategies and returns any warnings. + /// Validates the order of strategies and returns any errors and warnings. /// - internal static List Validate(IReadOnlyList strategies) + internal static OrderingValidationResult Validate(IReadOnlyList strategies) { + var errors = new List(); var warnings = new List(); if (strategies.Count < 2) { - return warnings; + return new OrderingValidationResult(errors, warnings); } // Build index maps for quick lookup. @@ -32,8 +50,9 @@ internal static List Validate(IReadOnlyList strategies) list.Add(i); } - // Rule 1: CircuitBreaker should be inside (after) Retry, not outside (before). - // If CB is before Retry, retries bypass the breaker entirely. + // Error 1: CircuitBreaker should be inside (after) Retry, not outside (before). + // If CB is before Retry, retries bypass the breaker entirely — this is essentially + // always a bug, never an intentional design choice. if (positions.TryGetValue(StrategyType.CircuitBreaker, out var cbPositions) && positions.TryGetValue(StrategyType.Retry, out var retryPositions)) { @@ -43,30 +62,31 @@ internal static List Validate(IReadOnlyList strategies) { if (cbPos < retryPos) { - warnings.Add( + errors.Add( "CircuitBreaker is outside Retry (position " + cbPos + " vs " + retryPos + "). " + - "This means retries bypass the circuit breaker. Usually CircuitBreaker should be " + + "This means retries bypass the circuit breaker. CircuitBreaker must be " + "inside (after) Retry so each attempt is tracked independently."); } } } } - // Rule 2: Fallback should typically be outermost (first) to catch all failures. + // Error 2: Fallback should be outermost (first) to catch all failures — a Fallback + // that isn't outermost fails to catch failures from strategies placed outside it. if (positions.TryGetValue(StrategyType.Fallback, out var fallbackPositions)) { foreach (var fbPos in fallbackPositions) { if (fbPos > 0) { - warnings.Add( + errors.Add( "Fallback is at position " + fbPos + ", not outermost (position 0). " + - "Fallback typically goes first so it catches failures from all inner strategies."); + "Fallback must go first so it catches failures from all inner strategies."); } } } - // Rule 3: Hedging and Retry together — may cause excessive load. + // Warning 1: Hedging and Retry together — may cause excessive load. Sometimes intentional. if (positions.ContainsKey(StrategyType.Hedging) && positions.ContainsKey(StrategyType.Retry)) { warnings.Add( @@ -75,7 +95,7 @@ internal static List Validate(IReadOnlyList strategies) "Verify this is intentional."); } - // Rule 4: Multiple Timeout strategies without clear total/per-attempt separation. + // Warning 2: Multiple Timeout strategies without clear total/per-attempt separation. if (positions.TryGetValue(StrategyType.Timeout, out var timeoutPositions) && timeoutPositions.Count > 2) { warnings.Add( @@ -83,7 +103,7 @@ internal static List Validate(IReadOnlyList strategies) "one total timeout (outermost) and one per-attempt timeout (innermost)."); } - // Rule 5: Retry as outermost strategy with no Timeout outside it. + // Warning 3: Retry as outermost strategy with no Timeout outside it. if (positions.TryGetValue(StrategyType.Retry, out var retryPos2)) { foreach (var rPos in retryPos2) @@ -97,6 +117,6 @@ internal static List Validate(IReadOnlyList strategies) } } - return warnings; + return new OrderingValidationResult(errors, warnings); } } diff --git a/src/Resilion/Internal/OutcomePredicates.cs b/src/Resilion/Internal/OutcomePredicates.cs new file mode 100644 index 0000000..8349926 --- /dev/null +++ b/src/Resilion/Internal/OutcomePredicates.cs @@ -0,0 +1,16 @@ +namespace Resilion.Internal; + +/// +/// Shared default predicates for strategy options. Extracted so the "handle everything except +/// cancellation" default isn't copy-pasted across every typed options class. +/// +internal static class OutcomePredicates +{ + /// + /// The default "should handle" predicate used when a strategy's ShouldHandle is not + /// set: treats any outcome carrying an exception as a failure, except + /// , which is never handled by default. + /// + internal static bool DefaultShouldHandle(Outcome outcome) + => outcome.Exception is not null and not OperationCanceledException; +} diff --git a/src/Resilion/Internal/PipelineComponent.cs b/src/Resilion/Internal/PipelineComponent.cs index d4f6912..9e89dfc 100644 --- a/src/Resilion/Internal/PipelineComponent.cs +++ b/src/Resilion/Internal/PipelineComponent.cs @@ -4,7 +4,7 @@ namespace Resilion.Internal; /// Internal component in the pipeline execution chain. Each strategy is wrapped in a component /// that calls the next component in the chain, forming a middleware-like pipeline. /// -internal abstract class PipelineComponent : IDisposable +internal abstract class PipelineComponent : IDisposable, IAsyncDisposable { /// /// Executes this component's logic and optionally delegates to the next component. @@ -24,6 +24,12 @@ public virtual void Dispose() { } + public virtual ValueTask DisposeAsync() + { + Dispose(); + return default; + } + /// /// A no-op component that directly invokes the callback. Used as the terminal component. /// @@ -80,6 +86,12 @@ public override void Dispose() _strategy.Dispose(); _next.Dispose(); } + + public override async ValueTask DisposeAsync() + { + await _strategy.DisposeAsync().ConfigureAwait(false); + await _next.DisposeAsync().ConfigureAwait(false); + } } /// @@ -108,6 +120,7 @@ internal override ValueTask> ExecuteAsync( } // Type mismatch — skip this strategy entirely. + WarnTypeMismatch(typeof(TResult)); return _next.ExecuteAsync(callback, context); } @@ -120,9 +133,23 @@ internal override Outcome Execute( return ExecuteTyped(callback, context); } + WarnTypeMismatch(typeof(TResult)); return _next.Execute(callback, context); } + /// + /// A typed strategy added to an untyped silently does nothing when + /// executed with a different result type — typeof(TResult) != typeof(TStrategyResult). + /// This doesn't change that behavior (it's still not an error), but at least makes it + /// visible when debugging why a strategy doesn't seem to run. + /// + private void WarnTypeMismatch(Type requestedType) + { + System.Diagnostics.Debug.WriteLine( + $"[Resilion] {_strategy.GetType().Name} is a Strategy<{typeof(TStrategyResult).Name}> but was " + + $"executed with result type {requestedType.Name} — this strategy was skipped, not applied."); + } + private ValueTask> ExecuteTypedAsync( Func>> callback, ResilienceContext context) @@ -164,4 +191,10 @@ public override void Dispose() _strategy.Dispose(); _next.Dispose(); } + + public override async ValueTask DisposeAsync() + { + await _strategy.DisposeAsync().ConfigureAwait(false); + await _next.DisposeAsync().ConfigureAwait(false); + } } diff --git a/src/Resilion/Outcome.cs b/src/Resilion/Outcome.cs index 5b248db..3000d2e 100644 --- a/src/Resilion/Outcome.cs +++ b/src/Resilion/Outcome.cs @@ -135,6 +135,20 @@ public static Outcome FromException(Exception exception) /// Implicitly converts a result value to a successful . /// /// The result value. + /// + /// + /// This always produces a success outcome wrapping — + /// including when is itself or a + /// subtype of it. Outcome<Exception> o = someException; creates a success whose + /// result happens to be an exception object, not a failure. + /// + /// + /// If you use Pipeline<Exception> (or any Pipeline<TResult> where + /// derives from ), prefer the explicit + /// / factory methods over relying on + /// this implicit conversion, to avoid ambiguity about which one you meant. + /// + /// public static implicit operator Outcome(TResult result) => FromResult(result); diff --git a/src/Resilion/Pipeline.cs b/src/Resilion/Pipeline.cs index 4ae66e8..09f63c8 100644 --- a/src/Resilion/Pipeline.cs +++ b/src/Resilion/Pipeline.cs @@ -17,15 +17,17 @@ namespace Resilion; /// use . /// /// -public sealed class Pipeline : IDisposable +public sealed class Pipeline : IDisposable, IAsyncDisposable { private readonly PipelineComponent _component; private readonly ResilienceContextPool _pool; + private readonly string? _name; - internal Pipeline(PipelineComponent component, ResilienceContextPool? pool = null) + internal Pipeline(PipelineComponent component, ResilienceContextPool? pool = null, string? name = null) { _component = component; _pool = pool ?? ResilienceContextPool.Shared; + _name = name; } /// @@ -39,6 +41,11 @@ internal Pipeline(PipelineComponent component, ResilienceContextPool? pool = nul /// internal PipelineComponent Component => _component; + /// + /// Gets the optional name of this pipeline, used in telemetry and diagnostics. + /// + internal string? Name => _name; + /// /// Creates a pipeline by configuring strategies on a builder. /// @@ -88,6 +95,9 @@ public async ValueTask ExecuteAsync( var context = _pool.Rent(cancellationToken); try { + // Set pipeline name in context for telemetry correlation + context.PipelineName = _name; + var outcome = await _component.ExecuteAsync( async ctx => { @@ -142,6 +152,9 @@ public async ValueTask ExecuteAsync( var context = _pool.Rent(cancellationToken); try { + // Set pipeline name in context for telemetry correlation + context.PipelineName = _name; + var outcome = await _component.ExecuteAsync( async ctx => { @@ -182,6 +195,7 @@ public ValueTask> ExecuteOutcomeAsync( { ArgumentNullException.ThrowIfNull(action); ArgumentNullException.ThrowIfNull(context); + context.PipelineName = _name; return _component.ExecuteAsync( ctx => action(state, ctx), @@ -209,6 +223,8 @@ public TResult Execute( ArgumentNullException.ThrowIfNull(action); var context = _pool.Rent(cancellationToken); context.IsSynchronous = true; + // Set pipeline name in context for telemetry correlation + context.PipelineName = _name; try { var outcome = _component.Execute( @@ -271,6 +287,9 @@ public void Execute( /// public void Dispose() => _component.Dispose(); + + /// + public ValueTask DisposeAsync() => _component.DisposeAsync(); } /// diff --git a/src/Resilion/PipelineBuilder.cs b/src/Resilion/PipelineBuilder.cs index a77d8c4..79085ee 100644 --- a/src/Resilion/PipelineBuilder.cs +++ b/src/Resilion/PipelineBuilder.cs @@ -3,23 +3,15 @@ namespace Resilion; /// -/// Builds a by composing resilience strategies. Strategies execute -/// outermost to innermost — the first strategy added is the first to see each call. +/// Shared state and behavior for and : +/// the pipeline name, , and ordering-warning configuration. /// /// -/// -/// The canonical strategy order is: RateLimiter → TotalTimeout → Retry → CircuitBreaker → AttemptTimeout. -/// -/// /// Builders are not thread-safe and should not be shared. Build once, then cache and reuse -/// the resulting . -/// +/// the resulting pipeline. /// -public sealed class PipelineBuilder +public abstract class PipelineBuilderBase { - private readonly List> _componentFactories = []; - private readonly List _strategyTypes = []; - /// /// Gets or sets an optional name for the pipeline, used in telemetry and diagnostics. /// @@ -32,17 +24,84 @@ public sealed class PipelineBuilder public TimeProvider TimeProvider { get; set; } = TimeProvider.System; /// - /// When true, suppresses ordering validation warnings at time. - /// Defaults to false. + /// When true, suppresses ordering validation entirely (both errors and warnings) at + /// build time. Defaults to false. /// public bool SuppressOrderingWarnings { get; set; } /// - /// Optional callback that receives ordering validation warnings at time. - /// When null, warnings are written to . + /// When true, dangerous strategy misorderings (CircuitBreaker outside Retry, Fallback + /// not outermost) throw at build time + /// instead of only warning. Defaults to true — these misorderings are essentially + /// always a bug. Situational misorderings (3+ Timeouts, Hedging + Retry together) always + /// remain advisory regardless of this setting. Has no effect when + /// is true. + /// + public bool ThrowOnOrderingErrors { get; set; } = true; + + /// + /// Optional callback that receives ordering validation warnings at build time (both the + /// advisory warnings, and the dangerous-misordering errors when + /// is false). When null, warnings are written to . /// public Action? OnValidationWarning { get; set; } + private protected bool Built { get; set; } + + private protected void ThrowIfBuilt() + { + if (Built) + { + throw new InvalidOperationException( + "This builder has already been built. Create a new builder for a new pipeline."); + } + } + + /// + /// Applies ordering validation: throws for errors when configured to, otherwise emits both + /// errors and warnings through the advisory path. + /// + private protected void ApplyOrderingValidation(OrderingValidationResult result) + { + if (ThrowOnOrderingErrors && result.Errors.Count > 0) + { + throw new InvalidOperationException( + "Pipeline has dangerous strategy ordering: " + string.Join(" ", result.Errors) + + " Fix the ordering, or set ThrowOnOrderingErrors = false to only warn."); + } + + EmitWarnings(result.Errors); + EmitWarnings(result.Warnings); + } + + private void EmitWarnings(List warnings) + { + foreach (var warning in warnings) + { + if (OnValidationWarning is not null) + { + OnValidationWarning(warning); + } + else + { + System.Diagnostics.Debug.WriteLine($"[Resilion] Ordering warning: {warning}"); + } + } + } +} + +/// +/// Builds a by composing resilience strategies. Strategies execute +/// outermost to innermost — the first strategy added is the first to see each call. +/// +/// +/// The canonical strategy order is: RateLimiter → TotalTimeout → Retry → CircuitBreaker → AttemptTimeout. +/// +public sealed class PipelineBuilder : PipelineBuilderBase +{ + private readonly List> _componentFactories = []; + private readonly List _strategyTypes = []; + /// /// Adds a non-generic strategy to the pipeline. /// @@ -50,6 +109,7 @@ public sealed class PipelineBuilder /// This builder for chaining. public PipelineBuilder AddStrategy(Strategy strategy) { + ThrowIfBuilt(); ArgumentNullException.ThrowIfNull(strategy); _componentFactories.Add(next => new StrategyComponent(strategy, next)); _strategyTypes.Add(StrategyType.Unknown); @@ -58,6 +118,7 @@ public PipelineBuilder AddStrategy(Strategy strategy) internal PipelineBuilder AddStrategy(Strategy strategy, StrategyType type) { + ThrowIfBuilt(); ArgumentNullException.ThrowIfNull(strategy); _componentFactories.Add(next => new StrategyComponent(strategy, next)); _strategyTypes.Add(type); @@ -71,6 +132,7 @@ internal PipelineBuilder AddStrategy(Strategy strategy, StrategyType type) /// This builder for chaining. public PipelineBuilder AddPipeline(Pipeline pipeline) { + ThrowIfBuilt(); ArgumentNullException.ThrowIfNull(pipeline); var outerComponent = pipeline.Component; _componentFactories.Add(next => new DelegatingComponent(outerComponent, next)); @@ -79,21 +141,24 @@ public PipelineBuilder AddPipeline(Pipeline pipeline) } /// - /// Builds the pipeline. The builder should not be used after calling this method. + /// Builds the pipeline. The builder should not be used after calling this method — + /// doing so throws . /// /// An immutable, thread-safe . public Pipeline Build() { + ThrowIfBuilt(); + Built = true; + if (_componentFactories.Count == 0) { - return Pipeline.Empty; + return Name is not null ? new Pipeline(PipelineComponent.Empty, name: Name) : Pipeline.Empty; } // Validate ordering unless suppressed. if (!SuppressOrderingWarnings && _strategyTypes.Count >= 2) { - var warnings = OrderingValidator.Validate(_strategyTypes); - EmitWarnings(warnings); + ApplyOrderingValidation(OrderingValidator.Validate(_strategyTypes)); } var component = PipelineComponent.Empty; @@ -102,22 +167,7 @@ public Pipeline Build() component = _componentFactories[i](component); } - return new Pipeline(component); - } - - private void EmitWarnings(List warnings) - { - foreach (var warning in warnings) - { - if (OnValidationWarning is not null) - { - OnValidationWarning(warning); - } - else - { - System.Diagnostics.Debug.WriteLine($"[Resilion] Ordering warning: {warning}"); - } - } + return new Pipeline(component, name: Name); } } @@ -126,38 +176,17 @@ private void EmitWarnings(List warnings) /// non-generic strategies (Timeout, RateLimiter) and typed strategies (Fallback, Hedging). /// /// The result type for the pipeline. -public sealed class PipelineBuilder +public sealed class PipelineBuilder : PipelineBuilderBase { private readonly List> _componentFactories = []; private readonly List _strategyTypes = []; - /// - /// Gets or sets an optional name for the pipeline, used in telemetry and diagnostics. - /// - public string? Name { get; set; } - - /// - /// Gets or sets the used by time-dependent strategies. - /// Defaults to . - /// - public TimeProvider TimeProvider { get; set; } = TimeProvider.System; - - /// - /// When true, suppresses ordering validation warnings at time. - /// - public bool SuppressOrderingWarnings { get; set; } - - /// - /// Optional callback that receives ordering validation warnings. - /// When null, warnings go to . - /// - public Action? OnValidationWarning { get; set; } - /// /// Adds a non-generic strategy (e.g., Timeout, RateLimiter) to the typed pipeline. /// public PipelineBuilder AddStrategy(Strategy strategy) { + ThrowIfBuilt(); ArgumentNullException.ThrowIfNull(strategy); _componentFactories.Add(next => new StrategyComponent(strategy, next)); _strategyTypes.Add(StrategyType.Unknown); @@ -166,6 +195,7 @@ public PipelineBuilder AddStrategy(Strategy strategy) internal PipelineBuilder AddStrategy(Strategy strategy, StrategyType type) { + ThrowIfBuilt(); ArgumentNullException.ThrowIfNull(strategy); _componentFactories.Add(next => new StrategyComponent(strategy, next)); _strategyTypes.Add(type); @@ -177,6 +207,7 @@ internal PipelineBuilder AddStrategy(Strategy strategy, StrategyType ty /// public PipelineBuilder AddStrategy(Strategy strategy) { + ThrowIfBuilt(); ArgumentNullException.ThrowIfNull(strategy); _componentFactories.Add(next => new TypedStrategyComponent(strategy, next)); _strategyTypes.Add(StrategyType.Unknown); @@ -185,6 +216,7 @@ public PipelineBuilder AddStrategy(Strategy strategy) internal PipelineBuilder AddStrategy(Strategy strategy, StrategyType type) { + ThrowIfBuilt(); ArgumentNullException.ThrowIfNull(strategy); _componentFactories.Add(next => new TypedStrategyComponent(strategy, next)); _strategyTypes.Add(type); @@ -198,6 +230,7 @@ public PipelineBuilder AddStrategy( string name, Func>>, ValueTask>> handler) { + ThrowIfBuilt(); ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(handler); _componentFactories.Add(next => @@ -211,6 +244,7 @@ public PipelineBuilder AddStrategy( /// public PipelineBuilder AddPipeline(Pipeline pipeline) { + ThrowIfBuilt(); ArgumentNullException.ThrowIfNull(pipeline); var outerComponent = pipeline.Component; _componentFactories.Add(next => new DelegatingComponent(outerComponent, next)); @@ -223,6 +257,7 @@ public PipelineBuilder AddPipeline(Pipeline pipeline) /// public PipelineBuilder AddPipeline(Pipeline pipeline) { + ThrowIfBuilt(); ArgumentNullException.ThrowIfNull(pipeline); var outerComponent = pipeline.Component; _componentFactories.Add(next => new DelegatingComponent(outerComponent, next)); @@ -231,19 +266,22 @@ public PipelineBuilder AddPipeline(Pipeline pipeline) } /// - /// Builds the pipeline. + /// Builds the pipeline. The builder should not be used after calling this method — + /// doing so throws . /// public Pipeline Build() { + ThrowIfBuilt(); + Built = true; + if (_componentFactories.Count == 0) { - return Pipeline.Empty; + return Name is not null ? new Pipeline(PipelineComponent.Empty, name: Name) : Pipeline.Empty; } if (!SuppressOrderingWarnings && _strategyTypes.Count >= 2) { - var warnings = OrderingValidator.Validate(_strategyTypes); - EmitWarnings(warnings); + ApplyOrderingValidation(OrderingValidator.Validate(_strategyTypes)); } var component = PipelineComponent.Empty; @@ -252,22 +290,7 @@ public Pipeline Build() component = _componentFactories[i](component); } - return new Pipeline(component); - } - - private void EmitWarnings(List warnings) - { - foreach (var warning in warnings) - { - if (OnValidationWarning is not null) - { - OnValidationWarning(warning); - } - else - { - System.Diagnostics.Debug.WriteLine($"[Resilion] Ordering warning: {warning}"); - } - } + return new Pipeline(component, name: Name); } } @@ -331,6 +354,14 @@ internal override Outcome Execute( public override void Dispose() { // Don't dispose inner — it may be shared via AddPipeline. - // Don't dispose next — the parent chain owns it. + // Dispose next — it's part of this pipeline's chain and must be cleaned up. + _next.Dispose(); + } + + public override async ValueTask DisposeAsync() + { + // Don't dispose inner — it may be shared via AddPipeline. + // Dispose next asynchronously — it's part of this pipeline's chain and must be cleaned up. + await _next.DisposeAsync().ConfigureAwait(false); } } diff --git a/src/Resilion/PipelineOfT.cs b/src/Resilion/PipelineOfT.cs index 9372277..8199cd2 100644 --- a/src/Resilion/PipelineOfT.cs +++ b/src/Resilion/PipelineOfT.cs @@ -11,15 +11,17 @@ namespace Resilion; /// /// Pipelines are thread-safe and immutable after construction. Build once, cache, and reuse. /// -public sealed class Pipeline : IDisposable +public sealed class Pipeline : IDisposable, IAsyncDisposable { private readonly PipelineComponent _component; private readonly ResilienceContextPool _pool; + private readonly string? _name; - internal Pipeline(PipelineComponent component, ResilienceContextPool? pool = null) + internal Pipeline(PipelineComponent component, ResilienceContextPool? pool = null, string? name = null) { _component = component; _pool = pool ?? ResilienceContextPool.Shared; + _name = name; } /// @@ -32,6 +34,11 @@ internal Pipeline(PipelineComponent component, ResilienceContextPool? pool = nul /// internal PipelineComponent Component => _component; + /// + /// Gets the optional name of this pipeline, used in telemetry and diagnostics. + /// + internal string? Name => _name; + // ────────────────────────────────────────────────────────────────── // Async execution // ────────────────────────────────────────────────────────────────── @@ -53,6 +60,9 @@ public async ValueTask ExecuteAsync( var context = _pool.Rent(cancellationToken); try { + // Set pipeline name in context for telemetry correlation + context.PipelineName = _name; + var outcome = await _component.ExecuteAsync( async ctx => { @@ -105,6 +115,7 @@ public ValueTask> ExecuteOutcomeAsync( { ArgumentNullException.ThrowIfNull(action); ArgumentNullException.ThrowIfNull(context); + context.PipelineName = _name; return _component.ExecuteAsync( ctx => action(state, ctx), @@ -131,6 +142,8 @@ public TResult Execute( ArgumentNullException.ThrowIfNull(action); var context = _pool.Rent(cancellationToken); context.IsSynchronous = true; + // Set pipeline name in context for telemetry correlation + context.PipelineName = _name; try { var outcome = _component.Execute( @@ -172,4 +185,7 @@ public TResult Execute( /// public void Dispose() => _component.Dispose(); + + /// + public ValueTask DisposeAsync() => _component.DisposeAsync(); } diff --git a/src/Resilion/PublicAPI.Shipped.txt b/src/Resilion/PublicAPI.Shipped.txt new file mode 100644 index 0000000..6e64274 --- /dev/null +++ b/src/Resilion/PublicAPI.Shipped.txt @@ -0,0 +1,4 @@ +# This file tracks the public API surface of Resilion v1.0. +# It will be generated after v1.0 is shipped using PublicApiAnalyzers. +# Until then, it serves as a placeholder to enable the analyzer. +# See: https://github.com/dotnet/roslyn-analyzers/blob/main/src/PublicApiAnalyzers/PublicApiAnalyzers.Help.md diff --git a/src/Resilion/PublicAPI.Unshipped.txt b/src/Resilion/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..45903e5 --- /dev/null +++ b/src/Resilion/PublicAPI.Unshipped.txt @@ -0,0 +1,242 @@ +#nullable enable + +# New public APIs for Resilion (yet to ship). +# Entries here should be moved to PublicAPI.Shipped.txt upon release. + +namespace Resilion; + +CircuitBreakerExtensions +CircuitBreakerExtensions.AddCircuitBreaker(this PipelineBuilder! builder, CircuitBreakerStrategyOptions? options) -> PipelineBuilder! +CircuitBreakerExtensions.AddCircuitBreaker(this PipelineBuilder! builder, CircuitBreakerStrategyOptions! options) -> PipelineBuilder! +CircuitBreakerExtensions.AddCircuitBreaker(this PipelineBuilder! builder, CircuitBreakerStrategyOptions? options) -> PipelineBuilder! + +CircuitBreakerManualControl +CircuitBreakerManualControl.IsolateAsync() -> System.Threading.Tasks.Task! +CircuitBreakerManualControl.ResetAsync() -> System.Threading.Tasks.Task! + +CircuitBreakerStrategyOptions +CircuitBreakerStrategyOptions.FailureRatioThreshold.get -> double +CircuitBreakerStrategyOptions.SamplingDuration.get -> System.TimeSpan +CircuitBreakerStrategyOptions.MinimumThroughput.get -> int +CircuitBreakerStrategyOptions.BreakDuration.get -> System.TimeSpan +CircuitBreakerStrategyOptions.BreakDurationGenerator.get -> System.Func? +CircuitBreakerStrategyOptions.ShouldHandle.get -> System.Func? +CircuitBreakerStrategyOptions.OnOpened.get -> ResilienceEventHandler? +CircuitBreakerStrategyOptions.OnClosed.get -> ResilienceEventHandler? +CircuitBreakerStrategyOptions.OnHalfOpened.get -> ResilienceEventHandler? +CircuitBreakerStrategyOptions.ManualControl.get -> CircuitBreakerManualControl? + +CircuitBreakerStrategyOptions +CircuitBreakerStrategyOptions.FailureRatioThreshold.get -> double +CircuitBreakerStrategyOptions.SamplingDuration.get -> System.TimeSpan +CircuitBreakerStrategyOptions.MinimumThroughput.get -> int +CircuitBreakerStrategyOptions.BreakDuration.get -> System.TimeSpan +CircuitBreakerStrategyOptions.BreakDurationGenerator.get -> System.Func? +CircuitBreakerStrategyOptions.ShouldHandle.get -> System.Func, bool>? +CircuitBreakerStrategyOptions.OnOpened.get -> ResilienceEventHandler? +CircuitBreakerStrategyOptions.OnClosed.get -> ResilienceEventHandler? +CircuitBreakerStrategyOptions.OnHalfOpened.get -> ResilienceEventHandler? +CircuitBreakerStrategyOptions.ManualControl.get -> CircuitBreakerManualControl? + +CircuitBrokenException +CircuitBrokenException.CircuitState.get -> CircuitState +CircuitBrokenException.RetryAfter.get -> System.TimeSpan + +CircuitState +CircuitState.Closed = 0 +CircuitState.Open = 1 +CircuitState.HalfOpen = 2 +CircuitState.Isolated = 3 + +FallbackAction +FallbackAction.HasValue.get -> bool + +FallbackExtensions +FallbackExtensions.AddFallback(this PipelineBuilder! builder, FallbackStrategyOptions! options) -> PipelineBuilder! + +FallbackStrategyOptions +FallbackStrategyOptions.FallbackAction.get -> FallbackAction +FallbackStrategyOptions.ShouldHandle.get -> System.Func, bool>? +FallbackStrategyOptions.OnFallback.get -> ResilienceEventHandler>? + +HedgingExtensions +HedgingExtensions.AddHedging(this PipelineBuilder! builder, HedgingStrategyOptions! options) -> PipelineBuilder! + +HedgingStrategyOptions +HedgingStrategyOptions.Delay.get -> System.TimeSpan +HedgingStrategyOptions.MaxHedgedAttempts.get -> int +HedgingStrategyOptions.ShouldHandle.get -> System.Func, bool>? +HedgingStrategyOptions.OnHedging.get -> ResilienceEventHandler>? + +Outcome +Outcome.IsSuccess.get -> bool +Outcome.IsFailure.get -> bool +Outcome.Exception.get -> System.Exception? +Outcome.Result.get -> TResult +Outcome.TryGetResult(out TResult) -> bool +Outcome.GetResultOrDefault(TResult) -> TResult +Outcome.ThrowIfFailed() -> TResult +Outcome.Match(System.Func!, System.Func!) -> T +Outcome.FromResult(TResult) -> Outcome +Outcome.FromException(System.Exception!) -> Outcome +Outcome.operator ==(Outcome, Outcome) -> bool +Outcome.operator !=(Outcome, Outcome) -> bool +Outcome.operator implicit(TResult) -> Outcome + +Pipeline +Pipeline.Empty.get -> Pipeline! +Pipeline.Create(System.Action!) -> Pipeline! +Pipeline.Create(System.Action>!) -> Pipeline! +Pipeline.ExecuteAsync(System.Func>!, TState, System.Threading.CancellationToken) -> System.Threading.Tasks.ValueTask +Pipeline.ExecuteAsync(System.Func>!, System.Threading.CancellationToken) -> System.Threading.Tasks.ValueTask +Pipeline.ExecuteAsync(System.Func!, System.Threading.CancellationToken) -> System.Threading.Tasks.ValueTask +Pipeline.ExecuteOutcomeAsync(System.Func>>!, TState, ResilienceContext!) -> System.Threading.Tasks.ValueTask> +Pipeline.Execute(System.Func!, TState, System.Threading.CancellationToken) -> TResult +Pipeline.Execute(System.Func!, System.Threading.CancellationToken) -> TResult +Pipeline.Execute(System.Action!, System.Threading.CancellationToken) -> void +Pipeline.Dispose() -> void +Pipeline.DisposeAsync() -> System.Threading.Tasks.ValueTask + +Pipeline +Pipeline.Empty.get -> Pipeline! +Pipeline.ExecuteAsync(System.Func>!, TState, System.Threading.CancellationToken) -> System.Threading.Tasks.ValueTask +Pipeline.ExecuteAsync(System.Func>!, System.Threading.CancellationToken) -> System.Threading.Tasks.ValueTask +Pipeline.ExecuteOutcomeAsync(System.Func>>!, TState, ResilienceContext!) -> System.Threading.Tasks.ValueTask> +Pipeline.Execute(System.Func!, TState, System.Threading.CancellationToken) -> TResult +Pipeline.Execute(System.Func!, System.Threading.CancellationToken) -> TResult +Pipeline.Dispose() -> void +Pipeline.DisposeAsync() -> System.Threading.Tasks.ValueTask + +PipelineBuilder +PipelineBuilder.Name.get -> string? +PipelineBuilder.Name.set -> void +PipelineBuilder.TimeProvider.get -> System.TimeProvider! +PipelineBuilder.TimeProvider.set -> void +PipelineBuilder.SuppressOrderingWarnings.get -> bool +PipelineBuilder.SuppressOrderingWarnings.set -> void +PipelineBuilder.ThrowOnOrderingErrors.get -> bool +PipelineBuilder.ThrowOnOrderingErrors.set -> void +PipelineBuilder.OnValidationWarning.get -> System.Action? +PipelineBuilder.OnValidationWarning.set -> void +PipelineBuilder.AddStrategy(Strategy!) -> PipelineBuilder! +PipelineBuilder.AddPipeline(Pipeline!) -> PipelineBuilder! +PipelineBuilder.Build() -> Pipeline! + +PipelineBuilder +PipelineBuilder.Name.get -> string? +PipelineBuilder.Name.set -> void +PipelineBuilder.TimeProvider.get -> System.TimeProvider! +PipelineBuilder.TimeProvider.set -> void +PipelineBuilder.SuppressOrderingWarnings.get -> bool +PipelineBuilder.SuppressOrderingWarnings.set -> void +PipelineBuilder.ThrowOnOrderingErrors.get -> bool +PipelineBuilder.ThrowOnOrderingErrors.set -> void +PipelineBuilder.OnValidationWarning.get -> System.Action? +PipelineBuilder.OnValidationWarning.set -> void +PipelineBuilder.AddStrategy(Strategy!) -> PipelineBuilder! +PipelineBuilder.AddStrategy(Strategy!) -> PipelineBuilder! +PipelineBuilder.AddStrategy(string!, System.Func>>, System.Threading.Tasks.ValueTask>>!) -> PipelineBuilder! +PipelineBuilder.AddPipeline(Pipeline!) -> PipelineBuilder! +PipelineBuilder.AddPipeline(Pipeline!) -> PipelineBuilder! +PipelineBuilder.Build() -> Pipeline! + +PipelineBuilderBase +PipelineBuilderBase.Name.get -> string? +PipelineBuilderBase.Name.set -> void +PipelineBuilderBase.TimeProvider.get -> System.TimeProvider! +PipelineBuilderBase.TimeProvider.set -> void +PipelineBuilderBase.SuppressOrderingWarnings.get -> bool +PipelineBuilderBase.SuppressOrderingWarnings.set -> void +PipelineBuilderBase.ThrowOnOrderingErrors.get -> bool +PipelineBuilderBase.ThrowOnOrderingErrors.set -> void +PipelineBuilderBase.OnValidationWarning.get -> System.Action? +PipelineBuilderBase.OnValidationWarning.set -> void + +ResilienceContext +ResilienceContext.CancellationToken.get -> System.Threading.CancellationToken +ResilienceContext.CancellationToken.set -> void +ResilienceContext.OperationKey.get -> string? +ResilienceContext.OperationKey.set -> void +ResilienceContext.ContinueOnCapturedContext.get -> bool +ResilienceContext.ContinueOnCapturedContext.set -> void +ResilienceContext.Properties.get -> ResilienceProperties! + +ResilienceContextPool +ResilienceContextPool.Shared.get -> ResilienceContextPool! +ResilienceContextPool.Rent(System.Threading.CancellationToken) -> ResilienceContext! +ResilienceContextPool.Return(ResilienceContext!) -> void + +ResilienceEventHandler +ResilienceEventHandler.Invoke(TEvent, ResilienceContext!) -> System.Threading.Tasks.ValueTask + +ResilienceProperties +ResilienceProperties.Set(ResiliencePropertyKey, TValue) -> void +ResilienceProperties.TryGetValue(ResiliencePropertyKey, out TValue?) -> bool +ResilienceProperties.GetValue(ResiliencePropertyKey, TValue) -> TValue +ResilienceProperties.Remove(ResiliencePropertyKey) -> bool +ResilienceProperties.Count.get -> int + +ResiliencePropertyKey +ResiliencePropertyKey.ResiliencePropertyKey(string!) -> void +ResiliencePropertyKey.Key.get -> string! +ResiliencePropertyKey.Equals(ResiliencePropertyKey) -> bool +ResiliencePropertyKey.operator ==(ResiliencePropertyKey, ResiliencePropertyKey) -> bool +ResiliencePropertyKey.operator !=(ResiliencePropertyKey, ResiliencePropertyKey) -> bool + +ResilionException +ResilionException.ResilionException(string!) -> void +ResilionException.ResilionException(string!, System.Exception!) -> void + +RetryExtensions +RetryExtensions.AddRetry(this PipelineBuilder! builder, RetryStrategyOptions? options) -> PipelineBuilder! +RetryExtensions.AddRetry(this PipelineBuilder! builder, RetryStrategyOptions! options) -> PipelineBuilder! +RetryExtensions.AddRetry(this PipelineBuilder! builder, RetryStrategyOptions? options) -> PipelineBuilder! + +RetryStrategyOptions +RetryStrategyOptions.MaxRetryAttempts.get -> int +RetryStrategyOptions.Delay.get -> RetryDelay! +RetryStrategyOptions.UseJitter.get -> bool +RetryStrategyOptions.MaxDelay.get -> System.TimeSpan? +RetryStrategyOptions.ShouldHandle.get -> System.Func? +RetryStrategyOptions.OnRetry.get -> ResilienceEventHandler? + +RetryStrategyOptions +RetryStrategyOptions.MaxRetryAttempts.get -> int +RetryStrategyOptions.Delay.get -> RetryDelay! +RetryStrategyOptions.UseJitter.get -> bool +RetryStrategyOptions.MaxDelay.get -> System.TimeSpan? +RetryStrategyOptions.ShouldHandle.get -> System.Func, bool>? +RetryStrategyOptions.OnRetry.get -> ResilienceEventHandler? + +RetryDelay +RetryDelay.Constant(System.TimeSpan) -> RetryDelay! +RetryDelay.Linear(System.TimeSpan, System.TimeSpan) -> RetryDelay! +RetryDelay.Exponential(System.TimeSpan) -> RetryDelay! +RetryDelay.Exponential(System.TimeSpan, System.TimeSpan) -> RetryDelay! +RetryDelay.Custom(System.Func!) -> RetryDelay! + +Strategy +Strategy.ExecuteAsync(System.Func>>!, ResilienceContext!) -> System.Threading.Tasks.ValueTask> +Strategy.Execute(System.Func>!, ResilienceContext!) -> Outcome +Strategy.Dispose() -> void +Strategy.DisposeAsync() -> System.Threading.Tasks.ValueTask + +Strategy +Strategy.ExecuteAsync(System.Func>>!, ResilienceContext!) -> System.Threading.Tasks.ValueTask> +Strategy.Execute(System.Func>!, ResilienceContext!) -> Outcome +Strategy.Dispose() -> void +Strategy.DisposeAsync() -> System.Threading.Tasks.ValueTask + +TimeoutExtensions +TimeoutExtensions.AddTimeout(this PipelineBuilder! builder, System.TimeSpan timeout) -> PipelineBuilder! +TimeoutExtensions.AddTimeout(this PipelineBuilder! builder, TimeoutStrategyOptions? options) -> PipelineBuilder! +TimeoutExtensions.AddTimeout(this PipelineBuilder! builder, System.TimeSpan timeout) -> PipelineBuilder! +TimeoutExtensions.AddTimeout(this PipelineBuilder! builder, TimeoutStrategyOptions? options) -> PipelineBuilder! + +TimeoutRejectedException +TimeoutRejectedException.ConfiguredTimeout.get -> System.TimeSpan +TimeoutRejectedException.ElapsedTime.get -> System.TimeSpan + +TimeoutStrategyOptions +TimeoutStrategyOptions.Timeout.get -> System.TimeSpan +TimeoutStrategyOptions.Timeout.set -> void diff --git a/src/Resilion/ResilienceContext.cs b/src/Resilion/ResilienceContext.cs index ebb7384..551be28 100644 --- a/src/Resilion/ResilienceContext.cs +++ b/src/Resilion/ResilienceContext.cs @@ -39,6 +39,12 @@ internal ResilienceContext() /// public string? OperationKey { get; set; } + /// + /// Gets or sets the name of the pipeline executing this operation, used for telemetry correlation. + /// Set automatically by during execution. + /// + internal string? PipelineName { get; set; } + /// /// Gets or sets a value indicating whether async continuations should run on the captured /// synchronization context. Defaults to false (library-friendly behavior). @@ -67,6 +73,7 @@ internal void Reset() { CancellationToken = default; OperationKey = null; + PipelineName = null; ContinueOnCapturedContext = false; IsSynchronous = false; Properties.Clear(); diff --git a/src/Resilion/ResilienceContextPool.cs b/src/Resilion/ResilienceContextPool.cs index fc29ba9..40d0875 100644 --- a/src/Resilion/ResilienceContextPool.cs +++ b/src/Resilion/ResilienceContextPool.cs @@ -17,11 +17,29 @@ public sealed class ResilienceContextPool // Cap the pool size to avoid unbounded memory growth under burst traffic. // 256 is generous — most applications use far fewer concurrent pipeline executions. - private const int MaxPoolSize = 256; + private readonly int _maxPoolSize; // Tracks pool count with Interlocked to avoid O(n) ConcurrentBag.Count enumeration. private int _count; + /// + /// Creates a new pool with the specified maximum size. + /// + /// + /// The maximum number of contexts retained in the pool. Defaults to 256. Excess returned + /// contexts beyond this cap are discarded rather than pooled. + /// + public ResilienceContextPool(int maxPoolSize = 256) + { + if (maxPoolSize < 1) + { + throw new ArgumentOutOfRangeException(nameof(maxPoolSize), maxPoolSize, + "maxPoolSize must be >= 1."); + } + + _maxPoolSize = maxPoolSize; + } + /// /// Gets the shared default pool instance. Use this unless you have a specific reason /// to create a separate pool. @@ -59,7 +77,7 @@ public void Return(ResilienceContext context) context.Reset(); // Don't let the pool grow without bound. Use Interlocked counter to avoid O(n) ConcurrentBag.Count. - if (Interlocked.Increment(ref _count) <= MaxPoolSize) + if (Interlocked.Increment(ref _count) <= _maxPoolSize) { _pool.Add(context); } diff --git a/src/Resilion/ResilienceEventHandler.cs b/src/Resilion/ResilienceEventHandler.cs index f061441..993297a 100644 --- a/src/Resilion/ResilienceEventHandler.cs +++ b/src/Resilion/ResilienceEventHandler.cs @@ -62,6 +62,12 @@ internal ValueTask InvokeAsync(TArgs args) /// Invokes the handler synchronously. If the handler is async, blocks until completion. /// /// The event arguments. + /// + /// For a genuinely async handler under load with a present + /// (WPF, WinForms, legacy ASP.NET), this costs two threads per invocation — one ThreadPool + /// thread running the handler plus the calling thread blocked on it. Prefer + /// from an async execution path when possible. + /// internal void Invoke(TArgs args) { if (_syncHandler is not null) @@ -72,10 +78,18 @@ internal void Invoke(TArgs args) if (_asyncHandler is not null) { - // Copy to local to avoid struct 'this' capture in lambda. - // Run on the thread pool to avoid deadlocking when a SynchronizationContext - // is present (WPF, WinForms). var handler = _asyncHandler; + + if (SynchronizationContext.Current is null) + { + // No context to deadlock against: call directly. Completes synchronously with + // no thread hop in the common case (e.g. a handler that checks a cached value). + handler(args).GetAwaiter().GetResult(); + return; + } + + // A SynchronizationContext is present — run the handler on the thread pool so its + // continuations resume there instead of trying to resume on this (now blocked) thread. Task.Run(() => handler(args).AsTask()).GetAwaiter().GetResult(); } } diff --git a/src/Resilion/Retry/RetryDelay.cs b/src/Resilion/Retry/RetryDelay.cs index 7b1b60a..fb9dd94 100644 --- a/src/Resilion/Retry/RetryDelay.cs +++ b/src/Resilion/Retry/RetryDelay.cs @@ -21,7 +21,12 @@ private RetryDelay() { } /// Creates a constant delay (same duration for every retry). /// /// The fixed delay between retries. - public static RetryDelay Constant(TimeSpan delay) => new ConstantDelay(delay); + public static RetryDelay Constant(TimeSpan delay) + { + if (delay < TimeSpan.Zero) + throw new ArgumentException("Delay cannot be negative.", nameof(delay)); + return new ConstantDelay(delay); + } /// /// Creates a linearly increasing delay (baseDelay × attemptNumber). @@ -29,7 +34,13 @@ private RetryDelay() { } /// The base delay that scales linearly. /// Optional cap on the delay. Defaults to no cap. public static RetryDelay Linear(TimeSpan baseDelay, TimeSpan? maxDelay = null) - => new LinearDelay(baseDelay, maxDelay); + { + if (baseDelay < TimeSpan.Zero) + throw new ArgumentException("Base delay cannot be negative.", nameof(baseDelay)); + if (maxDelay.HasValue && maxDelay.Value < TimeSpan.Zero) + throw new ArgumentException("Max delay cannot be negative.", nameof(maxDelay)); + return new LinearDelay(baseDelay, maxDelay); + } /// /// Creates an exponentially increasing delay (baseDelay × 2^attemptNumber). @@ -37,7 +48,13 @@ public static RetryDelay Linear(TimeSpan baseDelay, TimeSpan? maxDelay = null) /// The base delay for the first retry. /// Optional cap on the delay. Defaults to 30 seconds. public static RetryDelay Exponential(TimeSpan baseDelay, TimeSpan? maxDelay = null) - => new ExponentialDelay(baseDelay, maxDelay ?? TimeSpan.FromSeconds(30)); + { + if (baseDelay < TimeSpan.Zero) + throw new ArgumentException("Base delay cannot be negative.", nameof(baseDelay)); + if (maxDelay.HasValue && maxDelay.Value < TimeSpan.Zero) + throw new ArgumentException("Max delay cannot be negative.", nameof(maxDelay)); + return new ExponentialDelay(baseDelay, maxDelay ?? TimeSpan.FromSeconds(30)); + } /// /// Creates a fully custom delay computation. diff --git a/src/Resilion/Retry/RetryStrategy.cs b/src/Resilion/Retry/RetryStrategy.cs index 70169c1..b88f8d2 100644 --- a/src/Resilion/Retry/RetryStrategy.cs +++ b/src/Resilion/Retry/RetryStrategy.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace Resilion; /// @@ -18,6 +20,14 @@ protected internal override async ValueTask> ExecuteAsync>> callback, ResilienceContext context) { + using var activity = ResilionTelemetry.ActivitySource.StartActivity("Retry"); + if (activity is not null) + { + activity.SetTag("strategy.name", "Retry"); + activity.SetTag("pipeline.name", context.PipelineName); + activity.SetTag("operation.key", context.OperationKey); + } + if (_options.MaxRetryAttempts == 0) { return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); @@ -35,12 +45,20 @@ protected internal override async ValueTask> ExecuteAsync> ExecuteAsync _options.MaxDelay.Value) + { + delay = _options.MaxDelay.Value; + } - ResilionTelemetry.RetryAttempts.Add(1); + ResilionTelemetry.RetryAttempts.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); // Fire OnRetry callback. if (_options.OnRetry is { } handler && handler.HasHandler) @@ -72,6 +94,11 @@ await Task.Delay(delay, _timeProvider, context.CancellationToken) } } + if (activity is not null) + { + activity.SetTag("outcome", "retry_exhausted"); + } + return outcome; } @@ -79,6 +106,14 @@ protected internal override Outcome Execute( Func> callback, ResilienceContext context) { + using var activity = ResilionTelemetry.ActivitySource.StartActivity("Retry"); + if (activity is not null) + { + activity.SetTag("strategy.name", "Retry"); + activity.SetTag("pipeline.name", context.PipelineName); + activity.SetTag("operation.key", context.OperationKey); + } + if (_options.MaxRetryAttempts == 0) { return callback(context); @@ -94,11 +129,19 @@ protected internal override Outcome Execute( if (outcome.IsSuccess) { + if (activity is not null) + { + activity.SetTag("outcome", "success"); + } return outcome; } if (!_options.ShouldHandleException(outcome.Exception!)) { + if (activity is not null) + { + activity.SetTag("outcome", "failure"); + } return outcome; } @@ -109,8 +152,12 @@ protected internal override Outcome Execute( var retryNumber = attempt + 1; var delay = _options.Delay.ComputeDelay(retryNumber, _options.UseJitter); + if (_options.MaxDelay.HasValue && delay > _options.MaxDelay.Value) + { + delay = _options.MaxDelay.Value; + } - ResilionTelemetry.RetryAttempts.Add(1); + ResilionTelemetry.RetryAttempts.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); if (_options.OnRetry is { } handler && handler.HasHandler) { @@ -125,6 +172,11 @@ protected internal override Outcome Execute( } } + if (activity is not null) + { + activity.SetTag("outcome", "retry_exhausted"); + } + return outcome; } } @@ -173,8 +225,12 @@ protected internal override async ValueTask> ExecuteAsync( var retryNumber = attempt + 1; var delay = _options.Delay.ComputeDelay(retryNumber, _options.UseJitter); + if (_options.MaxDelay.HasValue && delay > _options.MaxDelay.Value) + { + delay = _options.MaxDelay.Value; + } - ResilionTelemetry.RetryAttempts.Add(1); + ResilionTelemetry.RetryAttempts.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); if (_options.OnRetry is { } handler && handler.HasHandler) { @@ -222,8 +278,12 @@ protected internal override Outcome Execute( var retryNumber = attempt + 1; var delay = _options.Delay.ComputeDelay(retryNumber, _options.UseJitter); + if (_options.MaxDelay.HasValue && delay > _options.MaxDelay.Value) + { + delay = _options.MaxDelay.Value; + } - ResilionTelemetry.RetryAttempts.Add(1); + ResilionTelemetry.RetryAttempts.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); if (_options.OnRetry is { } handler && handler.HasHandler) { diff --git a/src/Resilion/Retry/RetryStrategyOptions.cs b/src/Resilion/Retry/RetryStrategyOptions.cs index 6b68692..29ab737 100644 --- a/src/Resilion/Retry/RetryStrategyOptions.cs +++ b/src/Resilion/Retry/RetryStrategyOptions.cs @@ -1,3 +1,5 @@ +using Resilion.Internal; + namespace Resilion; /// @@ -23,6 +25,14 @@ public sealed record RetryStrategyOptions /// public bool UseJitter { get; init; } = true; + /// + /// Gets an optional global cap on the computed retry delay, applied after + /// computes it (including any per-strategy cap it already has, e.g. RetryDelay.Exponential's + /// own maxDelay). Useful as a safety net for a delegate + /// that could otherwise return an unbounded delay. + /// + public TimeSpan? MaxDelay { get; init; } + /// /// Gets the predicate that determines which exceptions trigger a retry. /// Defaults to all exceptions except . @@ -41,6 +51,12 @@ internal void Validate() throw new ArgumentOutOfRangeException(nameof(MaxRetryAttempts), MaxRetryAttempts, "MaxRetryAttempts must be >= 0."); } + + if (MaxDelay.HasValue && MaxDelay.Value < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(MaxDelay), MaxDelay, + "MaxDelay must not be negative."); + } } internal bool ShouldHandleException(Exception exception) @@ -76,6 +92,9 @@ public sealed record RetryStrategyOptions /// public bool UseJitter { get; init; } = true; + /// + public TimeSpan? MaxDelay { get; init; } + /// /// Gets the predicate that determines which outcomes (exceptions or results) trigger a retry. /// Receives the to enable both exception and result-based decisions. @@ -94,6 +113,12 @@ internal void Validate() throw new ArgumentOutOfRangeException(nameof(MaxRetryAttempts), MaxRetryAttempts, "MaxRetryAttempts must be >= 0."); } + + if (MaxDelay.HasValue && MaxDelay.Value < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(MaxDelay), MaxDelay, + "MaxDelay must not be negative."); + } } internal bool ShouldHandleOutcome(Outcome outcome) @@ -105,7 +130,7 @@ internal bool ShouldHandleOutcome(Outcome outcome) } // Default: handle any exception except OperationCanceledException. - return outcome.Exception is not null and not OperationCanceledException; + return OutcomePredicates.DefaultShouldHandle(outcome); } } diff --git a/src/Resilion/Strategy.cs b/src/Resilion/Strategy.cs index 399446a..849e248 100644 --- a/src/Resilion/Strategy.cs +++ b/src/Resilion/Strategy.cs @@ -15,7 +15,7 @@ namespace Resilion; /// result-based Retry), use instead. /// /// -public abstract class Strategy : IDisposable +public abstract class Strategy : IDisposable, IAsyncDisposable { /// /// Executes the resilience logic around the given callback. @@ -39,8 +39,18 @@ protected internal virtual Outcome Execute( Func> callback, ResilienceContext context) { - // Default: delegate to async path synchronously. - // Strategies that support true sync execution should override this method. + // Default: delegate to async path synchronously. Strategies that support true sync + // execution should override this method. All built-in strategies do; this default only + // runs for custom strategies deriving from Strategy that haven't overridden Execute(). + if (SynchronizationContext.Current is not null) + { + throw new InvalidOperationException( + $"'{GetType().Name}' does not override {nameof(Execute)}() and cannot be used " + + "synchronously while a SynchronizationContext is present (WPF, WinForms, legacy " + + $"ASP.NET) — blocking here could deadlock. Override {nameof(Execute)}() with a " + + $"true synchronous implementation, or call {nameof(ExecuteAsync)}() instead."); + } + return ExecuteAsync( ctx => new ValueTask>(callback(ctx)), context).GetAwaiter().GetResult(); @@ -50,6 +60,16 @@ protected internal virtual Outcome Execute( public virtual void Dispose() { } + + /// + /// Asynchronously disposes this strategy. The default implementation calls . + /// Override this when a strategy holds async-disposable resources. + /// + public virtual ValueTask DisposeAsync() + { + Dispose(); + return default; + } } /// @@ -66,7 +86,7 @@ public virtual void Dispose() /// For strategies that don't need to inspect results (Timeout, RateLimiter), use instead. /// /// -public abstract class Strategy : IDisposable +public abstract class Strategy : IDisposable, IAsyncDisposable { /// /// Executes the resilience logic around the given callback. @@ -88,8 +108,18 @@ protected internal virtual Outcome Execute( Func> callback, ResilienceContext context) { - // Default: delegate to async path synchronously. - // Strategies that support true sync execution should override this method. + // Default: delegate to async path synchronously. Strategies that support true sync + // execution should override this method. All built-in strategies do; this default only + // runs for custom strategies deriving from Strategy that haven't overridden Execute(). + if (SynchronizationContext.Current is not null) + { + throw new InvalidOperationException( + $"'{GetType().Name}' does not override {nameof(Execute)}() and cannot be used " + + "synchronously while a SynchronizationContext is present (WPF, WinForms, legacy " + + $"ASP.NET) — blocking here could deadlock. Override {nameof(Execute)}() with a " + + $"true synchronous implementation, or call {nameof(ExecuteAsync)}() instead."); + } + return ExecuteAsync( ctx => new ValueTask>(callback(ctx)), context).GetAwaiter().GetResult(); @@ -99,4 +129,14 @@ protected internal virtual Outcome Execute( public virtual void Dispose() { } + + /// + /// Asynchronously disposes this strategy. The default implementation calls . + /// Override this when a strategy holds async-disposable resources. + /// + public virtual ValueTask DisposeAsync() + { + Dispose(); + return default; + } } diff --git a/src/Resilion/Telemetry/ResilionTelemetry.cs b/src/Resilion/Telemetry/ResilionTelemetry.cs index bc26025..3bea7de 100644 --- a/src/Resilion/Telemetry/ResilionTelemetry.cs +++ b/src/Resilion/Telemetry/ResilionTelemetry.cs @@ -21,14 +21,19 @@ public static class ResilionTelemetry internal static readonly Meter Meter = new(MeterName); internal static readonly ActivitySource ActivitySource = new(ActivitySourceName); - internal static readonly Counter StrategyExecutions = Meter.CreateCounter( - "resilion.strategy.executions", - description: "Total strategy executions."); - - internal static readonly Histogram StrategyDuration = Meter.CreateHistogram( - "resilion.strategy.duration", - unit: "s", - description: "Strategy execution duration in seconds."); + /// + /// Tag key for the pipeline name in telemetry events. + /// + public const string PipelineNameTag = "pipeline.name"; + + /// + /// Tag key for the operation key in telemetry events. + /// + public const string OperationKeyTag = "operation.key"; + + // Pipeline-level execution count and duration instruments are planned for a future + // telemetry-wrapping component. They are intentionally omitted here rather than declared + // unused — the per-strategy counters below are the only instruments this meter emits today. internal static readonly Counter RetryAttempts = Meter.CreateCounter( "resilion.retry.attempts", diff --git a/src/Resilion/Timeout/TimeoutStrategy.cs b/src/Resilion/Timeout/TimeoutStrategy.cs index 647925a..c30ea8d 100644 --- a/src/Resilion/Timeout/TimeoutStrategy.cs +++ b/src/Resilion/Timeout/TimeoutStrategy.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace Resilion; /// @@ -18,10 +20,22 @@ protected internal override async ValueTask> ExecuteAsync>> callback, ResilienceContext context) { + using var activity = ResilionTelemetry.ActivitySource.StartActivity("Timeout"); + if (activity is not null) + { + activity.SetTag("strategy.name", "Timeout"); + activity.SetTag("pipeline.name", context.PipelineName); + activity.SetTag("operation.key", context.OperationKey); + } + var timeout = ResolveTimeout(context); if (timeout == System.Threading.Timeout.InfiniteTimeSpan) { + if (activity is not null) + { + activity.SetTag("outcome", "no_timeout"); + } return await callback(context).ConfigureAwait(context.ContinueOnCapturedContext); } @@ -61,15 +75,29 @@ protected internal override async ValueTask> ExecuteAsync(context, timeout, elapsed, oce).ConfigureAwait(false); + var result = await HandleTimeout(context, timeout, elapsed, oce).ConfigureAwait(false); + if (activity is not null) + { + activity.SetTag("outcome", "timeout"); + } + return result; } + if (activity is not null) + { + activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); + } return outcome; } catch (OperationCanceledException oce) when (WasCancelledByTimeout(linkedCts, previousToken)) { var elapsed = _timeProvider.GetElapsedTime(startTimestamp); - return await HandleTimeout(context, timeout, elapsed, oce).ConfigureAwait(false); + var result = await HandleTimeout(context, timeout, elapsed, oce).ConfigureAwait(false); + if (activity is not null) + { + activity.SetTag("outcome", "timeout"); + } + return result; } } finally @@ -89,10 +117,22 @@ protected internal override Outcome Execute( Func> callback, ResilienceContext context) { + using var activity = ResilionTelemetry.ActivitySource.StartActivity("Timeout"); + if (activity is not null) + { + activity.SetTag("strategy.name", "Timeout"); + activity.SetTag("pipeline.name", context.PipelineName); + activity.SetTag("operation.key", context.OperationKey); + } + var timeout = ResolveTimeout(context); if (timeout == System.Threading.Timeout.InfiniteTimeSpan) { + if (activity is not null) + { + activity.SetTag("outcome", "no_timeout"); + } return callback(context); } @@ -130,16 +170,28 @@ protected internal override Outcome Execute( { var elapsed = _timeProvider.GetElapsedTime(startTimestamp); HandleTimeoutSync(context, timeout, elapsed); + if (activity is not null) + { + activity.SetTag("outcome", "timeout"); + } return Outcome.FromException( new TimeoutRejectedException(timeout, elapsed, oce)); } + if (activity is not null) + { + activity.SetTag("outcome", outcome.IsSuccess ? "success" : "failure"); + } return outcome; } catch (OperationCanceledException oce) when (WasCancelledByTimeout(linkedCts, previousToken)) { var elapsed = _timeProvider.GetElapsedTime(startTimestamp); HandleTimeoutSync(context, timeout, elapsed); + if (activity is not null) + { + activity.SetTag("outcome", "timeout"); + } return Outcome.FromException( new TimeoutRejectedException(timeout, elapsed, oce)); } @@ -182,7 +234,7 @@ private async ValueTask> HandleTimeout( TimeSpan elapsed, Exception originalException) { - ResilionTelemetry.TimeoutExpirations.Add(1); + ResilionTelemetry.TimeoutExpirations.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); if (_options.OnTimeout is { } handler && handler.HasHandler) { @@ -198,7 +250,7 @@ private void HandleTimeoutSync( TimeSpan timeout, TimeSpan elapsed) { - ResilionTelemetry.TimeoutExpirations.Add(1); + ResilionTelemetry.TimeoutExpirations.Add(1, new(ResilionTelemetry.PipelineNameTag, context.PipelineName), new(ResilionTelemetry.OperationKeyTag, context.OperationKey)); if (_options.OnTimeout is { } handler && handler.HasHandler) { diff --git a/tests/Resilion.Extensions.Tests/ExtensionsTests.cs b/tests/Resilion.Extensions.Tests/ExtensionsTests.cs index 2debf9d..2a08f79 100644 --- a/tests/Resilion.Extensions.Tests/ExtensionsTests.cs +++ b/tests/Resilion.Extensions.Tests/ExtensionsTests.cs @@ -27,6 +27,41 @@ public void GetPipeline_UnregisteredKey_Throws() Assert.Throws(() => registry.GetPipeline("missing")); } + [Fact] + public void GetPipeline_FailedLookupThenRegisterAndRetry_Succeeds() + { + // Regression test: Ensure failed lookups don't cache a faulted Lazy entry + using var registry = new ResiliencePipelineRegistry(); + + // First attempt: unregistered key should throw + Assert.Throws(() => registry.GetPipeline("retry-cache")); + + // Register the pipeline after the failed attempt + registry.RegisterPipeline("retry-cache", b => b.AddRetry()); + + // Second attempt: should now succeed instead of replaying the cached failure + var pipeline = registry.GetPipeline("retry-cache"); + Assert.NotNull(pipeline); + } + + [Fact] + public void GetPipeline_Typed_FailedLookupThenRegisterAndRetry_Succeeds() + { + // Regression test: Ensure typed lookups don't cache a faulted Lazy entry + using var registry = new ResiliencePipelineRegistry(); + + // First attempt: unregistered typed key should throw + Assert.Throws(() => registry.GetPipeline("typed-retry-cache")); + + // Register the typed pipeline after the failed attempt + registry.RegisterPipeline("typed-retry-cache", b => + b.AddFallback(new FallbackStrategyOptions { FallbackAction = 42 })); + + // Second attempt: should now succeed instead of replaying the cached failure + var pipeline = registry.GetPipeline("typed-retry-cache"); + Assert.NotNull(pipeline); + } + [Fact] public void RegisterDuplicateKey_Throws() { @@ -116,10 +151,10 @@ public void Dispose_DisposesCreatedPipelines() public class ServiceCollectionExtensionsTests { [Fact] - public void AddResilion_RegistersRegistry() + public void AddResilienceServices_RegistersRegistry() { var services = new ServiceCollection(); - services.AddResilion(); + services.AddResilienceServices(); var sp = services.BuildServiceProvider(); var registry = sp.GetService>(); @@ -128,10 +163,10 @@ public void AddResilion_RegistersRegistry() } [Fact] - public void AddResilion_RegistersContextPool() + public void AddResilienceServices_RegistersContextPool() { var services = new ServiceCollection(); - services.AddResilion(); + services.AddResilienceServices(); var sp = services.BuildServiceProvider(); var pool = sp.GetService(); @@ -140,6 +175,33 @@ public void AddResilion_RegistersContextPool() Assert.Same(ResilienceContextPool.Shared, pool); } + [Fact] + public void AddResilienceServices_RegistersPipelineProviderAsSameInstanceAsRegistry() + { + var services = new ServiceCollection(); + services.AddResilienceServices(); + + var sp = services.BuildServiceProvider(); + var registry = sp.GetRequiredService>(); + var provider = sp.GetRequiredService>(); + + Assert.Same(registry, provider); + } + +#pragma warning disable CS0618 // Testing the obsolete alias intentionally + [Fact] + public void AddResilion_ObsoleteAlias_StillRegistersServices() + { + var services = new ServiceCollection(); + services.AddResilion(); + + var sp = services.BuildServiceProvider(); + + Assert.NotNull(sp.GetService>()); + Assert.NotNull(sp.GetService>()); + } +#pragma warning restore CS0618 + [Fact] public async Task AddResiliencePipeline_ResolvesViaRegistry() { @@ -182,12 +244,51 @@ public async Task AddResiliencePipeline_Typed_ResolvesViaRegistry() } [Fact] - public void AddResilion_Idempotent() + public async Task AddResiliencePipeline_DiRoundTrip_ResolvesViaPipelineProvider() { var services = new ServiceCollection(); - services.AddResilion(); - services.AddResilion(); - services.AddResilion(); + services.AddResiliencePipeline("http-retry", b => b + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 1, Delay = RetryDelay.None }) + .AddTimeout(TimeSpan.FromSeconds(30))); + + var sp = services.BuildServiceProvider(); + + // Resolve through the full DI container — no manual BuildRegistry call. + var provider = sp.GetRequiredService>(); + var pipeline = provider.GetPipeline("http-retry"); + + var result = await pipeline.ExecuteAsync(ct => new ValueTask("ok")); + Assert.Equal("ok", result); + } + + [Fact] + public async Task AddResiliencePipeline_Typed_DiRoundTrip_ResolvesViaPipelineProvider() + { + var services = new ServiceCollection(); + services.AddResiliencePipeline("fallback-pipeline", b => b + .AddFallback(new FallbackStrategyOptions { FallbackAction = 42 })); + + var sp = services.BuildServiceProvider(); + + var provider = sp.GetRequiredService>(); + var pipeline = provider.GetPipeline("fallback-pipeline"); + + var result = await pipeline.ExecuteAsync(ct => + { + throw new Exception("fail"); + return new ValueTask(0); + }); + + Assert.Equal(42, result); + } + + [Fact] + public void AddResilienceServices_Idempotent() + { + var services = new ServiceCollection(); + services.AddResilienceServices(); + services.AddResilienceServices(); + services.AddResilienceServices(); var sp = services.BuildServiceProvider(); var registries = sp.GetServices>().ToList(); diff --git a/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerStrategyTests.cs b/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerStrategyTests.cs index c44c341..7bd5c66 100644 --- a/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerStrategyTests.cs +++ b/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerStrategyTests.cs @@ -428,4 +428,60 @@ public async Task Exception_StillPropagates_WhenCountedAsFailure() Assert.Equal("should propagate", ex.Message); } + + // ────────────────────────────────────────────────────────────────── + // BreakDurationGenerator — dynamic break duration per trip + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task BreakDurationGenerator_ComputesIncreasingDurationPerTrip() + { + var fakeTime = new FakeTimeProvider(); + var capturedArgs = new List(); + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + BreakDuration = TimeSpan.FromSeconds(1), + BreakDurationGenerator = args => + { + capturedArgs.Add(args); + return TimeSpan.FromSeconds(args.FailureCount * 2); // 2s, then 4s + }, + }); + }); + + // First trip. + for (var i = 0; i < 2; i++) + { + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => throw new InvalidOperationException("fail")).AsTask()); + } + + var firstReject = await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(1)).AsTask()); + + Assert.Single(capturedArgs); + Assert.Equal(1, capturedArgs[0].FailureCount); + Assert.Equal(TimeSpan.FromSeconds(1), capturedArgs[0].CurrentBreakDuration); + // The generator's 2s result is used, not the static 1s BreakDuration. + Assert.True(firstReject.RetryAfter > TimeSpan.FromSeconds(1)); + + // Not yet past the generated 2s — still rejecting. + fakeTime.Advance(TimeSpan.FromSeconds(1.5)); + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(1)).AsTask()); + + // Past 2s total — probe is allowed through and fails, tripping a second time. + fakeTime.Advance(TimeSpan.FromSeconds(1)); + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => throw new InvalidOperationException("fail again")).AsTask()); + + Assert.Equal(2, capturedArgs.Count); + Assert.Equal(2, capturedArgs[1].FailureCount); + } } diff --git a/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerTypedStrategyTests.cs b/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerTypedStrategyTests.cs new file mode 100644 index 0000000..769e7b7 --- /dev/null +++ b/tests/Resilion.Tests/CircuitBreaker/CircuitBreakerTypedStrategyTests.cs @@ -0,0 +1,323 @@ +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Resilion.Tests; + +/// +/// Comprehensive coverage for the typed (result-based) circuit breaker. Mirrors +/// but exercises the Strategy<TResult> path +/// and result-based predicates, including a regression test for the race condition fixed when +/// CircuitBreakerTypedStrategy<T> was moved onto the shared CircuitBreakerStateMachine. +/// +public class CircuitBreakerTypedStrategyTests +{ + private static bool IsBadResult(Outcome outcome) + => outcome.TryGetResult(out var value) && value < 0; + + // ────────────────────────────────────────────────────────────────── + // Closed state — happy path + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Closed_GoodResults_StayClosed() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 4, + ShouldHandle = IsBadResult, + })); + + for (var i = 0; i < 20; i++) + { + var result = await pipeline.ExecuteAsync(ct => new ValueTask(42)); + Assert.Equal(42, result); + } + } + + [Fact] + public void Sync_Closed_GoodResults_StayClosed() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 4, + ShouldHandle = IsBadResult, + })); + + for (var i = 0; i < 20; i++) + { + var result = pipeline.Execute(ct => 42); + Assert.Equal(42, result); + } + } + + // ────────────────────────────────────────────────────────────────── + // Closed → Open on result-based predicate + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Closed_ExceedsFailureRatio_TripsToOpen() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 4, + BreakDuration = TimeSpan.FromSeconds(5), + ShouldHandle = IsBadResult, + })); + + for (var i = 0; i < 4; i++) + { + await pipeline.ExecuteAsync(ct => new ValueTask(-1)); + } + + var ex = await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(42)).AsTask()); + + Assert.Equal(CircuitState.Open, ex.CircuitState); + Assert.True(ex.RetryAfter > TimeSpan.Zero); + } + + [Fact] + public async Task Closed_BelowMinimumThroughput_DoesNotTrip() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 4, + ShouldHandle = IsBadResult, + })); + + for (var i = 0; i < 3; i++) + { + await pipeline.ExecuteAsync(ct => new ValueTask(-1)); + } + + var result = await pipeline.ExecuteAsync(ct => new ValueTask(42)); + Assert.Equal(42, result); + } + + [Fact] + public async Task Closed_BelowFailureRatio_DoesNotTrip() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 4, + ShouldHandle = IsBadResult, + })); + + await pipeline.ExecuteAsync(ct => new ValueTask(-1)); + for (var i = 0; i < 3; i++) + { + await pipeline.ExecuteAsync(ct => new ValueTask(1)); + } + + var result = await pipeline.ExecuteAsync(ct => new ValueTask(99)); + Assert.Equal(99, result); + } + + // ────────────────────────────────────────────────────────────────── + // Full recovery cycle with all three callbacks + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Open_AfterBreakDuration_TransitionsToHalfOpen_ThenClosed() + { + var fakeTime = new FakeTimeProvider(); + var opened = new List(); + var halfOpened = new List(); + var closed = new List(); + Action onOpened = e => opened.Add(e); + Action onHalfOpened = e => halfOpened.Add(e); + Action onClosed = e => closed.Add(e); + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + BreakDuration = TimeSpan.FromSeconds(5), + ShouldHandle = IsBadResult, + OnOpened = onOpened, + OnHalfOpened = onHalfOpened, + OnClosed = onClosed, + }); + }); + + // Trip the circuit. + for (var i = 0; i < 2; i++) + { + await pipeline.ExecuteAsync(ct => new ValueTask(-1)); + } + + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(1)).AsTask()); + + Assert.Single(opened); + Assert.Equal(CircuitState.Closed, opened[0].PreviousState); + Assert.Equal(CircuitState.Open, opened[0].CurrentState); + + // Advance past break duration. + fakeTime.Advance(TimeSpan.FromSeconds(6)); + + // Probe succeeds. + var result = await pipeline.ExecuteAsync(ct => new ValueTask(42)); + Assert.Equal(42, result); + + Assert.Single(halfOpened); + Assert.Equal(CircuitState.Open, halfOpened[0].PreviousState); + Assert.Equal(CircuitState.HalfOpen, halfOpened[0].CurrentState); + + Assert.Single(closed); + Assert.Equal(CircuitState.HalfOpen, closed[0].PreviousState); + Assert.Equal(CircuitState.Closed, closed[0].CurrentState); + } + + // ────────────────────────────────────────────────────────────────── + // HalfOpen probe fails → back to Open + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task HalfOpen_ProbeFails_TransitionsBackToOpen() + { + var fakeTime = new FakeTimeProvider(); + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + BreakDuration = TimeSpan.FromSeconds(5), + ShouldHandle = IsBadResult, + }); + }); + + for (var i = 0; i < 2; i++) + { + await pipeline.ExecuteAsync(ct => new ValueTask(-1)); + } + + fakeTime.Advance(TimeSpan.FromSeconds(6)); + + // Probe fails again. + await pipeline.ExecuteAsync(ct => new ValueTask(-1)); + + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(1)).AsTask()); + } + + // ────────────────────────────────────────────────────────────────── + // Manual control + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ManualControl_IsolateThenReset_RoundTrips() + { + var control = new CircuitBreakerManualControl(); + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + ManualControl = control, + ShouldHandle = IsBadResult, + })); + + var result = await pipeline.ExecuteAsync(ct => new ValueTask(1)); + Assert.Equal(1, result); + + await control.IsolateAsync(); + var ex = await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(1)).AsTask()); + Assert.Equal(CircuitState.Isolated, ex.CircuitState); + + await control.ResetAsync(); + result = await pipeline.ExecuteAsync(ct => new ValueTask(2)); + Assert.Equal(2, result); + } + + // ────────────────────────────────────────────────────────────────── + // Concurrent access — no exceptions, ratio stays valid + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task ConcurrentAccess_NoExceptionsOrCorruption() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.8, + MinimumThroughput = 100, + SamplingDuration = TimeSpan.FromSeconds(60), + BreakDuration = TimeSpan.FromSeconds(1), + ShouldHandle = IsBadResult, + })); + + var successCount = 0; + var rejectionCount = 0; + + var tasks = Enumerable.Range(0, 500).Select(async i => + { + try + { + // 20% "bad" results — below the 80% threshold, so the circuit should never trip. + var value = i % 5 == 0 ? -1 : i; + var result = await pipeline.ExecuteAsync(ct => new ValueTask(value)); + Interlocked.Increment(ref successCount); + } + catch (CircuitBrokenException) + { + Interlocked.Increment(ref rejectionCount); + } + }); + + await Task.WhenAll(tasks); + + Assert.Equal(0, rejectionCount); + Assert.Equal(500, successCount); + } + + /// + /// Regression test for the race condition fixed in CircuitBreakerTypedStrategy<T>: + /// recording an outcome and reading the failure ratio used to be two separate lock + /// acquisitions, letting a concurrent caller observe a stale or out-of-range ratio between + /// them. Now both strategies share CircuitBreakerStateMachine, which combines the two + /// under one lock via SlidingWindow.RecordAndGetRatio. Heavy concurrent, mixed + /// success/failure traffic should never crash and should trip deterministically once the + /// threshold is exceeded. + /// + [Fact] + public async Task ConcurrentAccess_RapidMixedOutcomes_TripsExactlyWhenThresholdCrossed() + { + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 10, + SamplingDuration = TimeSpan.FromSeconds(60), + BreakDuration = TimeSpan.FromMinutes(5), + ShouldHandle = IsBadResult, + })); + + // 100% failures, well above the 50% threshold — should trip and stay tripped. + var rejections = 0; + var tasks = Enumerable.Range(0, 500).Select(async _ => + { + try + { + await pipeline.ExecuteAsync(ct => new ValueTask(-1)); + } + catch (CircuitBrokenException) + { + Interlocked.Increment(ref rejections); + } + }); + + await Task.WhenAll(tasks); + + // Some calls must have been rejected once the circuit tripped — no exception escaped + // uncontrolled, and the circuit reaches a consistent Open state. + Assert.True(rejections > 0); + } +} diff --git a/tests/Resilion.Tests/CircuitBreaker/SlidingWindowTests.cs b/tests/Resilion.Tests/CircuitBreaker/SlidingWindowTests.cs new file mode 100644 index 0000000..ba448d8 --- /dev/null +++ b/tests/Resilion.Tests/CircuitBreaker/SlidingWindowTests.cs @@ -0,0 +1,174 @@ +using Microsoft.Extensions.Time.Testing; +using Resilion.Internal; +using Xunit; + +namespace Resilion.Tests; + +/// +/// Direct tests for — previously only exercised indirectly through +/// the circuit breaker strategies. Resilion.Internal types are visible here via +/// InternalsVisibleTo. +/// +public class SlidingWindowTests +{ + private static SlidingWindow Create(FakeTimeProvider timeProvider, TimeSpan? samplingDuration = null) + => new(samplingDuration ?? TimeSpan.FromSeconds(10), timeProvider); + + [Fact] + public void InitialState_RatioIsZero_TotalCountIsZero() + { + var window = Create(new FakeTimeProvider()); + + var ratio = window.GetFailureRatio(out var totalCount); + + Assert.Equal(0.0, ratio); + Assert.Equal(0, totalCount); + } + + [Fact] + public void AllFailures_RatioIsOne() + { + var window = Create(new FakeTimeProvider()); + + for (var i = 0; i < 5; i++) + { + window.RecordFailure(); + } + + var ratio = window.GetFailureRatio(out var totalCount); + + Assert.Equal(1.0, ratio); + Assert.Equal(5, totalCount); + } + + [Fact] + public void AllSuccesses_RatioIsZero() + { + var window = Create(new FakeTimeProvider()); + + for (var i = 0; i < 5; i++) + { + window.RecordSuccess(); + } + + var ratio = window.GetFailureRatio(out var totalCount); + + Assert.Equal(0.0, ratio); + Assert.Equal(5, totalCount); + } + + [Fact] + public void MixedOutcomes_RatioReflectsAccurateProportion() + { + var window = Create(new FakeTimeProvider()); + + for (var i = 0; i < 3; i++) + { + window.RecordFailure(); + } + + for (var i = 0; i < 7; i++) + { + window.RecordSuccess(); + } + + var ratio = window.GetFailureRatio(out var totalCount); + + Assert.Equal(0.3, ratio, precision: 10); + Assert.Equal(10, totalCount); + } + + [Fact] + public void RecordAndGetRatio_RecordsAndReadsUnderOneAcquisition() + { + var window = Create(new FakeTimeProvider()); + + window.RecordAndGetRatio(isFailure: true, out var afterFirst); + Assert.Equal(1, afterFirst); + + var ratio = window.RecordAndGetRatio(isFailure: false, out var afterSecond); + + Assert.Equal(2, afterSecond); + Assert.Equal(0.5, ratio, precision: 10); + } + + [Fact] + public void BucketRotation_AdvancingPastOneBucket_KeepsOlderDataUntilWindowExpires() + { + var fakeTime = new FakeTimeProvider(); + // 10 buckets over a 10s window => 1s per bucket. + var window = Create(fakeTime, TimeSpan.FromSeconds(10)); + + window.RecordFailure(); + + // Advance past a single bucket duration but well within the full window. + fakeTime.Advance(TimeSpan.FromSeconds(1.5)); + window.RecordSuccess(); + + var ratio = window.GetFailureRatio(out var totalCount); + + // Both the older failure and the newer success should still be visible. + Assert.Equal(2, totalCount); + Assert.Equal(0.5, ratio, precision: 10); + } + + [Fact] + public void FullWindowExpiry_AdvancingPastEntireDuration_ClearsAllData() + { + var fakeTime = new FakeTimeProvider(); + var window = Create(fakeTime, TimeSpan.FromSeconds(10)); + + for (var i = 0; i < 5; i++) + { + window.RecordFailure(); + } + + // Advance past the entire sampling duration. + fakeTime.Advance(TimeSpan.FromSeconds(15)); + + var ratio = window.GetFailureRatio(out var totalCount); + + Assert.Equal(0.0, ratio); + Assert.Equal(0, totalCount); + } + + [Fact] + public void Reset_ClearsAllRecordedData() + { + var window = Create(new FakeTimeProvider()); + + for (var i = 0; i < 5; i++) + { + window.RecordFailure(); + } + + window.Reset(); + + var ratio = window.GetFailureRatio(out var totalCount); + Assert.Equal(0.0, ratio); + Assert.Equal(0, totalCount); + } + + /// + /// Thread-safety regression: concurrent calls + /// (the method that fixed the typed circuit breaker's race condition, see future-plans #1) + /// must never throw and must always return a ratio within [0.0, 1.0]. + /// + [Fact] + public async Task RecordAndGetRatio_ConcurrentCallers_NeverThrowsAndRatioStaysInRange() + { + var window = Create(new FakeTimeProvider(), TimeSpan.FromSeconds(60)); + var ratios = new System.Collections.Concurrent.ConcurrentBag(); + + var tasks = Enumerable.Range(0, 2000).Select(i => Task.Run(() => + { + var ratio = window.RecordAndGetRatio(isFailure: i % 3 == 0, out _); + ratios.Add(ratio); + })); + + await Task.WhenAll(tasks); + + Assert.Equal(2000, ratios.Count); + Assert.All(ratios, r => Assert.InRange(r, 0.0, 1.0)); + } +} diff --git a/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs b/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs index 45d8eb1..a928d92 100644 --- a/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs +++ b/tests/Resilion.Tests/Hedging/HedgingStrategyTests.cs @@ -206,7 +206,7 @@ public void Sync_ExecutesSequentially() new HedgingStrategyOptions { MaxHedgedAttempts = 3, - HedgingDelay = TimeSpan.FromSeconds(1), // Ignored in sync — always sequential + HedgingDelay = System.Threading.Timeout.InfiniteTimeSpan, // Sequential mode })); var result = pipeline.Execute(ct => @@ -224,6 +224,36 @@ public void Sync_ExecutesSequentially() Assert.Equal(3, callCount); } + [Fact] + public void Sync_NonSequentialMode_Throws() + { + // Parallel and latency hedging modes require concurrent execution, which the sync path + // can't provide — it must throw rather than silently degrade to sequential. + var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = TimeSpan.FromSeconds(1), // Latency mode + })); + + Assert.Throws(() => + pipeline.Execute(ct => "unreachable")); + } + + [Fact] + public void Sync_ParallelMode_Throws() + { + var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 3, + HedgingDelay = TimeSpan.Zero, // Parallel mode + })); + + Assert.Throws(() => + pipeline.Execute(ct => "unreachable")); + } + // ────────────────────────────────────────────────────────────────── // User cancellation // ────────────────────────────────────────────────────────────────── @@ -355,4 +385,156 @@ private class HedgingPropertyTestState public ResiliencePropertyKey PropertyKey; public string? CapturedValue; } + + // ────────────────────────────────────────────────────────────────── + // Latency mode (HedgingDelay > 0) — the core hedging use case + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task LatencyMode_PrimarySlow_SecondaryFiresAfterDelayAndWins() + { + var fakeTime = new FakeTimeProvider(); + var callCount = 0; + var primaryTcs = new TaskCompletionSource(); + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelay = TimeSpan.FromSeconds(2), + }); + }); + + var executeTask = pipeline.ExecuteAsync(ct => + { + var attempt = Interlocked.Increment(ref callCount); + return attempt == 1 + ? new ValueTask(primaryTcs.Task) // primary: never completes on its own + : new ValueTask("secondary-fast"); + }).AsTask(); + + // Let the primary attempt actually start running on its own Task.Run thread + // before we advance the clock past the hedging delay. + await Task.Delay(TimeSpan.FromMilliseconds(50)); + fakeTime.Advance(TimeSpan.FromSeconds(3)); + + var result = await executeTask; + + Assert.Equal("secondary-fast", result); + Assert.Equal(2, callCount); + } + + [Fact] + public async Task LatencyMode_PrimarySucceedsBeforeDelay_SecondaryNeverFires() + { + var fakeTime = new FakeTimeProvider(); + var callCount = 0; + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelay = TimeSpan.FromSeconds(2), + }); + }); + + var result = await pipeline.ExecuteAsync(ct => + { + Interlocked.Increment(ref callCount); + return new ValueTask("primary-fast"); + }); + + Assert.Equal("primary-fast", result); + + // Even if we advance well past the hedging delay after the primary already completed, + // no secondary attempt should have been launched. + fakeTime.Advance(TimeSpan.FromSeconds(5)); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task LatencyMode_BothSlow_SecondaryEventuallySucceeds() + { + var fakeTime = new FakeTimeProvider(); + var callCount = 0; + var primaryTcs = new TaskCompletionSource(); + var secondaryTcs = new TaskCompletionSource(); + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelay = TimeSpan.FromSeconds(2), + }); + }); + + var executeTask = pipeline.ExecuteAsync(ct => + { + var attempt = Interlocked.Increment(ref callCount); + return attempt == 1 + ? new ValueTask(primaryTcs.Task) + : new ValueTask(secondaryTcs.Task); + }).AsTask(); + + // Let the primary start, then advance past the hedging delay so the secondary launches. + await Task.Delay(TimeSpan.FromMilliseconds(50)); + fakeTime.Advance(TimeSpan.FromSeconds(3)); + await Task.Delay(TimeSpan.FromMilliseconds(50)); + + // Both attempts are still pending — now let the secondary win. + secondaryTcs.SetResult("secondary-wins"); + + var result = await executeTask; + Assert.Equal("secondary-wins", result); + } + + // ────────────────────────────────────────────────────────────────── + // Cleanup: non-cooperative losing attempt doesn't hang indefinitely + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Cleanup_NonCooperativeLosingAttempt_DoesNotHangIndefinitely() + { + var callCount = 0; + + var pipeline = Pipeline.Create(b => b.AddHedging( + new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelay = TimeSpan.Zero, // Parallel mode — both launch immediately. + })); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + + var result = await pipeline.ExecuteAsync(ct => + { + var attempt = Interlocked.Increment(ref callCount); + if (attempt == 1) + { + // Winning attempt: succeeds immediately. + return new ValueTask("winner"); + } + + // Losing attempt: ignores cancellation entirely (never observes the token). + // The strategy's cleanup path must bound how long it waits for this to finish. + return new ValueTask(Task.Run(() => + { + Thread.Sleep(TimeSpan.FromMinutes(10)); + return "never"; + })); + }); + + sw.Stop(); + + Assert.Equal("winner", result); + // The strategy's cleanup deadline is a bounded few seconds, not indefinite. + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(15), + $"Expected bounded cleanup, took {sw.Elapsed}."); + } } diff --git a/tests/Resilion.Tests/Integration/CompositionTests.cs b/tests/Resilion.Tests/Integration/CompositionTests.cs new file mode 100644 index 0000000..0d194e9 --- /dev/null +++ b/tests/Resilion.Tests/Integration/CompositionTests.cs @@ -0,0 +1,178 @@ +using System.Threading.RateLimiting; +using Resilion.RateLimiting; +using Xunit; + +namespace Resilion.Tests; + +/// +/// Integration tests exercising multiple strategies composed together, rather than each +/// strategy in isolation. +/// +public class CompositionTests +{ + [Fact] + public async Task CanonicalFiveStrategyPipeline_ExecutesSuccessfully() + { + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 10, + QueueLimit = 0, + }); + + var visited = new List(); + + var pipeline = Pipeline.Create(b => b + .AddRateLimiter(new RateLimiterStrategyOptions { RateLimiter = limiter }) + .AddTimeout(TimeSpan.FromSeconds(30)) + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 2, Delay = RetryDelay.None }) + .AddCircuitBreaker(new CircuitBreakerStrategyOptions()) + .AddTimeout(TimeSpan.FromSeconds(5))); + + var result = await pipeline.ExecuteAsync(ct => + { + visited.Add("user-code"); + return new ValueTask("ok"); + }); + + Assert.Equal("ok", result); + Assert.Single(visited); + } + + [Fact] + public async Task CanonicalFiveStrategyPipeline_RetryRecoversFromTransientFailure() + { + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 10, + QueueLimit = 0, + }); + + var attempts = 0; + + var pipeline = Pipeline.Create(b => b + .AddRateLimiter(new RateLimiterStrategyOptions { RateLimiter = limiter }) + .AddTimeout(TimeSpan.FromSeconds(30)) + .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3, Delay = RetryDelay.None }) + .AddCircuitBreaker(new CircuitBreakerStrategyOptions { MinimumThroughput = 100 }) + .AddTimeout(TimeSpan.FromSeconds(5))); + + var result = await pipeline.ExecuteAsync(ct => + { + attempts++; + return attempts < 3 + ? throw new InvalidOperationException("transient") + : new ValueTask("recovered"); + }); + + Assert.Equal("recovered", result); + Assert.Equal(3, attempts); + } + + [Fact] + public async Task AddPipeline_ComposesTwoPrebuiltPipelines_BothRun() + { + var innerCalls = new List(); + Action onInnerRetry = e => innerCalls.Add("inner-retry"); + + var innerRetryPipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = RetryDelay.None, + OnRetry = onInnerRetry, + })); + + var innerTimeoutPipeline = Pipeline.Create(b => b.AddTimeout(TimeSpan.FromSeconds(30))); + + var composed = Pipeline.Create(b => b + .AddPipeline(innerTimeoutPipeline) + .AddPipeline(innerRetryPipeline)); + + var attempts = 0; + var result = await composed.ExecuteAsync(ct => + { + attempts++; + return attempts < 2 + ? throw new InvalidOperationException("fail once") + : new ValueTask("ok"); + }); + + Assert.Equal("ok", result); + Assert.Equal(2, attempts); + Assert.Contains("inner-retry", innerCalls); + } + + [Fact] + public async Task AddPipeline_TypedComposition_BothRun() + { + var innerFallbackPipeline = Pipeline.Create(b => b.AddFallback(new FallbackStrategyOptions + { + FallbackAction = -1, + })); + + var innerRetryPipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.None, + })); + + var composed = Pipeline.Create(b => b + .AddPipeline(innerRetryPipeline) + .AddPipeline(innerFallbackPipeline)); + + // Retry is outer, Fallback is inner — Fallback catches the failure before Retry ever + // sees it, so the composed pipeline still substitutes -1 for a call that always fails. + var result = await composed.ExecuteAsync(ct => throw new InvalidOperationException("always fails")); + + Assert.Equal(-1, result); + } + + [Fact] + public async Task AddPipeline_DisposesParentChainButNotComposedPipeline() + { + // Track disposal of strategies in composed pipeline + var innerDisposed = false; + var innerStrategy = new DisposableStrategy(() => { innerDisposed = true; }); + var innerPipeline = Pipeline.Create(b => b.AddStrategy(innerStrategy)); + + // Track disposal of strategies in outer pipeline + var outerDisposed = false; + var outerStrategy = new DisposableStrategy(() => { outerDisposed = true; }); + + var composed = Pipeline.Create(b => b + .AddPipeline(innerPipeline) + .AddStrategy(outerStrategy)); + + // Before disposal, nothing is disposed + Assert.False(innerDisposed); + Assert.False(outerDisposed); + + // Dispose the composed pipeline + composed.Dispose(); + + // The outer strategy should be disposed (parent chain) + Assert.True(outerDisposed, "Outer strategy should be disposed"); + + // The inner composed pipeline should NOT be disposed — it's shared and may be used elsewhere + Assert.False(innerDisposed, "Composed inner pipeline should not be disposed (it's shared)"); + } + + /// + /// Helper strategy that calls a callback on disposal. + /// + private sealed class DisposableStrategy : Strategy + { + private readonly Action _onDispose; + + public DisposableStrategy(Action onDispose) => _onDispose = onDispose; + + protected internal override ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) => callback(context); + + protected internal override Outcome Execute( + Func> callback, + ResilienceContext context) => callback(context); + + public override void Dispose() => _onDispose(); + } +} diff --git a/tests/Resilion.Tests/OrderingValidatorTests.cs b/tests/Resilion.Tests/OrderingValidatorTests.cs index 226b404..61991d5 100644 --- a/tests/Resilion.Tests/OrderingValidatorTests.cs +++ b/tests/Resilion.Tests/OrderingValidatorTests.cs @@ -21,23 +21,25 @@ public void CanonicalOrder_NoWarnings() StrategyType.Timeout, // per-attempt }; - var warnings = OrderingValidator.Validate(strategies); - Assert.Empty(warnings); + var result = OrderingValidator.Validate(strategies); + Assert.Empty(result.Errors); + Assert.Empty(result.Warnings); } [Fact] public void SingleStrategy_NoWarnings() { - var warnings = OrderingValidator.Validate([StrategyType.Retry]); - Assert.Empty(warnings); + var result = OrderingValidator.Validate([StrategyType.Retry]); + Assert.Empty(result.Errors); + Assert.Empty(result.Warnings); } // ────────────────────────────────────────────────────────────────── - // CircuitBreaker outside Retry + // CircuitBreaker outside Retry — error (dangerous, essentially always a bug) // ────────────────────────────────────────────────────────────────── [Fact] - public void CircuitBreakerBeforeRetry_Warns() + public void CircuitBreakerBeforeRetry_IsAnError() { var strategies = new List { @@ -45,13 +47,14 @@ public void CircuitBreakerBeforeRetry_Warns() StrategyType.Retry, // pos 1 }; - var warnings = OrderingValidator.Validate(strategies); - Assert.Single(warnings); - Assert.Contains("CircuitBreaker is outside Retry", warnings[0]); + var result = OrderingValidator.Validate(strategies); + Assert.Single(result.Errors); + Assert.Contains("CircuitBreaker is outside Retry", result.Errors[0]); + Assert.Empty(result.Warnings); } [Fact] - public void CircuitBreakerAfterRetry_NoWarning() + public void CircuitBreakerAfterRetry_NoError() { var strategies = new List { @@ -59,16 +62,16 @@ public void CircuitBreakerAfterRetry_NoWarning() StrategyType.CircuitBreaker, }; - var warnings = OrderingValidator.Validate(strategies); - Assert.DoesNotContain(warnings, w => w.Contains("CircuitBreaker is outside Retry")); + var result = OrderingValidator.Validate(strategies); + Assert.DoesNotContain(result.AllMessages, w => w.Contains("CircuitBreaker is outside Retry")); } // ────────────────────────────────────────────────────────────────── - // Fallback not outermost + // Fallback not outermost — error (dangerous, essentially always a bug) // ────────────────────────────────────────────────────────────────── [Fact] - public void FallbackNotFirst_Warns() + public void FallbackNotFirst_IsAnError() { var strategies = new List { @@ -76,12 +79,12 @@ public void FallbackNotFirst_Warns() StrategyType.Fallback, // pos 1, not 0 }; - var warnings = OrderingValidator.Validate(strategies); - Assert.Contains(warnings, w => w.Contains("Fallback is at position")); + var result = OrderingValidator.Validate(strategies); + Assert.Contains(result.Errors, w => w.Contains("Fallback is at position")); } [Fact] - public void FallbackFirst_NoWarning() + public void FallbackFirst_NoError() { var strategies = new List { @@ -89,16 +92,16 @@ public void FallbackFirst_NoWarning() StrategyType.Retry, }; - var warnings = OrderingValidator.Validate(strategies); - Assert.DoesNotContain(warnings, w => w.Contains("Fallback is at position")); + var result = OrderingValidator.Validate(strategies); + Assert.DoesNotContain(result.AllMessages, w => w.Contains("Fallback is at position")); } // ────────────────────────────────────────────────────────────────── - // Hedging + Retry together + // Hedging + Retry together — warning only (sometimes intentional) // ────────────────────────────────────────────────────────────────── [Fact] - public void HedgingAndRetry_Warns() + public void HedgingAndRetry_WarnsButIsNotAnError() { var strategies = new List { @@ -106,16 +109,17 @@ public void HedgingAndRetry_Warns() StrategyType.Retry, }; - var warnings = OrderingValidator.Validate(strategies); - Assert.Contains(warnings, w => w.Contains("Both Hedging and Retry")); + var result = OrderingValidator.Validate(strategies); + Assert.Contains(result.Warnings, w => w.Contains("Both Hedging and Retry")); + Assert.Empty(result.Errors); } // ────────────────────────────────────────────────────────────────── - // Too many timeouts + // Too many timeouts — warning only // ────────────────────────────────────────────────────────────────── [Fact] - public void ThreeTimeouts_Warns() + public void ThreeTimeouts_WarnsButIsNotAnError() { var strategies = new List { @@ -124,8 +128,9 @@ public void ThreeTimeouts_Warns() StrategyType.Timeout, }; - var warnings = OrderingValidator.Validate(strategies); - Assert.Contains(warnings, w => w.Contains("More than 2 Timeout")); + var result = OrderingValidator.Validate(strategies); + Assert.Contains(result.Warnings, w => w.Contains("More than 2 Timeout")); + Assert.Empty(result.Errors); } [Fact] @@ -138,16 +143,16 @@ public void TwoTimeouts_NoWarning() StrategyType.Timeout, }; - var warnings = OrderingValidator.Validate(strategies); - Assert.DoesNotContain(warnings, w => w.Contains("More than 2 Timeout")); + var result = OrderingValidator.Validate(strategies); + Assert.DoesNotContain(result.AllMessages, w => w.Contains("More than 2 Timeout")); } // ────────────────────────────────────────────────────────────────── - // Retry outermost with no timeout + // Retry outermost with no timeout — warning only // ────────────────────────────────────────────────────────────────── [Fact] - public void RetryOutermostNoTimeout_Warns() + public void RetryOutermostNoTimeout_WarnsButIsNotAnError() { var strategies = new List { @@ -155,8 +160,9 @@ public void RetryOutermostNoTimeout_Warns() StrategyType.CircuitBreaker, }; - var warnings = OrderingValidator.Validate(strategies); - Assert.Contains(warnings, w => w.Contains("Retry is the outermost strategy with no Timeout")); + var result = OrderingValidator.Validate(strategies); + Assert.Contains(result.Warnings, w => w.Contains("Retry is the outermost strategy with no Timeout")); + Assert.Empty(result.Errors); } [Fact] @@ -168,67 +174,90 @@ public void RetryWithTimeoutPresent_NoWarning() StrategyType.Retry, }; - var warnings = OrderingValidator.Validate(strategies); - Assert.DoesNotContain(warnings, w => w.Contains("Retry is the outermost")); + var result = OrderingValidator.Validate(strategies); + Assert.DoesNotContain(result.AllMessages, w => w.Contains("Retry is the outermost")); } // ────────────────────────────────────────────────────────────────── - // SuppressOrderingWarnings on builder + // ThrowOnOrderingErrors (default true) — dangerous misorderings throw at Build() // ────────────────────────────────────────────────────────────────── [Fact] - public void SuppressWarnings_NoWarningsEmitted() + public void ThrowOnOrderingErrors_DefaultsToTrue_ThrowsOnDangerousOrdering() + { + var ex = Assert.Throws(() => + Pipeline.Create(b => + { + b.AddCircuitBreaker(); + b.AddRetry(); + })); + + Assert.Contains("CircuitBreaker is outside Retry", ex.Message); + } + + [Fact] + public void ThrowOnOrderingErrors_False_WarnsInsteadOfThrowing() { var captured = new List(); var pipeline = Pipeline.Create(b => { - b.SuppressOrderingWarnings = true; + b.ThrowOnOrderingErrors = false; b.OnValidationWarning = w => captured.Add(w); b.AddCircuitBreaker(); b.AddRetry(); }); - Assert.Empty(captured); + Assert.NotNull(pipeline); + Assert.Contains(captured, w => w.Contains("CircuitBreaker is outside Retry")); } - // ────────────────────────────────────────────────────────────────── - // OnValidationWarning callback receives warnings - // ────────────────────────────────────────────────────────────────── - [Fact] - public void OnValidationWarning_ReceivesWarnings() + public void ThrowOnOrderingErrors_DefaultTrue_DoesNotThrowForWarningsOnlyOrdering() { + // Hedging + Retry is a warning, not an error — must not throw even with the default. var captured = new List(); - var pipeline = Pipeline.Create(b => + var pipeline = Pipeline.Create(b => { b.OnValidationWarning = w => captured.Add(w); - b.AddCircuitBreaker(); + b.AddHedging(new HedgingStrategyOptions { MaxHedgedAttempts = 1 }); b.AddRetry(); }); - Assert.NotEmpty(captured); - Assert.Contains(captured, w => w.Contains("CircuitBreaker is outside Retry")); + Assert.NotNull(pipeline); + Assert.Contains(captured, w => w.Contains("Both Hedging and Retry")); + } + + [Fact] + public void TypedPipeline_ThrowOnOrderingErrors_DefaultsToTrue() + { + Assert.Throws(() => + Pipeline.Create(b => + { + b.AddCircuitBreaker(); + b.AddRetry(); + })); } // ────────────────────────────────────────────────────────────────── - // Typed pipeline also validates + // SuppressOrderingWarnings on builder — suppresses both errors and warnings entirely // ────────────────────────────────────────────────────────────────── [Fact] - public void TypedPipeline_AlsoValidates() + public void SuppressWarnings_NoWarningsEmitted_AndDoesNotThrow() { var captured = new List(); - var pipeline = Pipeline.Create(b => + var pipeline = Pipeline.Create(b => { + b.SuppressOrderingWarnings = true; b.OnValidationWarning = w => captured.Add(w); b.AddCircuitBreaker(); b.AddRetry(); }); - Assert.NotEmpty(captured); - Assert.Contains(captured, w => w.Contains("CircuitBreaker is outside Retry")); + Assert.NotNull(pipeline); + Assert.Empty(captured); } } diff --git a/tests/Resilion.Tests/PipelineTests.cs b/tests/Resilion.Tests/PipelineTests.cs index 52f5e6c..d5086fe 100644 --- a/tests/Resilion.Tests/PipelineTests.cs +++ b/tests/Resilion.Tests/PipelineTests.cs @@ -1,3 +1,4 @@ +using Resilion.Internal; using Xunit; namespace Resilion.Tests; @@ -335,6 +336,58 @@ public async Task Create_ReturnsEmptyWhenNoStrategies() Assert.Equal(42, result); } + // ────────────────────────────────────────────────────────────────── + // Builder Name preservation + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void NonGenericBuilder_EmptyPipeline_PreservesName() + { + var pipeline = Pipeline.Create(b => + { + b.Name = "my-pipeline"; + // Add no strategies — should still preserve the name + }); + + Assert.Equal("my-pipeline", pipeline.Name); + } + + [Fact] + public void GenericBuilder_EmptyPipeline_PreservesName() + { + var pipeline = Pipeline.Create(b => + { + b.Name = "typed-pipeline"; + // Add no strategies — should still preserve the name + }); + + Assert.Equal("typed-pipeline", pipeline.Name); + } + + [Fact] + public void NonGenericBuilder_NonEmptyPipeline_PreservesName() + { + var pipeline = Pipeline.Create(b => + { + b.Name = "with-strategies"; + b.AddStrategy(new NoOpStrategy()); + }); + + Assert.Equal("with-strategies", pipeline.Name); + } + + [Fact] + public void GenericBuilder_NonEmptyPipeline_PreservesName() + { + var pipeline = Pipeline.Create(b => + { + b.Name = "typed-with-strategies"; + b.AddStrategy(new NoOpStrategy()); + }); + + Assert.Equal("typed-with-strategies", pipeline.Name); + } + // ────────────────────────────────────────────────────────────────── // Test helpers // ────────────────────────────────────────────────────────────────── @@ -370,6 +423,14 @@ protected internal override Outcome Execute( _log.Add($"{_name}:after"); return outcome; } + + public override void Dispose() => _log.Add($"{_name}:disposed"); + + public override ValueTask DisposeAsync() + { + _log.Add($"{_name}:disposedAsync"); + return default; + } } /// A no-op strategy that passes through to the next component. @@ -416,3 +477,236 @@ protected internal override Outcome Execute( } } } + +/// +/// Guards against using a builder after Build() has already been called on it — +/// previously silent (strategies added after Build() were just lost), now throws. +/// +public class PipelineBuilderPostBuildGuardTests +{ + [Fact] + public void NonGeneric_AddStrategyAfterBuild_Throws() + { + var builder = new PipelineBuilder(); + builder.AddRetry(); + builder.Build(); + + Assert.Throws(() => builder.AddRetry()); + } + + [Fact] + public void NonGeneric_AddPipelineAfterBuild_Throws() + { + var builder = new PipelineBuilder(); + builder.Build(); + + Assert.Throws(() => builder.AddPipeline(Pipeline.Empty)); + } + + [Fact] + public void NonGeneric_BuildTwice_Throws() + { + var builder = new PipelineBuilder(); + builder.Build(); + + Assert.Throws(() => builder.Build()); + } + + [Fact] + public void Typed_AddStrategyAfterBuild_Throws() + { + var builder = new PipelineBuilder(); + builder.AddRetry(); + builder.Build(); + + Assert.Throws(() => builder.AddRetry()); + } + + [Fact] + public void Typed_AddDelegateStrategyAfterBuild_Throws() + { + var builder = new PipelineBuilder(); + builder.Build(); + + Assert.Throws(() => + builder.AddStrategy("custom", (ctx, next) => next(ctx))); + } + + [Fact] + public void Typed_BuildTwice_Throws() + { + var builder = new PipelineBuilder(); + builder.Build(); + + Assert.Throws(() => builder.Build()); + } +} + +/// +/// silently skips a typed strategy when +/// executed with a mismatched result type — this exercises that path directly (via +/// InternalsVisibleTo) and verifies it now at least warns via Debug.WriteLine. +/// +public class TypedStrategyComponentMismatchTests +{ + private sealed class PassthroughStringStrategy : Strategy + { + public bool WasCalled { get; private set; } + + protected internal override ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + { + WasCalled = true; + return callback(context); + } + } + + private sealed class RecordingTraceListener : System.Diagnostics.TraceListener + { + public List Messages { get; } = []; + public override void Write(string? message) { } + public override void WriteLine(string? message) => Messages.Add(message ?? string.Empty); + } + + [Fact] + public async Task ExecuteAsync_TypeMismatch_SkipsStrategyAndWarns() + { + var strategy = new PassthroughStringStrategy(); + var component = new TypedStrategyComponent(strategy, PipelineComponent.Empty); + + var listener = new RecordingTraceListener(); + System.Diagnostics.Trace.Listeners.Add(listener); + var context = ResilienceContextPool.Shared.Rent(); + try + { + // Executed with TResult = int, but the strategy is Strategy — mismatch. + var outcome = await component.ExecuteAsync( + ctx => new ValueTask>(Outcome.FromResult(42)), + context); + + // Skipped, not applied — the callback's result passes through untouched. + Assert.Equal(42, outcome.Result); + Assert.False(strategy.WasCalled); + } + finally + { + System.Diagnostics.Trace.Listeners.Remove(listener); + ResilienceContextPool.Shared.Return(context); + } + +#if DEBUG + Assert.Contains(listener.Messages, m => m.Contains("was skipped, not applied")); +#endif + } +} + +/// +/// IAsyncDisposable on — enables await using and walks the +/// component chain asynchronously, calling each strategy's DisposeAsync(). +/// +public class PipelineAsyncDisposableTests +{ + private sealed class DisposeTrackingStrategy : Strategy + { + private readonly List _log; + private readonly string _name; + + public DisposeTrackingStrategy(List log, string name) + { + _log = log; + _name = name; + } + + protected internal override ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + => callback(context); + + public override ValueTask DisposeAsync() + { + _log.Add($"{_name}:disposedAsync"); + return default; + } + } + + [Fact] + public async Task AwaitUsing_NonGenericPipeline_CallsDisposeAsyncOnEachStrategy() + { + var log = new List(); + + await using (var pipeline = Pipeline.Create(b => b + .AddStrategy(new DisposeTrackingStrategy(log, "Outer")) + .AddStrategy(new DisposeTrackingStrategy(log, "Inner")))) + { + await pipeline.ExecuteAsync(static ct => new ValueTask("ok")); + } + + Assert.Equal(["Outer:disposedAsync", "Inner:disposedAsync"], log); + } + + private sealed class DisposeTrackingTypedStrategy : Strategy + { + private readonly List _log; + private readonly string _name; + + public DisposeTrackingTypedStrategy(List log, string name) + { + _log = log; + _name = name; + } + + protected internal override ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + => callback(context); + + public override ValueTask DisposeAsync() + { + _log.Add($"{_name}:disposedAsync"); + return default; + } + } + + [Fact] + public async Task AwaitUsing_TypedPipeline_CallsDisposeAsyncOnEachStrategy() + { + var log = new List(); + + await using (var pipeline = Pipeline.Create(b => + b.AddStrategy(new DisposeTrackingTypedStrategy(log, "Only")))) + { + await pipeline.ExecuteAsync(static ct => new ValueTask("ok")); + } + + Assert.Equal(["Only:disposedAsync"], log); + } + + private sealed class SyncOnlyDisposeStrategy : Strategy + { + private readonly List _log; + + public SyncOnlyDisposeStrategy(List log) => _log = log; + + protected internal override ValueTask> ExecuteAsync( + Func>> callback, + ResilienceContext context) + => callback(context); + + public override void Dispose() => _log.Add("SyncOnly:disposed"); + } + + [Fact] + public async Task DisposeAsync_DefaultImplementation_FallsBackToSyncDispose() + { + // A strategy that only overrides the sync Dispose() — the base DisposeAsync() default + // must still call it, so custom strategies written before IAsyncDisposable existed + // keep working with `await using`. + var log = new List(); + var pipeline = Pipeline.Create(b => b.AddStrategy(new SyncOnlyDisposeStrategy(log))); + + await pipeline.DisposeAsync(); + + Assert.Contains("SyncOnly:disposed", log); + } +} diff --git a/tests/Resilion.Tests/RateLimiter/RateLimiterStrategyTests.cs b/tests/Resilion.Tests/RateLimiter/RateLimiterStrategyTests.cs index 347f963..4b7a8f1 100644 --- a/tests/Resilion.Tests/RateLimiter/RateLimiterStrategyTests.cs +++ b/tests/Resilion.Tests/RateLimiter/RateLimiterStrategyTests.cs @@ -316,4 +316,70 @@ private static void InterlockedMax(ref int location, int value) } } while (Interlocked.CompareExchange(ref location, value, current) != current); } + + // ────────────────────────────────────────────────────────────────── + // AttemptAcquire (sync) vs AcquireAsync (async) — queueing behavior differs + // ────────────────────────────────────────────────────────────────── + // + // ExecuteAsync uses AcquireAsync, which will wait in queue up to QueueLimit permits. + // Execute (sync) uses AttemptAcquire, which never queues — it rejects immediately if no + // permit is free right now, even when QueueLimit would have let AcquireAsync wait. + + [Fact] + public async Task Async_WithQueueLimit_WaitsInQueueThenSucceeds() + { + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 1, + QueueLimit = 1, + }); + + var pipeline = Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions + { + RateLimiter = limiter, + })); + + // Hold the only permit so the next acquire must queue. + var heldLease = limiter.AttemptAcquire(); + Assert.True(heldLease.IsAcquired); + + var queuedTask = pipeline.ExecuteAsync(ct => new ValueTask("queued-ok")).AsTask(); + + // Give the queued acquire a moment to actually start waiting, then free the permit. + await Task.Delay(TimeSpan.FromMilliseconds(50)); + heldLease.Dispose(); + + var result = await queuedTask; + Assert.Equal("queued-ok", result); + } + + [Fact] + public void Sync_WithQueueLimit_StillRejectsImmediately() + { + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 1, + QueueLimit = 1, // Would let an async caller wait — AttemptAcquire ignores this. + }); + + var pipeline = Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions + { + RateLimiter = limiter, + })); + + var heldLease = limiter.AttemptAcquire(); + Assert.True(heldLease.IsAcquired); + + try + { + // Sync Execute() uses AttemptAcquire, which never queues, so this rejects + // immediately instead of waiting for heldLease to be released. + Assert.Throws(() => + pipeline.Execute(ct => "should not reach")); + } + finally + { + heldLease.Dispose(); + } + } } diff --git a/tests/Resilion.Tests/ResilienceContextTests.cs b/tests/Resilion.Tests/ResilienceContextTests.cs index e9d0bf6..43bd11a 100644 --- a/tests/Resilion.Tests/ResilienceContextTests.cs +++ b/tests/Resilion.Tests/ResilienceContextTests.cs @@ -111,4 +111,41 @@ public void Properties_Remove() ResilienceContextPool.Shared.Return(ctx); } } + + // ────────────────────────────────────────────────────────────────── + // Configurable pool cap + // ────────────────────────────────────────────────────────────────── + + [Fact] + public void CustomMaxPoolSize_DiscardsContextsBeyondTheCap() + { + var pool = new ResilienceContextPool(maxPoolSize: 2); + + var c1 = pool.Rent(); + var c2 = pool.Rent(); + var c3 = pool.Rent(); + + pool.Return(c1); + pool.Return(c2); + pool.Return(c3); // cap is 2 — this one should be discarded, not pooled + + var rented = new HashSet(ReferenceEqualityComparer.Instance); + for (var i = 0; i < 3; i++) + { + rented.Add(pool.Rent()); + } + + // At most 2 of the 3 originally-returned contexts can have been reused — + // the third was discarded on return. + var reusedCount = new[] { c1, c2, c3 }.Count(rented.Contains); + Assert.True(reusedCount <= 2, $"Expected at most 2 contexts reused, got {reusedCount}."); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void NonPositiveMaxPoolSize_ThrowsAtConstruction(int maxPoolSize) + { + Assert.Throws(() => new ResilienceContextPool(maxPoolSize)); + } } diff --git a/tests/Resilion.Tests/Retry/RetryDelayTests.cs b/tests/Resilion.Tests/Retry/RetryDelayTests.cs new file mode 100644 index 0000000..9f02e5a --- /dev/null +++ b/tests/Resilion.Tests/Retry/RetryDelayTests.cs @@ -0,0 +1,88 @@ +using Xunit; + +namespace Resilion.Tests; + +public class RetryDelayTests +{ + [Fact] + public void Constant_NegativeDelay_Throws() + { + var ex = Assert.Throws(() => + RetryDelay.Constant(TimeSpan.FromMilliseconds(-100))); + Assert.Contains("negative", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constant_ZeroDelay_Succeeds() + { + var delay = RetryDelay.Constant(TimeSpan.Zero); + Assert.NotNull(delay); + } + + [Fact] + public void Constant_PositiveDelay_Succeeds() + { + var delay = RetryDelay.Constant(TimeSpan.FromMilliseconds(100)); + Assert.NotNull(delay); + } + + [Fact] + public void Linear_NegativeBaseDelay_Throws() + { + var ex = Assert.Throws(() => + RetryDelay.Linear(TimeSpan.FromMilliseconds(-100))); + Assert.Contains("negative", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Linear_NegativeMaxDelay_Throws() + { + var ex = Assert.Throws(() => + RetryDelay.Linear(TimeSpan.FromMilliseconds(100), maxDelay: TimeSpan.FromMilliseconds(-50))); + Assert.Contains("negative", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Linear_ValidBaseDelayWithoutMaxDelay_Succeeds() + { + var delay = RetryDelay.Linear(TimeSpan.FromSeconds(1)); + Assert.NotNull(delay); + } + + [Fact] + public void Linear_ValidBaseDelayWithValidMaxDelay_Succeeds() + { + var delay = RetryDelay.Linear(TimeSpan.FromSeconds(1), maxDelay: TimeSpan.FromSeconds(30)); + Assert.NotNull(delay); + } + + [Fact] + public void Exponential_NegativeBaseDelay_Throws() + { + var ex = Assert.Throws(() => + RetryDelay.Exponential(TimeSpan.FromMilliseconds(-100))); + Assert.Contains("negative", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Exponential_NegativeMaxDelay_Throws() + { + var ex = Assert.Throws(() => + RetryDelay.Exponential(TimeSpan.FromMilliseconds(100), maxDelay: TimeSpan.FromMilliseconds(-50))); + Assert.Contains("negative", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Exponential_ValidBaseDelayWithoutMaxDelay_Succeeds() + { + var delay = RetryDelay.Exponential(TimeSpan.FromMilliseconds(100)); + Assert.NotNull(delay); + } + + [Fact] + public void Exponential_ValidBaseDelayWithValidMaxDelay_Succeeds() + { + var delay = RetryDelay.Exponential(TimeSpan.FromMilliseconds(100), maxDelay: TimeSpan.FromSeconds(60)); + Assert.NotNull(delay); + } +} diff --git a/tests/Resilion.Tests/Retry/RetryStrategyTests.cs b/tests/Resilion.Tests/Retry/RetryStrategyTests.cs index 7c6b468..1c6d8c7 100644 --- a/tests/Resilion.Tests/Retry/RetryStrategyTests.cs +++ b/tests/Resilion.Tests/Retry/RetryStrategyTests.cs @@ -410,6 +410,59 @@ public async Task RetryWithTimeout_RetriesTimeoutRejectedException() Assert.Equal("success", result); Assert.Equal(3, callCount); } + + // ────────────────────────────────────────────────────────────────── + // MaxDelay — global safety cap + // ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task MaxDelay_CapsComputedDelayFromCustomDelegate() + { + var fakeTime = new FakeTimeProvider(); + TimeSpan? capturedDelay = null; + Action onRetry = e => capturedDelay = e.RetryDelay; + + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 1, + Delay = RetryDelay.Custom(ctx => TimeSpan.FromHours(1)), // absurdly large + MaxDelay = TimeSpan.FromSeconds(5), + UseJitter = false, + OnRetry = onRetry, + }); + }); + + var callCount = 0; + var executeTask = pipeline.ExecuteAsync(ct => + { + callCount++; + return callCount < 2 + ? throw new InvalidOperationException("fail") + : new ValueTask("ok"); + }).AsTask(); + + // Let the strategy reach its (capped) delay wait, then advance the fake clock past it. + await Task.Delay(TimeSpan.FromMilliseconds(50)); + fakeTime.Advance(TimeSpan.FromSeconds(6)); + + var result = await executeTask; + + Assert.Equal("ok", result); + Assert.Equal(TimeSpan.FromSeconds(5), capturedDelay); + } + + [Fact] + public void NegativeMaxDelay_ThrowsAtBuildTime() + { + Assert.Throws(() => + Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxDelay = TimeSpan.FromSeconds(-1), + }))); + } } // Needed for the ShouldHandle test diff --git a/tests/Resilion.Tests/Telemetry/TelemetryTests.cs b/tests/Resilion.Tests/Telemetry/TelemetryTests.cs new file mode 100644 index 0000000..e664f1f --- /dev/null +++ b/tests/Resilion.Tests/Telemetry/TelemetryTests.cs @@ -0,0 +1,193 @@ +using System.Diagnostics.Metrics; +using System.Threading.RateLimiting; +using Microsoft.Extensions.Time.Testing; +using Resilion.RateLimiting; +using Xunit; + +namespace Resilion.Tests; + +/// +/// Verifies each strategy actually emits its documented telemetry counter, and that the two +/// dead instruments removed in the correctness-fix phase (resilion.strategy.executions, +/// resilion.strategy.duration) no longer exist on the "Resilion" meter. +/// +public class TelemetryTests +{ + /// Tracks measurements recorded for a single named instrument on the "Resilion" meter. + private sealed class CounterTracker : IDisposable + { + private readonly MeterListener _listener; + private long _count; + + public CounterTracker(string instrumentName) + { + _listener = new MeterListener + { + InstrumentPublished = (instrument, l) => + { + if (instrument.Meter.Name == ResilionTelemetry.MeterName && instrument.Name == instrumentName) + { + l.EnableMeasurementEvents(instrument); + } + }, + }; + _listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => + Interlocked.Add(ref _count, measurement)); + _listener.Start(); + } + + public long Count => Interlocked.Read(ref _count); + + public void Dispose() => _listener.Dispose(); + } + + [Fact] + public async Task RetryAttempts_IncrementsOnEachRetry() + { + using var tracker = new CounterTracker("resilion.retry.attempts"); + + var callCount = 0; + var pipeline = Pipeline.Create(b => b.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, + Delay = RetryDelay.None, + })); + + await pipeline.ExecuteAsync(ct => + { + callCount++; + return callCount < 2 + ? throw new InvalidOperationException("fail") + : new ValueTask("ok"); + }); + + Assert.True(tracker.Count >= 1); + } + + [Fact] + public async Task CircuitBreakerStateChanges_IncrementsOnTrip() + { + using var tracker = new CounterTracker("resilion.circuit_breaker.state_changes"); + + var pipeline = Pipeline.Create(b => b.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + FailureRatioThreshold = 0.5, + MinimumThroughput = 2, + })); + + for (var i = 0; i < 2; i++) + { + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => throw new InvalidOperationException("fail")).AsTask()); + } + + Assert.True(tracker.Count >= 1); + } + + [Fact] + public async Task TimeoutExpirations_IncrementsWhenTimeoutFires() + { + using var tracker = new CounterTracker("resilion.timeout.expirations"); + + var fakeTime = new FakeTimeProvider(); + var pipeline = Pipeline.Create(b => + { + b.TimeProvider = fakeTime; + b.AddTimeout(TimeSpan.FromSeconds(5)); + }); + + var executeTask = pipeline.ExecuteAsync(async (ct) => + { + await Task.Delay(TimeSpan.FromMinutes(1), fakeTime, ct); + return "unreachable"; + }).AsTask(); + + fakeTime.Advance(TimeSpan.FromSeconds(6)); + + await Assert.ThrowsAsync(() => executeTask); + + Assert.True(tracker.Count >= 1); + } + + [Fact] + public async Task FallbackActivations_IncrementsWhenFallbackTriggers() + { + using var tracker = new CounterTracker("resilion.fallback.activations"); + + var pipeline = Pipeline.Create(b => b.AddFallback(new FallbackStrategyOptions + { + FallbackAction = -1, + })); + + var result = await pipeline.ExecuteAsync(ct => throw new InvalidOperationException("fail")); + + Assert.Equal(-1, result); + Assert.True(tracker.Count >= 1); + } + + [Fact] + public async Task HedgingAttempts_IncrementsWhenHedgeLaunches() + { + using var tracker = new CounterTracker("resilion.hedging.attempts"); + + var pipeline = Pipeline.Create(b => b.AddHedging(new HedgingStrategyOptions + { + MaxHedgedAttempts = 2, + HedgingDelay = TimeSpan.Zero, // Parallel mode — hedge always launches. + })); + + await pipeline.ExecuteAsync(ct => new ValueTask("ok")); + + Assert.True(tracker.Count >= 1); + } + + [Fact] + public async Task RateLimiterRejections_IncrementsWhenLimitExceeded() + { + using var tracker = new CounterTracker("resilion.rate_limiter.rejections"); + + using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = 1, + QueueLimit = 0, + }); + + var pipeline = Pipeline.Create(b => b.AddRateLimiter(new RateLimiterStrategyOptions + { + RateLimiter = limiter, + })); + + var lease = limiter.AttemptAcquire(); + try + { + await Assert.ThrowsAsync(() => + pipeline.ExecuteAsync(ct => new ValueTask(1)).AsTask()); + } + finally + { + lease.Dispose(); + } + + Assert.True(tracker.Count >= 1); + } + + [Fact] + public void DeadInstruments_NoLongerExistOnTheMeter() + { + var observedNames = new HashSet(); + using var listener = new MeterListener + { + InstrumentPublished = (instrument, l) => + { + if (instrument.Meter.Name == ResilionTelemetry.MeterName) + { + observedNames.Add(instrument.Name); + } + }, + }; + listener.Start(); + + Assert.DoesNotContain("resilion.strategy.executions", observedNames); + Assert.DoesNotContain("resilion.strategy.duration", observedNames); + } +}