Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Comment thread
FreakyAli marked this conversation as resolved.
Resolved
steps:
- uses: actions/checkout@v4
with:
Expand All @@ -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
Expand All @@ -45,16 +52,42 @@ 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

- name: Publish Test Results
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
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`, `ResilienceContext`, `Pipeline`, `Strategy`
- `IPipelineProvider<TKey>` — a read-only view over `ResiliencePipelineRegistry<TKey>` 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<TResult>`, `Strategy`, `Strategy<TResult>`, 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<T>` now share a single `CircuitBreakerStateMachine` instead of ~200 duplicated lines each — the two variants can no longer diverge in behavior
- `PipelineBuilder` and `PipelineBuilder<TResult>` now share `PipelineBuilderBase` for their common properties and `EmitWarnings`
- The default "handle everything except cancellation" predicate is now a single `OutcomePredicates.DefaultShouldHandle<T>()`, shared by all four options classes that used to copy-paste it
- `ResilienceEventHandler<TArgs>.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<T>`: 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<TResult>`'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<TKey>.GetPipeline()` no longer caches a faulted `Lazy<Pipeline>` after a failed lookup — registering the key afterward and retrying now succeeds instead of replaying the cached failure
- `PipelineBuilder`/`PipelineBuilder<TResult>` 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 `<remarks>` on `Outcome<T>`'s implicit operator warning about the `Outcome<Exception>` success-vs-failure ambiguity
- Documented ordering validation (`ThrowOnOrderingErrors`, `SuppressOrderingWarnings`) in `docs/pipelines.md`
52 changes: 34 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<ResiliencePipelineRegistry<string>>();
var pipeline = registry.GetPipeline("http-api");
// Later, resolve — inject IPipelineProvider<string> rather than the full registry if you
// only need to retrieve pipelines, not register new ones:
var provider = serviceProvider.GetRequiredService<IPipelineProvider<string>>();
var pipeline = provider.GetPipeline("http-api");

var result = await pipeline.ExecuteAsync(
async (client, ct) => await client.GetStringAsync(url, ct),
Expand All @@ -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<long>("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.

Expand Down Expand Up @@ -389,12 +390,27 @@ docs/

Resilion is designed for high-performance, latency-sensitive scenarios:

- Struct-based `Outcome<T>` eliminates heap allocations
- Struct-based `Outcome<T>` 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

Expand Down
46 changes: 46 additions & 0 deletions benchmarks/Resilion.Benchmarks/CircuitBreakerLoadBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using BenchmarkDotNet.Attributes;

namespace Resilion.Benchmarks;

/// <summary>
/// Circuit breaker in the Closed state handling mixed success/failure traffic. Every call
/// exercises <c>SlidingWindow.RecordAndGetRatio</c> under its single lock — the strategy's
/// hot-path contention point.
/// </summary>
[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),
}));
}

/// <summary>~20% of calls fail — comfortably under the 80% threshold.</summary>
[Benchmark]
public async ValueTask<string> Closed_MixedTraffic()
{
var i = Interlocked.Increment(ref _counter);
try
{
return await _pipeline.ExecuteAsync<string>(ct =>
i % 5 == 0
? throw new InvalidOperationException("simulated failure")
: new ValueTask<string>("ok"));
}
catch (InvalidOperationException)
{
return "handled";
}
}
}
28 changes: 28 additions & 0 deletions benchmarks/Resilion.Benchmarks/ContextPoolingBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using BenchmarkDotNet.Attributes;

namespace Resilion.Benchmarks;

/// <summary>
/// Measures the value of <see cref="ResilienceContextPool"/> — renting and returning a pooled
/// <see cref="ResilienceContext"/> vs allocating a fresh one per call. The pipeline's
/// <c>ExecuteAsync</c> overloads use the pool automatically; this isolates just that cost.
/// </summary>
[MemoryDiagnoser]
public class ContextPoolingBenchmarks
{
/// <summary>
/// Baseline: allocate a context directly, bypassing the pool. <see cref="ResilienceContext"/>'s
/// constructor is internal — accessible here only via <c>InternalsVisibleTo</c> — application
/// code should never construct one directly; this exists solely to measure what the pool saves.
/// </summary>
[Benchmark(Baseline = true)]
public ResilienceContext AllocateNewContext() => new();

[Benchmark]
public ResilienceContext RentAndReturnFromPool()
{
var context = ResilienceContextPool.Shared.Rent();
ResilienceContextPool.Shared.Return(context);
return context;
}
}
59 changes: 59 additions & 0 deletions benchmarks/Resilion.Benchmarks/GcPressureBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using BenchmarkDotNet.Attributes;
using Polly;

namespace Resilion.Benchmarks;

/// <summary>
/// Runs a large batch of executions in a single invocation to surface GC pressure (Gen0/Gen1
/// collections reported by <see cref="MemoryDiagnoserAttribute"/>) under sustained load, rather
/// than just the per-call allocation size the other benchmark classes measure.
/// </summary>
[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<string>("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<string>("ok"),
(object?)null);
}
}
}
14 changes: 8 additions & 6 deletions benchmarks/Resilion.Benchmarks/RealWorldScenarioBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(b => b
.AddFallback(new FallbackStrategyOptions<string>
{
FallbackAction = "CACHED_DEFAULT"
})
.AddTimeout(TimeSpan.FromSeconds(10))
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 1,
Delay = RetryDelay.Linear(TimeSpan.FromMilliseconds(50)),
})
.AddFallback(new FallbackStrategyOptions<string>
{
FallbackAction = "CACHED_DEFAULT"
}));

// ────── Resilion: Hedging Pattern (Parallel attempts, fail fast) ──────
Expand Down
Loading
Loading