From 5abffb43d76f9ea0ba87a05fb005f55219d145c9 Mon Sep 17 00:00:00 2001 From: Taras Kovalenko Date: Sun, 21 Jun 2026 11:01:26 +0300 Subject: [PATCH] feat: modernize the Relay library --- README.md | 32 +- Relay.sln | 264 --------- Relay.slnx | 32 ++ .../Relay.Examples.ContextRouting/Program.cs | 56 ++ .../Relay.Examples.ContextRouting.csproj | 15 + .../Relay.Examples.Observability/Program.cs | 61 ++ .../Relay.Examples.Observability.csproj | 15 + examples/Relay.Examples.Resilience/Program.cs | 65 +++ .../Relay.Examples.Resilience.csproj | 15 + src/Builders/ConditionalRelayBuilder.cs | 8 +- src/Builders/MultiRelayBuilder.cs | 61 +- src/Builders/RelayFactoryBuilder.cs | 31 +- src/Core/Implementations/MultiRelay.cs | 61 +- src/Core/Options/RelayResilienceOptions.cs | 30 + src/Relay.csproj | 2 +- .../Builders/MultiRelayBuilderTests.cs | 2 + tests/Relay.Tests/Features/CoverageTests.cs | 539 ++++++++++++++++++ .../Relay.Tests/Features/NewFeaturesTests.cs | 119 ++++ 18 files changed, 1116 insertions(+), 292 deletions(-) delete mode 100644 Relay.sln create mode 100644 Relay.slnx create mode 100644 examples/Relay.Examples.ContextRouting/Program.cs create mode 100644 examples/Relay.Examples.ContextRouting/Relay.Examples.ContextRouting.csproj create mode 100644 examples/Relay.Examples.Observability/Program.cs create mode 100644 examples/Relay.Examples.Observability/Relay.Examples.Observability.csproj create mode 100644 examples/Relay.Examples.Resilience/Program.cs create mode 100644 examples/Relay.Examples.Resilience/Relay.Examples.Resilience.csproj create mode 100644 src/Core/Options/RelayResilienceOptions.cs create mode 100644 tests/Relay.Tests/Features/CoverageTests.cs diff --git a/README.md b/README.md index a7f4a48..3f73d87 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Transform the adapter pattern from a simple design pattern into a **powerful arc - **🔗 True Adapter Pattern**: Seamlessly integrate legacy systems and incompatible interfaces - **⛓️ Adapter Chains**: Build complex transformation pipelines (A→B→C→X) - **🏭 Relay Factories**: Key-based service creation with native keyed-DI support +- **🛡️ Resilience**: Failover with per-relay retry and exponential backoff - **⏱️ Async Pipelines**: `IAsyncAdapter` chains for non-blocking I/O transformations - **🔭 Observability**: Built-in `ActivitySource` tracing for chains and multi-relays - **⚡ Performance Optimized**: Cached reflection and lock-free round-robin resolution @@ -102,12 +103,14 @@ services.AddMultiRelay(config => config .WithStrategy(RelayStrategy.Broadcast) ).Build(); -// Failover strategy +// Failover strategy with retry (transient-fault resilience). Each provider is retried +// up to maxAttempts with exponential backoff before failing over to the next one. services.AddMultiRelay(config => config .AddRelay() .AddRelay() .AddRelay() .WithStrategy(RelayStrategy.Failover) + .WithRetry(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(100), backoffFactor: 2.0) ).Build(); ``` @@ -164,16 +167,21 @@ var names = factory.GetAvailableChains(); // ["full", "fast", "mock"] ### **6. Relay Factory** ```csharp +// Implementations and their dependencies are created by DI — no manual `new`. services.AddRelayFactory(factory => factory - .RegisterRelay("stripe", provider => new StripeRelay()) - .RegisterRelay("paypal", provider => new PayPalRelay()) - .RegisterRelay("crypto", provider => new CryptoRelay()) + .RegisterRelay("stripe") + .RegisterRelay("paypal") + .RegisterRelay("crypto") .SetDefaultRelay("stripe") ).Build(); // Usage -var factory = serviceProvider.GetService>(); +var factory = serviceProvider.GetRequiredService>(); var paymentService = factory.CreateRelay("stripe"); + +// Escape hatch: the Func overload is only for objects that cannot be +// resolved from the container (e.g. third-party SDK clients): +// .RegisterRelay("legacy", sp => new LegacyRelay(sp.GetRequiredService())) ``` ### **7. Auto-Discovery** @@ -338,7 +346,7 @@ Install-Package Relay dotnet add package Relay # PackageReference - + ``` ### **Basic Setup** @@ -360,9 +368,15 @@ public void ConfigureServices(IServiceCollection services) ## 📚 **Documentation** -Runnable samples for every feature live in the [`examples/`](examples) directory — including -basic relays, conditional routing, multi-relay strategies, adapter chains, async chains, and -factories. The [`tests/`](tests) project doubles as executable documentation of the full API. +Runnable samples for every feature live in the [`examples/`](examples) directory. Production-oriented +ones worth starting with: + +- **Resilience** — failover across storage providers with retry + exponential backoff +- **Observability** — tracing an adapter chain via `ActivitySource` / OpenTelemetry +- **ContextRouting** — multi-tenant routing that picks an implementation per request from `IRelayContext` +- **AsyncChain** — non-blocking I/O transformation pipeline + +The [`tests/`](tests) project doubles as executable documentation of the full API. ## 🤝 **Contributing** diff --git a/Relay.sln b/Relay.sln deleted file mode 100644 index 59b0d74..0000000 --- a/Relay.sln +++ /dev/null @@ -1,264 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay", "src\Relay.csproj", "{4FE321DD-A035-430D-8C67-7E47AD1AB049}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{2DBE46D5-AB38-4455-8FE2-86A2BFA063E1}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.Basic", "examples\Relay.Examples.Basic\Relay.Examples.Basic.csproj", "{7C77897A-8967-431D-9E06-200F5C11628C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Tests", "tests\Relay.Tests\Relay.Tests.csproj", "{537B681F-5B1A-4E36-9EED-8CC30E5D2E81}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.Conditional", "examples\Relay.Examples.Conditional\Relay.Examples.Conditional.csproj", "{76989990-FEE3-4C8F-82B5-2FC52617C676}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.MultiBroadcasting", "examples\Relay.Examples.MultiBroadcasting\Relay.Examples.MultiBroadcasting.csproj", "{35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.MultiFailover", "examples\Relay.Examples.MultiFailover\Relay.Examples.MultiFailover.csproj", "{312AF4B0-A25B-4B70-AD8D-15325BBC9459}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.Adapter", "examples\Relay.Examples.Adapter\Relay.Examples.Adapter.csproj", "{4C6B9918-D8F0-4329-92D7-FC7EB0197681}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.AdapterFactory", "examples\Relay.Examples.AdapterFactory\Relay.Examples.AdapterFactory.csproj", "{DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.Factory", "examples\Relay.Examples.Factory\Relay.Examples.Factory.csproj", "{4EBFF6A8-770F-4723-87EE-70175DE20891}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.ParallelWithResults", "examples\Relay.Examples.ParallelWithResults\Relay.Examples.ParallelWithResults.csproj", "{797A2714-3414-4249-A1B6-D0132422E075}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.ConditionalRouting", "examples\Relay.Examples.ConditionalRouting\Relay.Examples.ConditionalRouting.csproj", "{44A73063-EC5C-41DA-A6E7-B042C4FC73B4}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.Integration", "examples\Relay.Examples.Integration\Relay.Examples.Integration.csproj", "{32A8A4D5-52C0-4383-9933-A21C9F2D878F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.DecorateWith", "examples\Relay.Examples.DecorateWith\Relay.Examples.DecorateWith.csproj", "{B7D20116-EB4A-4207-8C02-2C9F4728FA5F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.AdvancedDecorator", "examples\Relay.Examples.AdvancedDecorator\Relay.Examples.AdvancedDecorator.csproj", "{242EB29B-65C0-4B1F-A496-5BF256201D6B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.AdapterChain", "examples\Relay.Examples.AdapterChain\Relay.Examples.AdapterChain.csproj", "{A1DCFE8F-9EA6-42A8-9ECD-770592075235}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9C87688A-802E-46C4-A479-8A4CC1AB58AC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Relay.Examples.AsyncChain", "examples\Relay.Examples.AsyncChain\Relay.Examples.AsyncChain.csproj", "{F4D37684-04FF-4FF5-BD76-81CDBEC00064}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Debug|x64.ActiveCfg = Debug|Any CPU - {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Debug|x64.Build.0 = Debug|Any CPU - {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Debug|x86.ActiveCfg = Debug|Any CPU - {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Debug|x86.Build.0 = Debug|Any CPU - {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Release|Any CPU.Build.0 = Release|Any CPU - {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Release|x64.ActiveCfg = Release|Any CPU - {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Release|x64.Build.0 = Release|Any CPU - {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Release|x86.ActiveCfg = Release|Any CPU - {4FE321DD-A035-430D-8C67-7E47AD1AB049}.Release|x86.Build.0 = Release|Any CPU - {7C77897A-8967-431D-9E06-200F5C11628C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7C77897A-8967-431D-9E06-200F5C11628C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7C77897A-8967-431D-9E06-200F5C11628C}.Debug|x64.ActiveCfg = Debug|Any CPU - {7C77897A-8967-431D-9E06-200F5C11628C}.Debug|x64.Build.0 = Debug|Any CPU - {7C77897A-8967-431D-9E06-200F5C11628C}.Debug|x86.ActiveCfg = Debug|Any CPU - {7C77897A-8967-431D-9E06-200F5C11628C}.Debug|x86.Build.0 = Debug|Any CPU - {7C77897A-8967-431D-9E06-200F5C11628C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7C77897A-8967-431D-9E06-200F5C11628C}.Release|Any CPU.Build.0 = Release|Any CPU - {7C77897A-8967-431D-9E06-200F5C11628C}.Release|x64.ActiveCfg = Release|Any CPU - {7C77897A-8967-431D-9E06-200F5C11628C}.Release|x64.Build.0 = Release|Any CPU - {7C77897A-8967-431D-9E06-200F5C11628C}.Release|x86.ActiveCfg = Release|Any CPU - {7C77897A-8967-431D-9E06-200F5C11628C}.Release|x86.Build.0 = Release|Any CPU - {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Debug|Any CPU.Build.0 = Debug|Any CPU - {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Debug|x64.ActiveCfg = Debug|Any CPU - {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Debug|x64.Build.0 = Debug|Any CPU - {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Debug|x86.ActiveCfg = Debug|Any CPU - {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Debug|x86.Build.0 = Debug|Any CPU - {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Release|Any CPU.ActiveCfg = Release|Any CPU - {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Release|Any CPU.Build.0 = Release|Any CPU - {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Release|x64.ActiveCfg = Release|Any CPU - {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Release|x64.Build.0 = Release|Any CPU - {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Release|x86.ActiveCfg = Release|Any CPU - {537B681F-5B1A-4E36-9EED-8CC30E5D2E81}.Release|x86.Build.0 = Release|Any CPU - {76989990-FEE3-4C8F-82B5-2FC52617C676}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {76989990-FEE3-4C8F-82B5-2FC52617C676}.Debug|Any CPU.Build.0 = Debug|Any CPU - {76989990-FEE3-4C8F-82B5-2FC52617C676}.Debug|x64.ActiveCfg = Debug|Any CPU - {76989990-FEE3-4C8F-82B5-2FC52617C676}.Debug|x64.Build.0 = Debug|Any CPU - {76989990-FEE3-4C8F-82B5-2FC52617C676}.Debug|x86.ActiveCfg = Debug|Any CPU - {76989990-FEE3-4C8F-82B5-2FC52617C676}.Debug|x86.Build.0 = Debug|Any CPU - {76989990-FEE3-4C8F-82B5-2FC52617C676}.Release|Any CPU.ActiveCfg = Release|Any CPU - {76989990-FEE3-4C8F-82B5-2FC52617C676}.Release|Any CPU.Build.0 = Release|Any CPU - {76989990-FEE3-4C8F-82B5-2FC52617C676}.Release|x64.ActiveCfg = Release|Any CPU - {76989990-FEE3-4C8F-82B5-2FC52617C676}.Release|x64.Build.0 = Release|Any CPU - {76989990-FEE3-4C8F-82B5-2FC52617C676}.Release|x86.ActiveCfg = Release|Any CPU - {76989990-FEE3-4C8F-82B5-2FC52617C676}.Release|x86.Build.0 = Release|Any CPU - {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Debug|x64.ActiveCfg = Debug|Any CPU - {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Debug|x64.Build.0 = Debug|Any CPU - {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Debug|x86.ActiveCfg = Debug|Any CPU - {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Debug|x86.Build.0 = Debug|Any CPU - {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Release|Any CPU.Build.0 = Release|Any CPU - {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Release|x64.ActiveCfg = Release|Any CPU - {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Release|x64.Build.0 = Release|Any CPU - {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Release|x86.ActiveCfg = Release|Any CPU - {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4}.Release|x86.Build.0 = Release|Any CPU - {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Debug|Any CPU.Build.0 = Debug|Any CPU - {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Debug|x64.ActiveCfg = Debug|Any CPU - {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Debug|x64.Build.0 = Debug|Any CPU - {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Debug|x86.ActiveCfg = Debug|Any CPU - {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Debug|x86.Build.0 = Debug|Any CPU - {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Release|Any CPU.ActiveCfg = Release|Any CPU - {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Release|Any CPU.Build.0 = Release|Any CPU - {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Release|x64.ActiveCfg = Release|Any CPU - {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Release|x64.Build.0 = Release|Any CPU - {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Release|x86.ActiveCfg = Release|Any CPU - {312AF4B0-A25B-4B70-AD8D-15325BBC9459}.Release|x86.Build.0 = Release|Any CPU - {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Debug|x64.ActiveCfg = Debug|Any CPU - {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Debug|x64.Build.0 = Debug|Any CPU - {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Debug|x86.ActiveCfg = Debug|Any CPU - {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Debug|x86.Build.0 = Debug|Any CPU - {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Release|Any CPU.Build.0 = Release|Any CPU - {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Release|x64.ActiveCfg = Release|Any CPU - {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Release|x64.Build.0 = Release|Any CPU - {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Release|x86.ActiveCfg = Release|Any CPU - {4C6B9918-D8F0-4329-92D7-FC7EB0197681}.Release|x86.Build.0 = Release|Any CPU - {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Debug|x64.ActiveCfg = Debug|Any CPU - {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Debug|x64.Build.0 = Debug|Any CPU - {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Debug|x86.ActiveCfg = Debug|Any CPU - {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Debug|x86.Build.0 = Debug|Any CPU - {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Release|Any CPU.Build.0 = Release|Any CPU - {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Release|x64.ActiveCfg = Release|Any CPU - {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Release|x64.Build.0 = Release|Any CPU - {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Release|x86.ActiveCfg = Release|Any CPU - {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368}.Release|x86.Build.0 = Release|Any CPU - {4EBFF6A8-770F-4723-87EE-70175DE20891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4EBFF6A8-770F-4723-87EE-70175DE20891}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4EBFF6A8-770F-4723-87EE-70175DE20891}.Debug|x64.ActiveCfg = Debug|Any CPU - {4EBFF6A8-770F-4723-87EE-70175DE20891}.Debug|x64.Build.0 = Debug|Any CPU - {4EBFF6A8-770F-4723-87EE-70175DE20891}.Debug|x86.ActiveCfg = Debug|Any CPU - {4EBFF6A8-770F-4723-87EE-70175DE20891}.Debug|x86.Build.0 = Debug|Any CPU - {4EBFF6A8-770F-4723-87EE-70175DE20891}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4EBFF6A8-770F-4723-87EE-70175DE20891}.Release|Any CPU.Build.0 = Release|Any CPU - {4EBFF6A8-770F-4723-87EE-70175DE20891}.Release|x64.ActiveCfg = Release|Any CPU - {4EBFF6A8-770F-4723-87EE-70175DE20891}.Release|x64.Build.0 = Release|Any CPU - {4EBFF6A8-770F-4723-87EE-70175DE20891}.Release|x86.ActiveCfg = Release|Any CPU - {4EBFF6A8-770F-4723-87EE-70175DE20891}.Release|x86.Build.0 = Release|Any CPU - {797A2714-3414-4249-A1B6-D0132422E075}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {797A2714-3414-4249-A1B6-D0132422E075}.Debug|Any CPU.Build.0 = Debug|Any CPU - {797A2714-3414-4249-A1B6-D0132422E075}.Debug|x64.ActiveCfg = Debug|Any CPU - {797A2714-3414-4249-A1B6-D0132422E075}.Debug|x64.Build.0 = Debug|Any CPU - {797A2714-3414-4249-A1B6-D0132422E075}.Debug|x86.ActiveCfg = Debug|Any CPU - {797A2714-3414-4249-A1B6-D0132422E075}.Debug|x86.Build.0 = Debug|Any CPU - {797A2714-3414-4249-A1B6-D0132422E075}.Release|Any CPU.ActiveCfg = Release|Any CPU - {797A2714-3414-4249-A1B6-D0132422E075}.Release|Any CPU.Build.0 = Release|Any CPU - {797A2714-3414-4249-A1B6-D0132422E075}.Release|x64.ActiveCfg = Release|Any CPU - {797A2714-3414-4249-A1B6-D0132422E075}.Release|x64.Build.0 = Release|Any CPU - {797A2714-3414-4249-A1B6-D0132422E075}.Release|x86.ActiveCfg = Release|Any CPU - {797A2714-3414-4249-A1B6-D0132422E075}.Release|x86.Build.0 = Release|Any CPU - {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Debug|x64.ActiveCfg = Debug|Any CPU - {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Debug|x64.Build.0 = Debug|Any CPU - {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Debug|x86.ActiveCfg = Debug|Any CPU - {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Debug|x86.Build.0 = Debug|Any CPU - {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Release|Any CPU.Build.0 = Release|Any CPU - {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Release|x64.ActiveCfg = Release|Any CPU - {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Release|x64.Build.0 = Release|Any CPU - {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Release|x86.ActiveCfg = Release|Any CPU - {44A73063-EC5C-41DA-A6E7-B042C4FC73B4}.Release|x86.Build.0 = Release|Any CPU - {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Debug|x64.ActiveCfg = Debug|Any CPU - {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Debug|x64.Build.0 = Debug|Any CPU - {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Debug|x86.ActiveCfg = Debug|Any CPU - {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Debug|x86.Build.0 = Debug|Any CPU - {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Release|Any CPU.Build.0 = Release|Any CPU - {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Release|x64.ActiveCfg = Release|Any CPU - {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Release|x64.Build.0 = Release|Any CPU - {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Release|x86.ActiveCfg = Release|Any CPU - {32A8A4D5-52C0-4383-9933-A21C9F2D878F}.Release|x86.Build.0 = Release|Any CPU - {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Debug|x64.ActiveCfg = Debug|Any CPU - {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Debug|x64.Build.0 = Debug|Any CPU - {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Debug|x86.ActiveCfg = Debug|Any CPU - {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Debug|x86.Build.0 = Debug|Any CPU - {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Release|Any CPU.Build.0 = Release|Any CPU - {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Release|x64.ActiveCfg = Release|Any CPU - {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Release|x64.Build.0 = Release|Any CPU - {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Release|x86.ActiveCfg = Release|Any CPU - {B7D20116-EB4A-4207-8C02-2C9F4728FA5F}.Release|x86.Build.0 = Release|Any CPU - {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Debug|x64.ActiveCfg = Debug|Any CPU - {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Debug|x64.Build.0 = Debug|Any CPU - {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Debug|x86.ActiveCfg = Debug|Any CPU - {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Debug|x86.Build.0 = Debug|Any CPU - {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Release|Any CPU.Build.0 = Release|Any CPU - {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Release|x64.ActiveCfg = Release|Any CPU - {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Release|x64.Build.0 = Release|Any CPU - {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Release|x86.ActiveCfg = Release|Any CPU - {242EB29B-65C0-4B1F-A496-5BF256201D6B}.Release|x86.Build.0 = Release|Any CPU - {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Debug|x64.ActiveCfg = Debug|Any CPU - {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Debug|x64.Build.0 = Debug|Any CPU - {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Debug|x86.ActiveCfg = Debug|Any CPU - {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Debug|x86.Build.0 = Debug|Any CPU - {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Release|Any CPU.Build.0 = Release|Any CPU - {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Release|x64.ActiveCfg = Release|Any CPU - {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Release|x64.Build.0 = Release|Any CPU - {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Release|x86.ActiveCfg = Release|Any CPU - {A1DCFE8F-9EA6-42A8-9ECD-770592075235}.Release|x86.Build.0 = Release|Any CPU - {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Debug|x64.ActiveCfg = Debug|Any CPU - {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Debug|x64.Build.0 = Debug|Any CPU - {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Debug|x86.ActiveCfg = Debug|Any CPU - {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Debug|x86.Build.0 = Debug|Any CPU - {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Release|Any CPU.Build.0 = Release|Any CPU - {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Release|x64.ActiveCfg = Release|Any CPU - {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Release|x64.Build.0 = Release|Any CPU - {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Release|x86.ActiveCfg = Release|Any CPU - {F4D37684-04FF-4FF5-BD76-81CDBEC00064}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {7C77897A-8967-431D-9E06-200F5C11628C} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - {537B681F-5B1A-4E36-9EED-8CC30E5D2E81} = {9C87688A-802E-46C4-A479-8A4CC1AB58AC} - {76989990-FEE3-4C8F-82B5-2FC52617C676} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - {35C9EA01-DA81-42D7-855D-6C80E9AFDEA4} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - {312AF4B0-A25B-4B70-AD8D-15325BBC9459} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - {4C6B9918-D8F0-4329-92D7-FC7EB0197681} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - {DB77AE40-5BCF-4ECC-ADF7-9080A69E7368} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - {4EBFF6A8-770F-4723-87EE-70175DE20891} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - {797A2714-3414-4249-A1B6-D0132422E075} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - {44A73063-EC5C-41DA-A6E7-B042C4FC73B4} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - {32A8A4D5-52C0-4383-9933-A21C9F2D878F} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - {B7D20116-EB4A-4207-8C02-2C9F4728FA5F} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - {242EB29B-65C0-4B1F-A496-5BF256201D6B} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - {A1DCFE8F-9EA6-42A8-9ECD-770592075235} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - {F4D37684-04FF-4FF5-BD76-81CDBEC00064} = {2DBE46D5-AB38-4455-8FE2-86A2BFA063E1} - EndGlobalSection -EndGlobal diff --git a/Relay.slnx b/Relay.slnx new file mode 100644 index 0000000..88d4b70 --- /dev/null +++ b/Relay.slnx @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/Relay.Examples.ContextRouting/Program.cs b/examples/Relay.Examples.ContextRouting/Program.cs new file mode 100644 index 0000000..2e06c9b --- /dev/null +++ b/examples/Relay.Examples.ContextRouting/Program.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.DependencyInjection; +using Relay; +using Relay.Core.Implementations; +using Relay.Core.Interfaces; +using Relay.Examples.ContextRouting; + +// Production scenario: multi-tenant routing. The same IFeatureService resolves to a different +// implementation per request based on the tenant's plan, decided at call time via IRelayContext. + +var services = new ServiceCollection(); +services.AddRelayServices(); +services + .AddConditionalRelay() + .When(ctx => (string)ctx.Properties["plan"] == "enterprise") + .RelayTo() + .When(ctx => (string)ctx.Properties["plan"] == "pro") + .RelayTo() + .Otherwise() + .Build(); + +var provider = services.BuildServiceProvider(); + +foreach (var plan in new[] { "free", "pro", "enterprise" }) +{ + using var scope = provider.CreateScope(); + var resolver = scope.ServiceProvider.GetRequiredService(); + + var ctx = new DefaultRelayContext(scope.ServiceProvider); + ctx.Properties["plan"] = plan; + + var service = resolver.Resolve(ctx); + Console.WriteLine($"plan={plan,-10} -> {service.Describe()}"); +} + +namespace Relay.Examples.ContextRouting +{ + public interface IFeatureService + { + string Describe(); + } + + public sealed class FreeFeatures : IFeatureService + { + public string Describe() => "Free: 1 seat, community support"; + } + + public sealed class ProFeatures : IFeatureService + { + public string Describe() => "Pro: 10 seats, email support"; + } + + public sealed class EnterpriseFeatures : IFeatureService + { + public string Describe() => "Enterprise: unlimited seats, SSO, 24/7 support"; + } +} diff --git a/examples/Relay.Examples.ContextRouting/Relay.Examples.ContextRouting.csproj b/examples/Relay.Examples.ContextRouting/Relay.Examples.ContextRouting.csproj new file mode 100644 index 0000000..1593aa5 --- /dev/null +++ b/examples/Relay.Examples.ContextRouting/Relay.Examples.ContextRouting.csproj @@ -0,0 +1,15 @@ + + + Exe + net10.0 + enable + enable + + + + + + + + + diff --git a/examples/Relay.Examples.Observability/Program.cs b/examples/Relay.Examples.Observability/Program.cs new file mode 100644 index 0000000..ecd467c --- /dev/null +++ b/examples/Relay.Examples.Observability/Program.cs @@ -0,0 +1,61 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Relay; +using Relay.Core.Interfaces; +using Relay.Diagnostics; +using Relay.Examples.Observability; + +// Production scenario: trace an adapter chain end to end. In a real app you'd wire +// RelayDiagnostics.SourceName into OpenTelemetry; here we attach a plain ActivityListener +// and print each span so you can see the chain's steps. + +using var listener = new ActivityListener +{ + ShouldListenTo = source => source.Name == RelayDiagnostics.SourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStarted = a => Console.WriteLine($" -> start {a.OperationName}"), + ActivityStopped = a => + Console.WriteLine( + $" <- stop {a.OperationName} ({a.Duration.TotalMilliseconds:F1} ms) " + + string.Join(" ", a.Tags.Select(t => $"{t.Key}={t.Value}")) + ), +}; +ActivitySource.AddActivityListener(listener); + +var services = new ServiceCollection(); +services + .AddAdapterChain() + .From() + .Then() + .Finally() + .Build(); + +var provider = services.BuildServiceProvider(); +using var scope = provider.CreateScope(); +var chain = scope.ServiceProvider.GetRequiredService>(); + +Console.WriteLine("Executing traced chain:"); +var receipt = chain.Execute(new Order("SKU-1", 3)); +Console.WriteLine($"Result: {receipt}"); + +namespace Relay.Examples.Observability +{ + public record Order(string Sku, int Quantity); + + public record PricedOrder(string Sku, decimal Total); + + public record Receipt(string Sku, string Total) + { + public override string ToString() => $"{Sku} = {Total}"; + } + + public sealed class PricingAdapter : IAdapter + { + public PricedOrder Adapt(Order source) => new(source.Sku, source.Quantity * 9.99m); + } + + public sealed class ReceiptAdapter : IAdapter + { + public Receipt Adapt(PricedOrder source) => new(source.Sku, $"{source.Total:C}"); + } +} diff --git a/examples/Relay.Examples.Observability/Relay.Examples.Observability.csproj b/examples/Relay.Examples.Observability/Relay.Examples.Observability.csproj new file mode 100644 index 0000000..1593aa5 --- /dev/null +++ b/examples/Relay.Examples.Observability/Relay.Examples.Observability.csproj @@ -0,0 +1,15 @@ + + + Exe + net10.0 + enable + enable + + + + + + + + + diff --git a/examples/Relay.Examples.Resilience/Program.cs b/examples/Relay.Examples.Resilience/Program.cs new file mode 100644 index 0000000..51e0f96 --- /dev/null +++ b/examples/Relay.Examples.Resilience/Program.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.DependencyInjection; +using Relay; +using Relay.Core.Enums; +using Relay.Core.Interfaces; +using Relay.Examples.Resilience; + +// Production scenario: write to a primary storage provider, fall back to a secondary, and +// retry each provider for transient faults before failing over. Mirrors how you'd harden a +// multi-region write path. + +var services = new ServiceCollection(); +services + .AddMultiRelay(config => + config + .AddRelay() + .AddRelay() + .WithStrategy(RelayStrategy.Failover) + .WithRetry(maxAttempts: 3, delay: TimeSpan.FromMilliseconds(50), backoffFactor: 2.0) + ) + .Build(); + +var provider = services.BuildServiceProvider(); +using var scope = provider.CreateScope(); +var storage = scope.ServiceProvider.GetRequiredService>(); + +// Primary fails its first 2 attempts (transient), succeeds on the 3rd — retry recovers it, +// no failover needed. +var result = await storage.RelayToAllWithResults(s => s.SaveAsync("order-42")); +Console.WriteLine($"Saved via: {string.Join(", ", result)}"); + +namespace Relay.Examples.Resilience +{ + public interface IStorageProvider + { + Task SaveAsync(string payload); + } + + // Fails the first two attempts to simulate a transient outage, then succeeds. + public sealed class PrimaryStorage : IStorageProvider + { + private int _attempts; + + public async Task SaveAsync(string payload) + { + _attempts++; + await Task.Delay(10); + if (_attempts < 3) + { + Console.WriteLine($" [primary] attempt {_attempts} failed (transient)"); + throw new TimeoutException("primary temporarily unavailable"); + } + Console.WriteLine($" [primary] attempt {_attempts} succeeded"); + return $"primary:{payload}"; + } + } + + public sealed class SecondaryStorage : IStorageProvider + { + public async Task SaveAsync(string payload) + { + await Task.Delay(10); + return $"secondary:{payload}"; + } + } +} diff --git a/examples/Relay.Examples.Resilience/Relay.Examples.Resilience.csproj b/examples/Relay.Examples.Resilience/Relay.Examples.Resilience.csproj new file mode 100644 index 0000000..1593aa5 --- /dev/null +++ b/examples/Relay.Examples.Resilience/Relay.Examples.Resilience.csproj @@ -0,0 +1,15 @@ + + + Exe + net10.0 + enable + enable + + + + + + + + + diff --git a/src/Builders/ConditionalRelayBuilder.cs b/src/Builders/ConditionalRelayBuilder.cs index b92d9c3..47832b1 100644 --- a/src/Builders/ConditionalRelayBuilder.cs +++ b/src/Builders/ConditionalRelayBuilder.cs @@ -73,8 +73,12 @@ public IServiceCollection Build() } var implementationType = registration.ImplementationType ?? registration.TypeSelector!(context); - return (TInterface) - ActivatorUtilities.CreateInstance(provider, implementationType); + // Reuse an existing DI registration (and its lifetime) when the implementation + // is registered; otherwise create it with its constructor dependencies resolved. + var instance = + provider.GetService(implementationType) + ?? ActivatorUtilities.CreateInstance(provider, implementationType); + return (TInterface)instance; }, _lifetime ) diff --git a/src/Builders/MultiRelayBuilder.cs b/src/Builders/MultiRelayBuilder.cs index bfa2ec6..854d532 100644 --- a/src/Builders/MultiRelayBuilder.cs +++ b/src/Builders/MultiRelayBuilder.cs @@ -2,6 +2,7 @@ using Relay.Core.Enums; using Relay.Core.Implementations; using Relay.Core.Interfaces; +using Relay.Core.Options; namespace Relay.Builders; @@ -15,17 +16,18 @@ public sealed class MultiRelayBuilder(IServiceCollection services) private RelayStrategy _strategy = RelayStrategy.Broadcast; + private RelayResilienceOptions _resilience = RelayResilienceOptions.None; + private ServiceLifetime _lifetime = ServiceLifetime.Scoped; public MultiRelayBuilder AddRelay(ServiceLifetime? lifetime = null) where TRelay : class, TInterface { - var relayLifetime = lifetime ?? _lifetime; + // Registration is materialized in Build() so a later WithDefaultLifetime still applies + // to relays added without an explicit lifetime. _relayRegistrations.Add( - new RelayRegistration { ImplementationType = typeof(TRelay), Lifetime = relayLifetime } + new RelayRegistration { ImplementationType = typeof(TRelay), Lifetime = lifetime } ); - - _services.Add(new ServiceDescriptor(typeof(TRelay), typeof(TRelay), relayLifetime)); return this; } @@ -65,19 +67,60 @@ public MultiRelayBuilder WithParallelExecution() return this; } + /// + /// Retry each relay up to times (with optional delay and + /// exponential backoff) before failing over to the next relay. Applies to the + /// and strategies. + /// + public MultiRelayBuilder WithRetry( + int maxAttempts, + TimeSpan? delay = null, + double backoffFactor = 1.0 + ) + { + if (maxAttempts < 1) + { + throw new ArgumentOutOfRangeException( + nameof(maxAttempts), + "maxAttempts must be at least 1" + ); + } + + _resilience = new RelayResilienceOptions + { + MaxAttempts = maxAttempts, + Delay = delay ?? TimeSpan.Zero, + BackoffFactor = backoffFactor, + }; + return this; + } + public IServiceCollection Build() { + // Materialize each relay registration with its explicit lifetime, or the final default. + foreach (var reg in _relayRegistrations) + { + _services.Add( + new ServiceDescriptor( + reg.ImplementationType, + reg.ImplementationType, + reg.Lifetime ?? _lifetime + ) + ); + } + + var registrations = _relayRegistrations.ToList(); _services.Add( new ServiceDescriptor( typeof(IMultiRelay), provider => { - var relays = _relayRegistrations - .Select(reg => provider.GetService(reg.ImplementationType)) - .OfType() + // Fail loud if a relay cannot be resolved rather than silently dropping it. + var relays = registrations + .Select(reg => (TInterface)provider.GetRequiredService(reg.ImplementationType)) .ToList(); - return new MultiRelay(relays, _strategy); + return new MultiRelay(relays, _strategy, _resilience); }, _lifetime ) @@ -89,6 +132,6 @@ public IServiceCollection Build() private sealed class RelayRegistration { public Type ImplementationType { get; set; } = null!; - public ServiceLifetime Lifetime { get; set; } + public ServiceLifetime? Lifetime { get; set; } } } diff --git a/src/Builders/RelayFactoryBuilder.cs b/src/Builders/RelayFactoryBuilder.cs index ec28015..e2ac072 100644 --- a/src/Builders/RelayFactoryBuilder.cs +++ b/src/Builders/RelayFactoryBuilder.cs @@ -12,12 +12,21 @@ public sealed class RelayFactoryBuilder(IServiceCollection services) private readonly Dictionary> _factories = new(); + // Type-based registrations are materialized in Build() so the configured lifetime applies + // regardless of the order WithLifetime/RegisterRelay are called in. + private readonly List<(Type ImplType, bool Keyed, string Key)> _typeRegistrations = []; + private string? _defaultKey; private Func? _contextKeySelector; private ServiceLifetime _lifetime = ServiceLifetime.Scoped; + /// + /// Register a relay via a factory delegate. Escape hatch for types that are not resolvable + /// from the container (e.g. third-party objects). Prefer + /// so the relay and its dependencies are created by DI. + /// public RelayFactoryBuilder RegisterRelay( string key, Func factory @@ -34,6 +43,10 @@ Func factory return this; } + /// + /// Register a relay implementation resolved from the container. The implementation and its + /// dependencies are created by DI using the configured lifetime. + /// public RelayFactoryBuilder RegisterRelay(string key) where TImplementation : class, TInterface { @@ -42,8 +55,8 @@ public RelayFactoryBuilder RegisterRelay(string key throw new ArgumentException("Key cannot be null or empty", nameof(key)); } - _factories[key] = provider => provider.GetRequiredService(); - _services.AddScoped(); + _factories[key] = static provider => provider.GetRequiredService(); + _typeRegistrations.Add((typeof(TImplementation), false, key)); return this; } @@ -61,10 +74,8 @@ public RelayFactoryBuilder RegisterKeyedRelay(strin throw new ArgumentException("Key cannot be null or empty", nameof(key)); } - _services.Add( - new ServiceDescriptor(typeof(TInterface), key, typeof(TImplementation), _lifetime) - ); _factories[key] = provider => provider.GetRequiredKeyedService(key); + _typeRegistrations.Add((typeof(TImplementation), true, key)); return this; } @@ -98,6 +109,16 @@ public RelayFactoryBuilder SelectKeyByContext(Func), diff --git a/src/Core/Implementations/MultiRelay.cs b/src/Core/Implementations/MultiRelay.cs index 97de897..57de76b 100644 --- a/src/Core/Implementations/MultiRelay.cs +++ b/src/Core/Implementations/MultiRelay.cs @@ -1,5 +1,6 @@ using Relay.Core.Enums; using Relay.Core.Interfaces; +using Relay.Core.Options; using Relay.Diagnostics; namespace Relay.Core.Implementations; @@ -9,13 +10,30 @@ public sealed class MultiRelay : IMultiRelay { private readonly List _relays; private readonly RelayStrategy _strategy; + private readonly RelayResilienceOptions _resilience; private int _roundRobinIndex; public MultiRelay(IEnumerable relays, RelayStrategy strategy) + : this(relays, strategy, RelayResilienceOptions.None) { } + + public MultiRelay( + IEnumerable relays, + RelayStrategy strategy, + RelayResilienceOptions resilience + ) { ArgumentNullException.ThrowIfNull(relays); + ArgumentNullException.ThrowIfNull(resilience); + if (resilience.MaxAttempts < 1) + { + throw new ArgumentOutOfRangeException( + nameof(resilience), + "MaxAttempts must be at least 1" + ); + } _relays = relays.ToList(); _strategy = strategy; + _resilience = resilience; } public IEnumerable GetRelays() => _relays; @@ -106,7 +124,7 @@ Func> operation { try { - return await operation(relay); + return await ExecuteWithRetry(relay, operation); } catch (Exception ex) { @@ -123,7 +141,14 @@ private async Task ExecuteFirstSuccessful(Func operation) { try { - await operation(relay); + await ExecuteWithRetry( + relay, + async r => + { + await operation(r); + return true; + } + ); return; } catch (Exception ex) @@ -134,6 +159,38 @@ private async Task ExecuteFirstSuccessful(Func operation) throw new InvalidOperationException("No relay succeeded", lastException); } + /// + /// Invokes on a single relay, retrying up to + /// MaxAttempts times with the configured delay/backoff before giving up. + /// + private async Task ExecuteWithRetry( + TInterface relay, + Func> operation + ) + { + var delay = _resilience.Delay; + Exception? lastException = null; + + for (var attempt = 1; attempt <= _resilience.MaxAttempts; attempt++) + { + try + { + return await operation(relay); + } + catch (Exception ex) + { + lastException = ex; + if (attempt < _resilience.MaxAttempts && delay > TimeSpan.Zero) + { + await Task.Delay(delay); + delay *= _resilience.BackoffFactor; + } + } + } + + throw lastException!; + } + private async Task ExecuteFailover(Func> operation) { return await ExecuteFirstSuccessful(operation); diff --git a/src/Core/Options/RelayResilienceOptions.cs b/src/Core/Options/RelayResilienceOptions.cs new file mode 100644 index 0000000..2403877 --- /dev/null +++ b/src/Core/Options/RelayResilienceOptions.cs @@ -0,0 +1,30 @@ +namespace Relay.Core.Options; + +/// +/// Per-relay resilience settings applied when a multi-relay uses the +/// or +/// strategy. Each relay is retried up to times before the multi-relay +/// moves on to the next relay. +/// +public sealed record RelayResilienceOptions +{ + /// + /// Maximum number of attempts per relay (including the first). Must be at least 1. + /// A value of 1 (the default) disables retries. + /// + public int MaxAttempts { get; init; } = 1; + + /// + /// Delay between attempts on the same relay. Defaults to no delay. + /// + public TimeSpan Delay { get; init; } = TimeSpan.Zero; + + /// + /// Multiplier applied to after each failed attempt (exponential backoff). + /// Defaults to 1.0 (constant delay). + /// + public double BackoffFactor { get; init; } = 1.0; + + /// The default options: a single attempt, no retry. + public static RelayResilienceOptions None { get; } = new(); +} diff --git a/src/Relay.csproj b/src/Relay.csproj index 81ac40c..6c99216 100644 --- a/src/Relay.csproj +++ b/src/Relay.csproj @@ -3,7 +3,7 @@ net10.0; Relay Relay - Adaptive Dependency Injection - 1.1.0 + 1.2.0 Taras Kovalenko Copyright Taras Kovalenko Relay diff --git a/tests/Relay.Tests/Builders/MultiRelayBuilderTests.cs b/tests/Relay.Tests/Builders/MultiRelayBuilderTests.cs index 59c82bc..b227ce9 100644 --- a/tests/Relay.Tests/Builders/MultiRelayBuilderTests.cs +++ b/tests/Relay.Tests/Builders/MultiRelayBuilderTests.cs @@ -35,6 +35,7 @@ public void AddRelay_WithDefaultLifetime_ShouldAddRelay() // Act var result = builder.AddRelay(); + builder.Build(); // Assert result.ShouldBe(builder); @@ -49,6 +50,7 @@ public void AddRelay_WithSpecificLifetime_ShouldUseSpecificLifetime() // Act var result = builder.AddRelay(ServiceLifetime.Singleton); + builder.Build(); // Assert result.ShouldBe(builder); diff --git a/tests/Relay.Tests/Features/CoverageTests.cs b/tests/Relay.Tests/Features/CoverageTests.cs new file mode 100644 index 0000000..fd1292c --- /dev/null +++ b/tests/Relay.Tests/Features/CoverageTests.cs @@ -0,0 +1,539 @@ +using Microsoft.Extensions.DependencyInjection; +using Relay.Builders; +using Relay.Core.Enums; +using Relay.Core.Implementations; +using Relay.Core.Interfaces; +using Relay.Core.Options; +using Relay.Decorators; +using Shouldly; + +namespace Relay.Tests.Features; + +// Targeted tests filling coverage gaps (guard clauses, rarely-hit branches, direct engine calls). +public class CoverageTests +{ + // ---- shared types ---- + public record Src(int N); + + public record Mid(int N); + + public record Mid2(int N); + + public record Dst(string S); + + public sealed class IncAdapter : IAsyncAdapter + { + public Task AdaptAsync(Src source, CancellationToken ct = default) => + Task.FromResult(new Mid(source.N + 1)); + } + + public sealed class Inc2Adapter : IAsyncAdapter + { + public Task AdaptAsync(Mid source, CancellationToken ct = default) => + Task.FromResult(new Mid2(source.N + 1)); + } + + public sealed class FmtAdapter : IAsyncAdapter + { + public Task AdaptAsync(Mid2 source, CancellationToken ct = default) => + Task.FromResult(new Dst($"v{source.N}")); + } + + public sealed class DirectAsync : IAsyncAdapter + { + public Task AdaptAsync(Src source, CancellationToken ct = default) => + Task.FromResult(new Dst($"d{source.N}")); + } + + public record SourceData(string Value); + + public record FinalData(string Value); + + public sealed class SrcToFinal : IAdapter + { + public FinalData Adapt(SourceData source) => new(source.Value); + } + + public interface IFlaky + { + Task CallAsync(); + } + + public sealed class GoodRelay(string id) : IFlaky + { + public int Calls { get; private set; } + + public Task CallAsync() + { + Calls++; + return Task.FromResult(id); + } + } + + public sealed class BadRelay : IFlaky + { + public Task CallAsync() => throw new InvalidOperationException("always fails"); + } + + public sealed class OnceFlaky(string id) : IFlaky + { + private int _calls; + + public Task CallAsync() + { + _calls++; + if (_calls == 1) + { + throw new InvalidOperationException("transient"); + } + return Task.FromResult(id); + } + } + + private static ServiceProvider Empty() => new ServiceCollection().BuildServiceProvider(); + + // ---- AdapterChain.ExecuteCore wrong source ---- + [Fact] + public void AdapterChain_ExecuteCore_WrongSource_Throws() + { + var steps = new List + { + new() + { + SourceType = typeof(SourceData), + TargetType = typeof(FinalData), + AdapterType = typeof(SrcToFinal), + IsFinalStep = true, + }, + }; + var chain = new AdapterChain(Empty(), steps); + + Should.Throw(() => chain.ExecuteCore("not-a-source")); + } + + // ---- AsyncAdapterChain direct error paths ---- + [Fact] + public async Task AsyncAdapterChain_EmptySteps_Throws() + { + var chain = new AsyncAdapterChain(Empty(), []); + await Should.ThrowAsync(() => chain.ExecuteAsync(new Src(1))); + } + + [Fact] + public async Task AsyncAdapterChain_WrongSource_Throws() + { + var steps = new List + { + new() + { + SourceType = typeof(Src), + TargetType = typeof(Mid), + AdapterType = typeof(IncAdapter), + IsFinalStep = true, + }, + }; + var chain = new AsyncAdapterChain(Empty(), steps); + await Should.ThrowAsync(() => chain.ExecuteAsync(new Mid(1))); + } + + [Fact] + public async Task AsyncAdapterChain_FinalCastMismatch_Throws() + { + var services = new ServiceCollection(); + services.AddScoped(); + var provider = services.BuildServiceProvider(); + + var steps = new List + { + new() + { + SourceType = typeof(Src), + TargetType = typeof(Mid), + AdapterType = typeof(IncAdapter), + IsFinalStep = true, + }, + }; + var chain = new AsyncAdapterChain(provider, steps); + await Should.ThrowAsync(() => chain.ExecuteAsync(new Src(1))); + } + + // ---- Async builder: From().Finally() and multi-intermediate ---- + [Fact] + public async Task AsyncChain_SingleFinalStep_Works() + { + var services = new ServiceCollection(); + services.AddAsyncAdapterChain().From().Finally().Build(); + + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var chain = scope.ServiceProvider.GetRequiredService>(); + (await chain.ExecuteAsync(new Src(5))).S.ShouldBe("d5"); + } + + [Fact] + public async Task AsyncChain_MultiIntermediate_Works() + { + var services = new ServiceCollection(); + services + .AddAsyncAdapterChain() + .From() + .Then() + .Then() + .Finally() + .Build(); + + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var chain = scope.ServiceProvider.GetRequiredService>(); + (await chain.ExecuteAsync(new Src(1))).S.ShouldBe("v3"); + } + + // ---- Typed chain proxy (intermediate then final) ---- + public record TSrc(string V); + + public record TInter(string V); + + public record TDst(string V); + + public sealed class TSrcToInter : IAdapter + { + public TInter Adapt(TSrc source) => new($"{source.V}-i"); + } + + public sealed class TInterToDst : IAdapter + { + public TDst Adapt(TInter source) => new($"{source.V}-d"); + } + + [Fact] + public void TypedAdapterChain_WithIntermediate_BuildsAndExecutes() + { + var services = new ServiceCollection(); + services + .AddTypedAdapterChain() + .Then() + .Then() + .Build(); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var chain = scope.ServiceProvider.GetRequiredService>(); + chain.Execute(new TSrc("x")).V.ShouldBe("x-i-d"); + } + + // ---- RelayResolver without accessor registered ---- + [Fact] + public void RelayResolver_NoAccessor_ResolvesDirectly() + { + var services = new ServiceCollection(); + services.AddScoped(); + using var provider = services.BuildServiceProvider(); + + var resolver = new RelayResolver(provider); + resolver.Resolve().ShouldBeOfType(); + } + + // ---- MultiRelay ctor + RelayToAll strategies + retry delay ---- + [Fact] + public void MultiRelay_InvalidResilience_Throws() + { + Should.Throw(() => + new MultiRelay([new GoodRelay("a")], RelayStrategy.Failover, new RelayResilienceOptions { MaxAttempts = 0 }) + ); + } + + [Fact] + public async Task MultiRelay_RelayToAll_Parallel_CallsAll() + { + var a = new GoodRelay("a"); + var b = new GoodRelay("b"); + var multi = new MultiRelay([a, b], RelayStrategy.Parallel); + + await multi.RelayToAll(r => r.CallAsync()); + + a.Calls.ShouldBe(1); + b.Calls.ShouldBe(1); + } + + [Fact] + public async Task MultiRelay_RelayToAll_RoundRobin_CallsOne() + { + var a = new GoodRelay("a"); + var b = new GoodRelay("b"); + var multi = new MultiRelay([a, b], RelayStrategy.RoundRobin); + + await multi.RelayToAll(r => r.CallAsync()); + + (a.Calls + b.Calls).ShouldBe(1); + } + + [Fact] + public async Task MultiRelay_RelayToAll_Failover_AllFail_Throws() + { + var multi = new MultiRelay([new BadRelay(), new BadRelay()], RelayStrategy.Failover); + + await Should.ThrowAsync(() => multi.RelayToAll(r => r.CallAsync())); + } + + [Fact] + public async Task MultiRelay_RelayToAll_Failover_Succeeds() + { + var good = new GoodRelay("good"); + var multi = new MultiRelay([new BadRelay(), good], RelayStrategy.Failover); + + await multi.RelayToAll(r => r.CallAsync()); + + good.Calls.ShouldBe(1); + } + + [Fact] + public async Task MultiRelay_Retry_WithDelay_RecoversTransient() + { + var relay = new OnceFlaky("ok"); + var multi = new MultiRelay( + [relay], + RelayStrategy.FirstSuccessful, + new RelayResilienceOptions + { + MaxAttempts = 2, + Delay = TimeSpan.FromMilliseconds(1), + BackoffFactor = 2.0, + } + ); + + (await multi.RelayToAllWithResults(r => r.CallAsync())).Single().ShouldBe("ok"); + } + + // ---- MultiRelayBuilder.WithRetry success path ---- + [Fact] + public async Task MultiRelayBuilder_WithRetry_Configures() + { + var services = new ServiceCollection(); + new MultiRelayBuilder(services) + .AddRelay() + .WithStrategy(RelayStrategy.FirstSuccessful) + .WithRetry(2, TimeSpan.FromMilliseconds(1), 1.5) + .Build(); + services.AddSingleton(new OnceFlaky("done")); + + // Re-register OnceFlaky as the concrete relay resolves it; ensure build succeeded. + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + scope.ServiceProvider.GetRequiredService>().ShouldNotBeNull(); + } + + // ---- RelayFactoryBuilder delegate form + guards ---- + [Fact] + public void RelayFactory_DelegateRegistration_Works() + { + var services = new ServiceCollection(); + new RelayFactoryBuilder(services) + .RegisterRelay("k", _ => new TestServiceA()) + .WithLifetime(ServiceLifetime.Singleton) + .SetDefaultRelay("k") + .Build(); + + using var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService>(); + factory.CreateRelay("k").ShouldBeOfType(); + factory.GetDefaultRelay().ShouldBeOfType(); + } + + [Fact] + public void RelayFactoryBuilder_EmptyKeyGuards_Throw() + { + var b = new RelayFactoryBuilder(new ServiceCollection()); + Should.Throw(() => b.RegisterRelay("", _ => new TestServiceA())); + Should.Throw(() => b.RegisterRelay("k", null!)); + Should.Throw(() => b.RegisterRelay("")); + Should.Throw(() => b.RegisterKeyedRelay("")); + Should.Throw(() => b.SetDefaultRelay("")); + } + + // ---- AdapterChainFactoryBuilder guards + WithLifetime ---- + [Fact] + public void AdapterChainFactoryBuilder_EmptyNameGuards_Throw() + { + var b = new AdapterChainFactoryBuilder(new ServiceCollection()); + Should.Throw(() => b.AddChain("", _ => new FinalData("x"))); + Should.Throw(() => b.AddChain("")); + } + + [Fact] + public void AdapterChainFactoryBuilder_WithLifetime_Applies() + { + var services = new ServiceCollection(); + new AdapterChainFactoryBuilder(services) + .WithLifetime(ServiceLifetime.Singleton) + .AddChain("a", _ => new FinalData("a")) + .Build(); + + var descriptor = services.First(s => + s.ServiceType == typeof(IAdapterChainFactory) + ); + descriptor.Lifetime.ShouldBe(ServiceLifetime.Singleton); + } + + // ---- Decorator second overload: not-registered + instance-based ---- + [Fact] + public void Decorate_Func_ServiceNotRegistered_Throws() + { + var services = new ServiceCollection(); + Should.Throw(() => + services.Decorate((inner, _) => new LoggingDecorator(inner)) + ); + } + + [Fact] + public void Decorate_Func_InstanceRegistration_Wraps() + { + var services = new ServiceCollection(); + services.AddSingleton(new TestServiceA()); + services.Decorate((inner, _) => new LoggingDecorator(inner)); + + using var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + service.Process("x").ShouldBe("[Logged] ServiceA: x"); + } + + // ---- Reachable contract throws ---- + public sealed class NotAnAdapter; // intentionally does not implement IAdapter<,> + + public record WrongData(string Value); + + public sealed class SrcToWrong : IAdapter + { + public WrongData Adapt(SourceData source) => new(source.Value); + } + + [Fact] + public void AdapterChain_AdapterMissingInterface_Throws() + { + var services = new ServiceCollection(); + services.AddScoped(); + var provider = services.BuildServiceProvider(); + + var steps = new List + { + new() + { + SourceType = typeof(SourceData), + TargetType = typeof(FinalData), + AdapterType = typeof(NotAnAdapter), + IsFinalStep = true, + }, + }; + var chain = new AdapterChain(provider, steps); + + Should.Throw(() => chain.ExecuteCore(new SourceData("x"))); + } + + [Fact] + public void AdapterChain_FinalCastMismatch_Throws() + { + var services = new ServiceCollection(); + services.AddScoped(); + var provider = services.BuildServiceProvider(); + + var steps = new List + { + new() + { + SourceType = typeof(SourceData), + TargetType = typeof(WrongData), + AdapterType = typeof(SrcToWrong), + IsFinalStep = true, + }, + }; + var chain = new AdapterChain(provider, steps); + + Should.Throw(() => chain.ExecuteCore(new SourceData("x"))); + } + + // ---- Public constructor / builder null guards ---- + [Fact] + public void NullGuards_Throw() + { + var provider = Empty(); + var steps = new List(); + var services = new ServiceCollection(); + + Should.Throw(() => new AdapterChain(null!, steps)); + Should.Throw(() => new AdapterChain(provider, null!)); + Should.Throw(() => new AsyncAdapterChain(null!, steps)); + Should.Throw(() => new AsyncAdapterChain(provider, null!)); + Should.Throw(() => new MultiRelay(null!, RelayStrategy.Broadcast)); + Should.Throw(() => new TypedAdapterChain(null!)); + Should.Throw(() => new DefaultRelayContext(null!)); + Should.Throw(() => new RelayResolver(null!)); + Should.Throw(() => + new RelayFactory(null!, provider, null) + ); + Should.Throw(() => + new RelayFactory( + new Dictionary>(), + null!, + null + ) + ); + Should.Throw(() => new MultiRelayBuilder(null!)); + Should.Throw(() => new RelayFactoryBuilder(null!)); + Should.Throw(() => new AdapterChainFactoryBuilder(null!)); + Should.Throw(() => new ConditionalRelayBuilder(null!)); + Should.Throw(() => services.AddRelay(null!)); + } + + // ---- Conditional relay TypeSelector path ---- + [Fact] + public void ConditionalRelay_TypeSelector_Resolves() + { + var services = new ServiceCollection(); + services.AddRelayServices(); + services + .AddConditionalRelay() + .When(_ => true) + .RelayTo(_ => typeof(TestServiceA)) + .Build(); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + scope.ServiceProvider.GetRequiredService().ShouldBeOfType(); + } + + [Fact] + public void ConditionalRelay_NoMatch_Throws() + { + var services = new ServiceCollection(); + services.AddRelayServices(); + services.AddConditionalRelay().When(_ => false).RelayTo().Build(); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + Should.Throw(() => + scope.ServiceProvider.GetRequiredService() + ); + } + + // ---- Decorator first (Type) overload across registration sources ---- + [Fact] + public void Decorate_Type_FactoryRegistration_Wraps() + { + var services = new ServiceCollection(); + services.AddScoped(_ => new TestServiceA()); + services.Decorate(typeof(LoggingDecorator)); + + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().Process("x").ShouldBe("[Logged] ServiceA: x"); + } + + [Fact] + public void Decorate_Type_InstanceRegistration_Wraps() + { + var services = new ServiceCollection(); + services.AddSingleton(new TestServiceA()); + services.Decorate(typeof(LoggingDecorator)); + + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().Process("x").ShouldBe("[Logged] ServiceA: x"); + } +} diff --git a/tests/Relay.Tests/Features/NewFeaturesTests.cs b/tests/Relay.Tests/Features/NewFeaturesTests.cs index cfff6c4..1a8b6a5 100644 --- a/tests/Relay.Tests/Features/NewFeaturesTests.cs +++ b/tests/Relay.Tests/Features/NewFeaturesTests.cs @@ -1,7 +1,10 @@ using System.Diagnostics; using Microsoft.Extensions.DependencyInjection; +using Relay.Builders; +using Relay.Core.Enums; using Relay.Core.Implementations; using Relay.Core.Interfaces; +using Relay.Core.Options; using Relay.Diagnostics; using Shouldly; @@ -160,6 +163,122 @@ public void RelayFactory_KeyedAndContextSelector_Work() factory.CreateRelay(ctx).ShouldBeOfType(); } + // ---- Lifetime-ordering fixes ---- + [Fact] + public void RelayFactory_WithLifetimeAfterRegister_AppliesLifetime() + { + var services = new ServiceCollection(); + new RelayFactoryBuilder(services) + .RegisterRelay("a") + .WithLifetime(ServiceLifetime.Singleton) // called AFTER RegisterRelay + .Build(); + + var descriptor = services.First(s => s.ServiceType == typeof(TestServiceA)); + descriptor.Lifetime.ShouldBe(ServiceLifetime.Singleton); + } + + [Fact] + public void MultiRelay_WithDefaultLifetimeAfterAddRelay_AppliesLifetime() + { + var services = new ServiceCollection(); + new MultiRelayBuilder(services) + .AddRelay() + .WithDefaultLifetime(ServiceLifetime.Singleton) // called AFTER AddRelay + .WithStrategy(RelayStrategy.Broadcast) + .Build(); + + var descriptor = services.First(s => s.ServiceType == typeof(TestServiceA)); + descriptor.Lifetime.ShouldBe(ServiceLifetime.Singleton); + } + + [Fact] + public void MultiRelay_ExplicitRelayLifetime_OverridesDefault() + { + var services = new ServiceCollection(); + new MultiRelayBuilder(services) + .WithDefaultLifetime(ServiceLifetime.Singleton) + .AddRelay(ServiceLifetime.Transient) + .Build(); + + var descriptor = services.First(s => s.ServiceType == typeof(TestServiceA)); + descriptor.Lifetime.ShouldBe(ServiceLifetime.Transient); + } + + // ---- Resilience / retry ---- + public interface IFlaky + { + Task CallAsync(); + } + + public sealed class FlakyRelay(int failUntil, string id) : IFlaky + { + public int Calls { get; private set; } + + public Task CallAsync() + { + Calls++; + if (Calls <= failUntil) + { + throw new InvalidOperationException("transient"); + } + return Task.FromResult(id); + } + } + + [Fact] + public async Task Retry_RecoversTransientFailureOnSameRelay() + { + var relay = new FlakyRelay(failUntil: 2, id: "A"); + var multi = new MultiRelay( + [relay], + RelayStrategy.FirstSuccessful, + new RelayResilienceOptions { MaxAttempts = 3 } + ); + + var result = await multi.RelayToAllWithResults(r => r.CallAsync()); + + result.Single().ShouldBe("A"); + relay.Calls.ShouldBe(3); + } + + [Fact] + public async Task NoRetry_FailsOverToNextRelay() + { + var bad = new FlakyRelay(int.MaxValue, "bad"); + var good = new FlakyRelay(0, "good"); + var multi = new MultiRelay([bad, good], RelayStrategy.Failover); + + var result = await multi.RelayToAllWithResults(r => r.CallAsync()); + + result.Single().ShouldBe("good"); + } + + [Fact] + public async Task Retry_Exhausted_ThenFailsOver() + { + var bad = new FlakyRelay(int.MaxValue, "bad"); + var good = new FlakyRelay(0, "good"); + var multi = new MultiRelay( + [bad, good], + RelayStrategy.Failover, + new RelayResilienceOptions { MaxAttempts = 2 } + ); + + var result = await multi.RelayToAllWithResults(r => r.CallAsync()); + + result.Single().ShouldBe("good"); + bad.Calls.ShouldBe(2); // retried twice before failover + } + + [Fact] + public void WithRetry_InvalidAttempts_Throws() + { + var services = new ServiceCollection(); + Should.Throw(() => + new MultiRelayBuilder(services).WithRetry(0) + ); + } + // ---- Diagnostics ---- [Fact] public async Task AdapterChain_EmitsActivity()