diff --git a/.gitignore b/.gitignore index 3782168..51b4c07 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,10 @@ artifacts/ !.vscode/launch.json !.vscode/extensions.json +# ── Local build settings (per-machine) ─────────────────────── +# Optional override file. Directory.Build.props imports it when it exists. +Directory.Local.props + # ── NuGet package output ───────────────────────────────────── # Packing RequestFlow / RequestFlow.Cqrs. Symbols too (.snupkg). *.nupkg diff --git a/CHANGELOG.md b/CHANGELOG.md index 3412131..ac9a9f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,24 @@ Releases are cut from this file. The `release` workflow reads the section matchi ## [Unreleased] +### Added + +- `Continuation.Over(rest)` and `Continuation.Over(rest)` stand in for the rest of the chain with a delegate, so you can unit-test a stage with no container and no dispatcher. The delegate receives the token the stage passed to `InvokeAsync`; a call that names none falls back to `Over`'s optional second argument, which takes the place of the token the stage itself received. A default `Continuation` has no chain behind it, so `InvokeAsync` on one throws `InvalidOperationException`. [stages.md](docs/stages.md) has a retry stage tested this way. + ### Changed -- A level resolves its stage, and the bottom level resolves the handler, on every entry rather than once per dispatch. The lifetime a stage was registered with now decides what a repeated `next` call reaches: a transient stage is built again for the retry, a scoped one stays the same instance for the scope. A retry over a transient chain therefore gets a clean instance instead of the one the failed attempt left behind. The instances a repeated call leaves behind are the scope's to dispose, which matters most under a root-resolved dispatcher; [lifetimes.md](docs/lifetimes.md) has that case. +- `IContinuation` and `IContinuation` are gone. A stage now takes `Continuation` or `Continuation`, two `readonly struct`s. They wrap a chain that is built once, when the dispatch map freezes, and every call carries its own provider and cancellation token through it. The calls you make on `next` do not change, so migrating a stage means editing one parameter type. Its tests take more: a struct cannot be substituted, so build a real one with `Over`. +- Every entry into a level resolves the stage there, and the bottom level resolves the handler. That used to happen once per dispatch. So the lifetime you registered now decides what a second `next` call gets: a transient stage is built again, a scoped one comes back as the same instance. [lifetimes.md](docs/lifetimes.md) covers who disposes the extra instances when the dispatcher comes from the root provider. ### Removed -- `OverlappingNextCallException`. Calling `next` while an earlier call is still running is no longer an error. Each call enters the levels below it on its own and keeps the token it was handed, so two calls from one stage run the rest of the chain side by side over no state of RequestFlow's that either can disturb. Fan-out shapes such as hedging and shadow comparison work now. What the guard used to rule out comes with it: a scoped or singleton stage under a stage that overlaps its calls is one instance inside two walks at once, so it has to be thread safe within a single dispatch and not only across dispatches. A transient stage is resolved per call and stays clear of it. A stage that starts a second call also owns the first one, and [stages.md](docs/stages.md) has the shape that keeps a failure on either call from abandoning a walk nobody awaits. +- `OverlappingNextCallException`. A stage can now call `next` again while an earlier call is still running, which is what hedging and shadow comparison need. The catch: a scoped or singleton stage below an overlapping one is a single instance running in two walks at once, so it has to be thread safe inside one dispatch and not only across dispatches. A transient stage stays clear of that. [stages.md](docs/stages.md) shows how to keep a failure on one call from leaving the other walk unawaited. ### Performance -- No atomic operations left on the `next` path. A single pass through N stages allocates what it did before, one object per level it enters; a repeated `next` call now allocates the levels it re-enters instead of reusing the first call's. +- Allocation per dispatch no longer grows with the chain. Levels are built once, when the dispatch map freezes, so five stages cost what no stages cost. A repeated `next` call allocates nothing of RequestFlow's. Stage instances are still the container's to allocate, on the lifetime you registered. +- No atomic operations left on the `next` path. +- Void requests still cross a `Task` to `Task` bridge at every level. It is free for a level that already finished, and for a stage that hands back the task its own `next` call returned. A stage marked `async` pays one task per level of that shape. ## [1.0.0-preview.4] - 2026-08-03 diff --git a/Directory.Build.props b/Directory.Build.props index 637e008..10a6d7f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -23,4 +23,7 @@ + + + diff --git a/docs/lifetimes.md b/docs/lifetimes.md index 1ced414..1a5aee1 100644 --- a/docs/lifetimes.md +++ b/docs/lifetimes.md @@ -13,7 +13,7 @@ What RequestFlow registers, with which lifetime, and what you can change. ## Configuring handler lifetime -Handlers are transient by default: every call into the handler gets a fresh instance, so a handler can hold mutable state without leaking it into the next dispatch. The bottom of a stage chain resolves on each entry, so a retry stage that runs the chain twice reaches a second instance rather than the one that failed. Call `WithScopedHandlers` when handlers share per-request dependencies such as a `DbContext`; it chains with the registration methods: +Handlers are transient by default. Every call into the handler gets a fresh instance, so a handler can hold mutable state without leaking it into the next dispatch. The bottom of a stage chain resolves on each entry, so a retry stage that runs the chain twice reaches a second instance rather than the one that failed. Call `WithScopedHandlers` when handlers share per-request dependencies such as a `DbContext`. It chains with the registration methods: ```csharp services.AddRequestFlow(o => o @@ -21,16 +21,16 @@ services.AddRequestFlow(o => o .WithScopedHandlers()); ``` -Those two are the whole set. There is no singleton option, because a singleton handler pins every dependency it injects for the life of the process, and the dependency it usually injects is a `DbContext`. To register one anyway, add it yourself after the last `AddRequestFlow` call: +Those two are the whole set. There is no singleton option: a singleton handler pins every dependency it injects for the life of the process, and that dependency is usually a `DbContext`. To register one anyway, add it yourself after the last `AddRequestFlow` call: ```csharp services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()); services.AddSingleton, PingHandler>(); ``` -Order matters there, and one rule covers handlers and stages alike: register your own after the last `AddRequestFlow` call. RequestFlow appends its descriptors whatever the collection already holds, and the container resolves the last descriptor registered for a service type, so yours has to come second. +Order matters there, and one rule covers handlers and stages alike: register your own after the last `AddRequestFlow` call. RequestFlow appends its descriptors whatever the collection already holds, and the container resolves the last descriptor registered for a service type. So yours has to come second. -- "Last call" is literal. A registration made between two calls usually does win, since a call registers only what it newly discovers and skips a handler or closed stage type an earlier call already registered. It loses when the later call scans a new assembly and is the first to close an already declared stage over one of the new request types. Going after the last call saves you from telling those apart. +- "Last call" is literal. A registration made between two calls usually does win, because a call registers only what it newly discovers and skips any handler or closed stage type an earlier call already registered. It loses in one case: the later call scans a new assembly and is the first to close an already declared stage over one of the new request types. Going after the last call saves you from telling those apart. - A descriptor yours overrules stays in the collection and never resolves, but `ValidateOnBuild` still walks it and checks the constructor it names. - Supplying an instance you built yourself calls for `services.Replace`, which drops the leftover descriptor along the way. [stages.md](stages.md#replacing-a-stage-registration) has the example. @@ -52,7 +52,7 @@ services.AddRequestFlow(o => o An assembly already registered by an earlier call is skipped, so its handlers keep the lifetime of the call that first registered it. The same rule applies to `RegisterGenericHandler`: registering a generic handler for the same closing type again does nothing, and the first registration's lifetime wins. -`WithScopedHandlers` is the opposite of the dispatcher's rule below, where the first call fixes the lifetime for everyone. Handlers belong to the call that found them; the dispatcher is one service shared by all of them. +`WithScopedHandlers` is the opposite of the dispatcher's rule below, where the first call fixes the lifetime for everyone. Handlers belong to the call that found them. The dispatcher is one service shared by all of them. ## Stage lifetime is per stage @@ -68,14 +68,14 @@ services.AddRequestFlow(o => o Stages get the singleton option handlers do not, because a stage is usually the cross-cutting kind of class that holds no dependency worth pinning. The rest of the container's rules still apply: - A singleton stage is shared by every dispatch in the process, so it has to be thread safe, and anything it injects lives as long as it does. -- A scoped stage resolved from the root provider is the quiet case. With scope validation on, the resolution throws at dispatch; with it off, the root provider builds the stage and caches it there, so one instance serves every dispatch until the process exits. `WithTransientDispatcher` plus a dispatcher injected into a singleton is how a chain arrives there, and a unit-of-work stage shared across every request corrupts data rather than failing. -- A stage above that overlaps its `next` calls runs the levels below it side by side, so within one dispatch a scoped or singleton stage under it is entered twice at once and has to be thread safe on that path too. Only transient stays clear of it, because every call resolves an instance of its own. [stages.md](stages.md) has the shape. +- A scoped stage resolved from the root provider is the quiet case. With scope validation on, the resolution throws at dispatch. With it off, the root provider builds the stage and caches it there, so one instance serves every dispatch until the process exits. A chain arrives there through `WithTransientDispatcher` plus a dispatcher injected into a singleton. A unit-of-work stage shared across every request corrupts data rather than failing. +- A stage above that overlaps its `next` calls runs the levels below it side by side. So within one dispatch, a scoped or singleton stage under it is entered twice at once and has to be thread safe on that path too. Only transient stays clear of it, because every call resolves an instance of its own. [stages.md](stages.md) has the shape. - A transient stage that owns an `IDisposable` is tracked by the scope that resolved it, which is the root scope for a root-resolved dispatcher. -- Repeated `next` calls multiply that. A level is resolved once per entry, so a retry stage that makes three attempts leaves three instances behind, and a hedging stage leaves one per branch. Inside a request scope they are disposed when the request ends. Under a root-resolved dispatcher they go on the root provider's disposal list instead, where nothing releases them until the process exits and the list grows with every dispatch. +- Repeated `next` calls multiply that. A level is resolved once per entry, so a retry stage that makes three attempts leaves three instances behind, and a hedging stage leaves one per branch. Inside a request scope they are disposed when the request ends. Under a root-resolved dispatcher they go on the root provider's disposal list instead. Nothing releases them until the process exits, and the list grows with every dispatch. -Catching the first at startup takes both container flags. `ValidateOnBuild` walks every descriptor and builds its constructor graph, and every closed stage type is a registered service, so stages are in that walk. The lifetime comparison behind "Cannot consume scoped service" is `ValidateScopes`. Turn on the pair, which is what ASP.NET Core turns on in Development: under a bare `BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true })` a singleton stage holding a scoped `DbContext` starts up clean. +Catching the first at startup takes both container flags. `ValidateOnBuild` walks every descriptor and builds its constructor graph. Every closed stage type is a registered service, so stages are in that walk. The lifetime comparison behind "Cannot consume scoped service" comes from `ValidateScopes`. Turn on the pair, which is what ASP.NET Core turns on in Development. Under a bare `BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true })`, a singleton stage holding a scoped `DbContext` starts up clean. -`AddStage` registers each closed stage type the way the scan registers handlers, so the ordering rule above applies unchanged: the declaration's descriptor comes last, its lifetime is the one that applies, and a descriptor the declaration got to overrule is left behind unresolved. To supply your own, call `services.Replace` afterwards. It drops one descriptor for the type and appends yours, where a plain `AddSingleton` leaves the old one for `ValidateOnBuild` to check. One is all it drops, so if the type also has a descriptor of your own from before `AddRequestFlow`, use `services.RemoveAll()` and then register. [stages.md](stages.md) has the example. +`AddStage` registers each closed stage type the way the scan registers handlers, so the ordering rule above applies unchanged. The declaration's descriptor comes last, its lifetime is the one that applies, and any descriptor it overrules is left behind unresolved. To supply your own, call `services.Replace` afterwards: it drops one descriptor for the type and appends yours, where a plain `AddSingleton` leaves the old one for `ValidateOnBuild` to check. One is all `Replace` drops. If the type also has a descriptor of your own from before `AddRequestFlow`, use `services.RemoveAll()` and then register. [stages.md](stages.md) has the example. ## Why the dispatcher is scoped @@ -109,11 +109,11 @@ services.AddRequestFlow(o => o Dispatch behavior does not change: a transient dispatcher still resolves handlers from the provider it was created from. Two things do change: - Each injection point gets its own dispatcher instance instead of sharing one per scope. Creating a dispatcher is cheap, so this costs nothing in practice. -- A singleton can now inject `IRequestDispatcher` directly, because scope validation allows transient services at the root. That dispatcher resolves handlers from the root provider, which moves a scoped handler's problem past startup and, with scope validation off, out of sight: the root provider builds the handler, caches it, and hands the same instance to every dispatch for the life of the process. With scope validation on the dispatch throws "Cannot resolve scoped service" instead. Prefer the `IServiceScopeFactory` pattern above: it keeps the failure at startup and gives each unit of work its own scope. +- A singleton can now inject `IRequestDispatcher` directly, because scope validation allows transient services at the root. That dispatcher resolves handlers from the root provider, which moves a scoped handler's problem past startup. With scope validation off it also moves out of sight: the root provider builds the handler, caches it, and hands the same instance to every dispatch for the life of the process. With scope validation on, the dispatch throws "Cannot resolve scoped service" instead. Prefer the `IServiceScopeFactory` pattern above. It keeps the failure at startup and gives each unit of work its own scope. -There is a quieter cost to root-resolved dispatch: the container tracks every transient `IDisposable` it creates in the scope that resolved it, and the root scope only ends at application shutdown. A transient handler that is (or owns) an `IDisposable`, dispatched through a root-resolved dispatcher, is therefore kept alive by the root provider on every send; memory grows for the life of the process. Inside a request scope or an explicit `IServiceScopeFactory` scope the same handler is disposed at scope end. This is standard Microsoft DI behavior, not something RequestFlow can override, and one more reason to prefer the scope-per-unit-of-work pattern. +Root-resolved dispatch has a quieter cost. The container tracks every transient `IDisposable` it creates in the scope that resolved it, and the root scope only ends at application shutdown. So a transient handler that is or owns an `IDisposable` is kept alive by the root provider on every send, and memory grows for the life of the process. Inside a request scope, or an explicit `IServiceScopeFactory` scope, the same handler is disposed at scope end. This is standard Microsoft DI behavior, not something RequestFlow can override, and one more reason to prefer a scope per unit of work. -The first `AddRequestFlow` call fixes the dispatcher lifetime; later calls cannot change it. This matches how the first registration wins for assemblies. There is no singleton option: a singleton dispatcher would resolve every handler from the root provider, so scoped handlers could never work with it. +The first `AddRequestFlow` call fixes the dispatcher lifetime, and later calls cannot change it. This matches how the first registration wins for assemblies. There is no singleton option: a singleton dispatcher would resolve every handler from the root provider, so scoped handlers could never work with it. ## Captive dependencies @@ -126,7 +126,7 @@ The container does not stop a longer-lived handler from holding a shorter-lived ## Validation and build timing -Each `AddRequestFlow` call adds handler registrations to the container immediately. The dispatch map, the internal lookup the dispatcher uses to find handlers, is built and validated once per provider, the first time that provider resolves a dispatcher. Missing or duplicate handlers across all calls surface at that point as a single `RequestFlowValidationException` listing every problem. +Each `AddRequestFlow` call adds handler registrations to the container immediately. The dispatch map is the internal lookup the dispatcher uses to find handlers. It is built and validated once per provider, the first time that provider resolves a dispatcher. Missing or duplicate handlers across all calls surface at that point, as a single `RequestFlowValidationException` listing every problem. `ValidateOnBuild` cannot catch these problems: it checks constructor dependencies without executing factory registrations, and the dispatch map is built by one. To fail at startup instead of at first dispatch, call `ValidateRequestFlow` once after building the provider. It works in any application, hosted or not, and returns the provider for chaining: @@ -138,3 +138,11 @@ app.Services.ValidateRequestFlow(); ```csharp IServiceProvider provider = services.BuildServiceProvider().ValidateRequestFlow(); ``` + +## Registration changes after the provider is built + +`AddRequestFlow` records what it finds in one registry per service collection. A provider builds its dispatch map from that registry the first time it resolves a dispatcher, which can be long after `BuildServiceProvider` returned. A registration made in between reaches the map but not the provider, because a provider reads the collection once, when it is built. + +Nothing reports the split. The map gets a plan for the new request, and the dispatch fails further in, where that plan asks the container for the handler. The error is the container's own "No service for type", not a RequestFlow one. Removing or replacing a descriptor in the same window splits the same way: the provider keeps what it was built with. + +Register, replace, or remove every stage and handler descriptor before calling `BuildServiceProvider`. This includes `services.Replace(...)` ([stages.md](stages.md#replacing-a-stage-registration)). A provider built after the change is fine; it holds both the registry entry and the descriptor. diff --git a/docs/stages.md b/docs/stages.md index 0ada39b..dd8a11c 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -1,6 +1,6 @@ # Stages -A stage wraps the handler of every request it applies to: code before and after the handler, a replaced response, or no handler call at all. Coming from MediatR, `IRequestStage` is the `IPipelineBehavior` counterpart. +A stage wraps the handler of every request it applies to. It can run code before and after the handler, replace the response, or skip the handler entirely. If you are coming from MediatR, `IRequestStage` is its `IPipelineBehavior`. ## Writing a stage @@ -13,7 +13,7 @@ public sealed class LoggingStage : IRequestStage { public async Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { Console.WriteLine($"Handling {typeof(TRequest).Name}"); TResponse response = await next.InvokeAsync(); @@ -26,13 +26,15 @@ public sealed class LoggingStage : IRequestStage : IRequestStage HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); linked.CancelAfter(Limit); @@ -91,9 +93,9 @@ public sealed class TimeoutStage : IRequestStage o Every request both stages apply to runs logging, then validation, then its handler. A closed stage type can also register through the generic form: `AddStage()`. -One stage type belongs to a chain once. Registering the same type twice fails startup validation, whatever the two calls filtered on. So does a pair of declarations that reach one request as the same stage class, an open definition next to its own closed form for example. `AddStage` calls from separate `AddRequestFlow` calls combine into one chain, in call order, and validate together (see [registration.md](registration.md) on additive calls). +One stage type belongs to a chain once. Registering the same type twice fails startup validation, whatever the two calls filtered on. Two declarations that reach one request as the same stage class fail too, an open definition next to its own closed form for example. `AddStage` calls from separate `AddRequestFlow` calls combine into one chain, in call order, and validate together (see [registration.md](registration.md) on additive calls). ## Which requests a stage reaches @@ -124,7 +126,7 @@ public sealed class AuditStage : IRequestStage, IAudited { public async Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { TResponse response = await next.InvokeAsync(); // write the audit record @@ -133,7 +135,7 @@ public sealed class AuditStage : IRequestStage : IRequestStage { public async Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { try { @@ -174,18 +176,51 @@ The filter looks at the handler class, not the request, so a module can mark its ## Void requests -A stage for void requests implements `IRequestStage`, takes the void form `IContinuation`, and returns plain `Task`: +A stage for void requests implements `IRequestStage`, takes the void form `Continuation`, and returns plain `Task`: ```csharp public sealed class CacheClearGuard : IRequestStage { - public Task HandleAsync(ClearCache request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(ClearCache request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } ``` Both forms mix in one chain, in registration order. An open two-parameter stage whose constraints admit a void request wraps it too, with `NoResult` as the response. +## Testing a stage on its own + +A stage is a unit, and running one needs no container. `Continuation.Over` builds the `next` a stage expects from a delegate standing in for the rest of the chain, and `Continuation.Over` does the same for the void form: + +```csharp +[Fact] +public async Task Given_A_Failing_Chain_Then_The_Stage_Retries_Once() +{ + int calls = 0; + Continuation next = Continuation.Over(_ => + ++calls == 1 ? Task.FromException(new TimeoutException()) : Task.FromResult(new Receipt())); + + await new RetryStage().HandleAsync(new PlaceOrder(), next, CancellationToken.None); + + Assert.Equal(2, calls); +} +``` + +The delegate is handed whatever token the stage passed to `InvokeAsync`. So a stage that replaces the token for the levels below it is testable through the token the delegate sees. A call to `InvokeAsync()` with no token falls back to the token the stage itself received, and the optional second argument to `Over` is what it falls back to in a test: + +```csharp +CancellationToken observed = default; +Continuation next = Continuation.Over( + token => + { + observed = token; + return Task.FromResult(new Receipt()); + }, + ambient); +``` + +The default value of either type has no chain under it. Hand a stage `default(Continuation)` and the first `InvokeAsync` throws an `InvalidOperationException` saying so. Build one with `Over` instead. + ## Unused stages A stage that reaches no registered request is a silent no-op by default. `DisallowUnusedStages` makes it a startup validation problem instead: @@ -210,7 +245,7 @@ services.AddRequestFlow(o => o .AddStage(typeof(UnitOfWorkStage<,>), s => s.AsScoped())); ``` -Say nothing and the stage is transient, which means a fresh instance every time the chain enters its level. It is free to hold state for that one pass, but a repeated `next` call from the stage above builds a new instance, so nothing carries from one pass to the next. A stage takes one lifetime, so `AsSingleton().AsScoped()` throws; naming the same one twice is fine. An open generic stage passes its lifetime to every closed type it produces. +Say nothing and the stage is transient: a fresh instance every time the chain enters its level. It is free to hold state for that one pass. A repeated `next` call from the stage above builds a new instance, so nothing carries from one pass to the next. A stage takes one lifetime, so `AsSingleton().AsScoped()` throws. Naming the same one twice is fine. An open generic stage passes its lifetime to every closed type it produces. The two lifetime methods sit on the same delegate as `WhereHandlerImplements`, and chain in either order: @@ -227,9 +262,9 @@ Every closed stage type is a registered service, so the container can catch that - `ValidateOnBuild` and `ValidateScopes` both on: the captive dependency is reported. ASP.NET Core turns this pair on in Development. - `ValidateOnBuild` alone: it builds the constructor graph without comparing lifetimes, and says nothing. -Scoped stages have the mirror-image problem, quieter still. A root-resolved dispatcher resolves the stage from the root provider, so with scope validation off one instance sits there for the life of the process. [lifetimes.md](lifetimes.md) covers both. +Scoped stages have the mirror-image problem, and it is quieter still. A root-resolved dispatcher resolves the stage from the root provider. With scope validation off, one instance sits there for the life of the process. [lifetimes.md](lifetimes.md) covers both. -Thread safety is not only a question of separate dispatches. A stage above that overlaps its `next` calls runs the levels below it side by side, so inside one dispatch a scoped or singleton stage under it is entered twice at once. Transient is the lifetime that stays clear of it: every call resolves an instance of its own. +Thread safety is not only a question of separate dispatches. A stage above that overlaps its `next` calls runs the levels below it side by side. So inside one dispatch, a scoped or singleton stage under it is entered twice at once. Only transient stays clear of that, because every call resolves an instance of its own. ### Replacing a stage registration @@ -239,7 +274,7 @@ Thread safety is not only a question of separate dispatches. A stage above that - The declaration wins over anything registered before it. - Anything registered after it wins instead. -Register your own stage after the *last* `AddRequestFlow` call, not the first. Closing runs again on every call, so a stage declared in the first call gains descriptors for the request types the second call scans, and those land after anything registered between the two. +Register your own stage after the *last* `AddRequestFlow` call, not the first. Closing runs again on every call. So a stage declared in the first call gains descriptors for the request types the second call scans, and those land after anything you registered between the two. To register a stage on terms the declaration cannot express, a factory or an instance you built yourself, replace it after that call: @@ -253,7 +288,7 @@ services.AddRequestFlow(o => o services.Replace(ServiceDescriptor.Singleton(new LoggingStage(sink))); ``` -`Replace`, not `AddSingleton`. Both resolve to your instance, since the container takes the last descriptor. The difference shows up under `ServiceProviderOptions.ValidateOnBuild`, which walks every descriptor including the one `AddStage` left behind. That one names the stage's constructor, so if the container cannot supply `sink`, and not having to supply it is why you built the stage by hand, startup fails over a stage that never runs. `Replace` drops that descriptor and leaves nothing to fail on. +`Replace`, not `AddSingleton`. Both resolve to your instance, since the container takes the last descriptor. The difference shows up under `ServiceProviderOptions.ValidateOnBuild`, which walks every descriptor, including the one `AddStage` left behind. That leftover names the stage's constructor. If the container cannot supply `sink`, startup fails over a stage that never runs, and not having to supply `sink` is usually the reason you built the stage by hand. `Replace` drops that descriptor and leaves nothing to fail on. `Replace` drops exactly one descriptor. Register the same stage type yourself before `AddRequestFlow` as well and one survives, so `ValidateOnBuild` still walks it. To clear every descriptor for the type, call `services.RemoveAll>()` and then `AddSingleton`. diff --git a/src/RequestFlow.Abstractions/Continuation.cs b/src/RequestFlow.Abstractions/Continuation.cs new file mode 100644 index 0000000..ee08160 --- /dev/null +++ b/src/RequestFlow.Abstractions/Continuation.cs @@ -0,0 +1,149 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace RequestFlow; + +/// +/// The rest of the stage chain below one stage, ending at the request's handler. Invoke it again +/// to run that chain again, either after the first call finishes or while it is still running. +/// +/// +/// Each call enters the levels below on its own and keeps the token it was given, so two +/// overlapping calls share no state of the library's. They do share whatever the container hands to +/// both: a transient stage below is built again per call, while a scoped or singleton one runs +/// inside both calls at once and has to be thread safe. +/// +/// A level resolves before there is a task to hand back, so can throw +/// instead of returning one. A stage holding a call it has not awaited owns that call, and has to +/// observe it when a later call or walk fails. +/// +/// +/// Only a dispatch builds one over a real chain. To run a stage on its own, build one with +/// ; the default value of the type stands for no chain at all. +/// +/// +/// The response the chain produces. +public readonly struct Continuation +{ + // The void form wraps a Continuation, so its calls reach this same guard. + private const string NotBuiltMessage = + "This continuation is the default value of its type, so there is no chain below it to run. " + + "A stage is handed its continuation by the dispatch; to build one in a test, call " + + "Continuation.Over(rest), or Continuation.Over(rest) for a void request, passing a " + + "delegate that stands in for the rest of the chain."; + + private readonly LevelEntry _below; + private readonly object _request; + private readonly IServiceProvider _services; + private readonly CancellationToken _cancellationToken; + + internal Continuation( + LevelEntry below, + object request, + IServiceProvider services, + CancellationToken cancellationToken) + { + _below = below; + _request = request; + _services = services; + _cancellationToken = cancellationToken; + } + + /// + /// A continuation that runs where the levels below a stage would be, for + /// unit-testing a stage on its own without a container. + /// + /// + /// What the rest of the chain does. It is handed the token was called + /// with, or when that call named none. + /// + /// + /// The token to continue under when is called without one, standing in + /// for the token the stage under test received. + /// + /// + public static Continuation Over( + Func> rest, CancellationToken cancellationToken = default) + { + if (rest is null) + throw new ArgumentNullException(nameof(rest)); + + // A level resolves through the request and the provider; this one resolves nothing. + return new Continuation( + (request, services, token) => rest(token), null!, null!, cancellationToken); + } + + /// + /// Runs the rest of the chain. + /// + /// + /// The token every level below this stage runs under, the handler included. Omit it, or + /// pass , to continue under the token this stage + /// received. + /// + /// + /// This continuation is the default value of its type, so it has no chain below it. Build one + /// with . + /// + // Inlined so the guard and the token choice fold into the stage's call, leaving the delegate call. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task InvokeAsync(CancellationToken cancellationToken = default) + { + if (_below is null) + throw new InvalidOperationException(NotBuiltMessage); + + return _below( + _request, + _services, + cancellationToken == CancellationToken.None ? _cancellationToken : cancellationToken); + } +} + +/// +/// The void form of , under the same rules. +/// +public readonly struct Continuation +{ + private readonly Continuation _inner; + + internal Continuation(Continuation inner) => _inner = inner; + + /// + /// A continuation that runs where the levels below a stage would be, for + /// unit-testing a stage on its own without a container. + /// + /// + /// What the rest of the chain does. It is handed the token was called + /// with, or when that call named none. + /// + /// + /// The token to continue under when is called without one, standing in + /// for the token the stage under test received. + /// + /// + public static Continuation Over(Func rest, CancellationToken cancellationToken = default) + { + if (rest is null) + throw new ArgumentNullException(nameof(rest)); + + return new Continuation(Continuation.Over( + token => Complete(rest(token)), cancellationToken)); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task InvokeAsync(CancellationToken cancellationToken = default) + => _inner.InvokeAsync(cancellationToken); + + private static Task Complete(Task rest) + => rest as Task ?? AwaitAsync(rest); + + private static async Task AwaitAsync(Task rest) + { + await rest.ConfigureAwait(false); + + return NoResult.Value; + } +} diff --git a/src/RequestFlow.Abstractions/IContinuation.cs b/src/RequestFlow.Abstractions/IContinuation.cs deleted file mode 100644 index c8ddebb..0000000 --- a/src/RequestFlow.Abstractions/IContinuation.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -namespace RequestFlow; - -/// -/// The rest of the stage chain below one stage, ending at the request's handler. Invoke it again -/// to run that chain again, either after the first call finishes or while it is still running. -/// -/// -/// Each call enters the levels below on its own and keeps the token it was given, so the library -/// holds no state two overlapping calls can collide over. What they share is whatever the container -/// hands to both: every entry resolves its stage, so a transient stage below is built again per -/// call, while a scoped or singleton one runs inside both calls at once and has to be thread safe. -/// -/// Resolving that level happens before there is a task to hand back, so -/// can throw instead of returning one. A stage holding a call it has not awaited owns that call, -/// and has to observe it when a later call or walk fails. -/// -/// -/// The response the chain produces. -public interface IContinuation -{ - /// - /// Runs the rest of the chain. - /// - /// - /// The token every level below this stage runs under, the handler included. Omit it, or - /// pass , to continue under the token this stage - /// received. - /// - Task InvokeAsync(CancellationToken cancellationToken = default); -} - -/// -/// The void form of , under the same rules. -/// -public interface IContinuation -{ - /// - /// Runs the rest of the chain. - /// - /// - /// The token every level below this stage runs under, the handler included. Omit it, or - /// pass , to continue under the token this stage - /// received. - /// - Task InvokeAsync(CancellationToken cancellationToken = default); -} diff --git a/src/RequestFlow.Abstractions/IRequestStage.cs b/src/RequestFlow.Abstractions/IRequestStage.cs index 3a61358..cc46bad 100644 --- a/src/RequestFlow.Abstractions/IRequestStage.cs +++ b/src/RequestFlow.Abstractions/IRequestStage.cs @@ -16,7 +16,7 @@ public interface IRequestStage /// Wraps the rest of the chain for . Invoke /// to continue, or skip it to short-circuit. /// - Task HandleAsync(TRequest request, IContinuation next, CancellationToken cancellationToken); + Task HandleAsync(TRequest request, Continuation next, CancellationToken cancellationToken); } /// @@ -31,5 +31,5 @@ public interface IRequestStage /// Wraps the rest of the chain for . Invoke /// to continue, or skip it to short-circuit. /// - Task HandleAsync(TRequest request, IContinuation next, CancellationToken cancellationToken); + Task HandleAsync(TRequest request, Continuation next, CancellationToken cancellationToken); } diff --git a/src/RequestFlow.Abstractions/LevelEntry.cs b/src/RequestFlow.Abstractions/LevelEntry.cs new file mode 100644 index 0000000..04a0496 --- /dev/null +++ b/src/RequestFlow.Abstractions/LevelEntry.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RequestFlow; + +/// +/// Enters one level of a frozen stage chain: a stage, or the handler beneath the last one. +/// A holds one of these and calls it again on every . +/// +/// +/// The request travels as an object and the cast to its own type happens inside the level, where +/// that type is a constant. +/// +/// The request being dispatched. +/// The provider this walk resolves through. +/// The token this level and everything below it runs under. +/// The response the chain produces. +internal delegate Task LevelEntry( + object request, IServiceProvider services, CancellationToken cancellationToken); diff --git a/src/RequestFlow.Abstractions/RequestFlow.Abstractions.csproj b/src/RequestFlow.Abstractions/RequestFlow.Abstractions.csproj index 92bf445..c8b9da6 100644 --- a/src/RequestFlow.Abstractions/RequestFlow.Abstractions.csproj +++ b/src/RequestFlow.Abstractions/RequestFlow.Abstractions.csproj @@ -4,4 +4,9 @@ Zero-dependency contracts for RequestFlow: requests, handlers, and dispatch abstractions. Reference this from application layers; the RequestFlow package provides the runtime. + + + + + diff --git a/src/RequestFlow/Dispatch/DispatchMap.cs b/src/RequestFlow/Dispatch/DispatchMap.cs index 7f57dd8..a6228f5 100644 --- a/src/RequestFlow/Dispatch/DispatchMap.cs +++ b/src/RequestFlow/Dispatch/DispatchMap.cs @@ -20,6 +20,10 @@ internal sealed class DispatchMap(Dictionary plans) #endif [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool TryGet(Type requestType, out RequestPlanBase? plan) + public bool TryGetPlanFor(Type requestType, out RequestPlanBase? plan) => _plans.TryGetValue(requestType, out plan); + + public RequestPlanBase GetPlanFor(Type requestType) + => TryGetPlanFor(requestType, out RequestPlanBase? plan) + ? plan! : throw new KeyNotFoundException($"No plan frozen for {requestType}."); } diff --git a/src/RequestFlow/Dispatch/NoResultBridge.cs b/src/RequestFlow/Dispatch/NoResultBridge.cs index 8f25c0e..352ec03 100644 --- a/src/RequestFlow/Dispatch/NoResultBridge.cs +++ b/src/RequestFlow/Dispatch/NoResultBridge.cs @@ -4,14 +4,18 @@ namespace RequestFlow; /// -/// Completes a void handler's task as a task, reusing the cached -/// task when the handler finished synchronously. +/// Completes a void handler's or stage's task as a task, reusing the task +/// it was given where it can and the cached one where it cannot. /// internal static class NoResultBridge { + // A stage that hands back its next call's task hands back the level's own Task, so the + // cast recovers it and a running pass-through level crosses free. An async stage's builder makes + // a plain Task, which falls through to the wrap. The cast sits behind the completed check, which + // is the cheaper test and the one a synchronous chain answers on. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static Task Complete(Task task) - => Succeeded(task) ? NoResult.Task : AwaitAsync(task); + => Succeeded(task) ? NoResult.Task : (task as Task ?? AwaitAsync(task)); public static Task CompleteOrNull(Task? task) => task is null ? null! : Complete(task); diff --git a/src/RequestFlow/Dispatch/NullTaskGuard.cs b/src/RequestFlow/Dispatch/NullTaskGuard.cs index 290e3ca..7a7ec66 100644 --- a/src/RequestFlow/Dispatch/NullTaskGuard.cs +++ b/src/RequestFlow/Dispatch/NullTaskGuard.cs @@ -4,27 +4,13 @@ namespace RequestFlow; -/// -/// Rejects a null task returned by a handler with a -/// that names the request type. -/// internal static class NullTaskGuard { [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Task ThrowIfNull(Task task, Type requestType) - { - if (task is null) - throw new HandlerNullTaskException(requestType); - - return task; - } + public static Task FromHandler(Task task, Type requestType) + => task ?? throw new HandlerNullTaskException(requestType); [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Task ThrowIfNull(Task task, Type requestType) - { - if (task is null) - throw new HandlerNullTaskException(requestType); - - return task; - } + public static Task FromStage(Task task, Type stageType) + => task ?? throw new StageNullTaskException(stageType); } diff --git a/src/RequestFlow/Dispatch/RequestDispatcher.cs b/src/RequestFlow/Dispatch/RequestDispatcher.cs index e8f6a5f..f057405 100644 --- a/src/RequestFlow/Dispatch/RequestDispatcher.cs +++ b/src/RequestFlow/Dispatch/RequestDispatcher.cs @@ -20,8 +20,7 @@ public Task SendAsync(IRequest request, Cancell Type requestType = request.GetType(); - // The map never stores a null plan, so a hit always carries one. - if (!_map.TryGet(requestType, out RequestPlanBase? plan)) + if (!_map.TryGetPlanFor(requestType, out RequestPlanBase? plan)) throw new HandlerNotFoundException(requestType); if (plan is not RequestPlan typedPlan) diff --git a/src/RequestFlow/Dispatch/RequestPlan.cs b/src/RequestFlow/Dispatch/RequestPlan.cs index bc4f8eb..882e3dc 100644 --- a/src/RequestFlow/Dispatch/RequestPlan.cs +++ b/src/RequestFlow/Dispatch/RequestPlan.cs @@ -1,24 +1,20 @@ using System; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; namespace RequestFlow; /// -/// Closed plan for one request/response pair. Resolves the handler from the supplied -/// provider on each call, so scoped and transient handlers work even though the plan -/// itself lives for the process lifetime. +/// Closed plan for one request/response pair with no stages over it: the handler level on its own, +/// resolving the handler from the supplied provider on each call. /// internal sealed class RequestPlan : RequestPlan where TRequest : IRequest { + private readonly LevelEntry _handler = ChainBuilder.TypedHandler(); + /// public override Task ExecuteAsync( - IRequest request, IServiceProvider services, CancellationToken cancellationToken) - { - var handler = services.GetRequiredService>(); - return NullTaskGuard.ThrowIfNull( - handler.HandleAsync((TRequest)request, cancellationToken), typeof(TRequest)); - } + object request, IServiceProvider services, CancellationToken cancellationToken) + => _handler(request, services, cancellationToken); } diff --git a/src/RequestFlow/Dispatch/RequestPlanBase.cs b/src/RequestFlow/Dispatch/RequestPlanBase.cs index b75fa0d..de714e5 100644 --- a/src/RequestFlow/Dispatch/RequestPlanBase.cs +++ b/src/RequestFlow/Dispatch/RequestPlanBase.cs @@ -26,8 +26,8 @@ internal abstract class RequestPlan : RequestPlanBase public sealed override Type ResponseType => typeof(TResponse); /// - /// Resolves the handler from and invokes it. + /// Enters the top level of this plan's chain, which is the handler itself when no stage applies. /// public abstract Task ExecuteAsync( - IRequest request, IServiceProvider services, CancellationToken cancellationToken); + object request, IServiceProvider services, CancellationToken cancellationToken); } diff --git a/src/RequestFlow/Dispatch/VoidRequestPlan.cs b/src/RequestFlow/Dispatch/VoidRequestPlan.cs index cccb1fc..6974ccd 100644 --- a/src/RequestFlow/Dispatch/VoidRequestPlan.cs +++ b/src/RequestFlow/Dispatch/VoidRequestPlan.cs @@ -1,24 +1,20 @@ using System; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; namespace RequestFlow; /// -/// Closed plan for one void request. Resolves the standalone handler from the supplied -/// provider on each call and completes with . +/// Closed plan for one void request with no stages over it: the handler level on its own, resolving +/// the standalone handler from the supplied provider on each call. /// internal sealed class VoidRequestPlan : RequestPlan where TRequest : IRequest { + private readonly LevelEntry _handler = ChainBuilder.VoidHandler(); + /// public override Task ExecuteAsync( - IRequest request, IServiceProvider services, CancellationToken cancellationToken) - { - var handler = services.GetRequiredService>(); - Task task = NullTaskGuard.ThrowIfNull( - handler.HandleAsync((TRequest)request, cancellationToken), typeof(TRequest)); - return NoResultBridge.Complete(task); - } + object request, IServiceProvider services, CancellationToken cancellationToken) + => _handler(request, services, cancellationToken); } diff --git a/src/RequestFlow/Registration/RequestFlowRegistry.cs b/src/RequestFlow/Registration/RequestFlowRegistry.cs index b15b297..9017017 100644 --- a/src/RequestFlow/Registration/RequestFlowRegistry.cs +++ b/src/RequestFlow/Registration/RequestFlowRegistry.cs @@ -152,6 +152,7 @@ .. RegistrationValidator.ValidateAliasedStages(_stageDeclarations, _handlers, Cl if (problems.Count > 0) throw new RequestFlowValidationException(problems); + // Duplicate handlers were reported above, so one plan lands per handler here. Dictionary plans = []; foreach (var handler in _handlers) { @@ -181,7 +182,9 @@ private StagePlanSet BuildStagePlans() } Type[] stageTypes = ordered.ToArray(); - chainsByRequest[handler.RequestType] = new StageChain(stageTypes, TypedShapesFor(handler, stageTypes)); + + chainsByRequest[handler.RequestType] = + new StageChain(stageTypes, TypedShapesFor(handler, stageTypes)); } return new StagePlanSet(chainsByRequest, appliedStageTypes); @@ -202,6 +205,7 @@ private static bool[] TypedShapesFor(HandlerRegistration handler, Type[] stageTy return typedShapes; } + // The staged plans build their own levels, keeping the reflection at this one call. private static RequestPlanBase CreatePlan(HandlerRegistration handler, StageChain chain) { if (chain.StageTypes.Length == 0) @@ -209,22 +213,15 @@ private static RequestPlanBase CreatePlan(HandlerRegistration handler, StageChai Type planType = handler.IsVoid ? typeof(VoidRequestPlan<>).MakeGenericType(handler.RequestType) : typeof(RequestPlan<,>).MakeGenericType(handler.RequestType, handler.ResponseType); - return (RequestPlanBase)Activator.CreateInstance(planType)!; - } - // Wrapped in an object array on purpose: Type[] converts to object[], so handing - // stageTypes straight through would be read as one constructor argument per stage type. - if (handler.IsVoid) - { - Type voidPlanType = typeof(StagedVoidRequestPlan<>).MakeGenericType(handler.RequestType); - return (RequestPlanBase)Activator.CreateInstance( - voidPlanType, [chain.StageTypes, chain.TypedShapes])!; + return (RequestPlanBase)Activator.CreateInstance(planType)!; } - Type stagedPlanType = typeof(StagedRequestPlan<,>) - .MakeGenericType(handler.RequestType, handler.ResponseType); + Type stagedPlanType = handler.IsVoid + ? typeof(StagedVoidRequestPlan<>).MakeGenericType(handler.RequestType) + : typeof(StagedRequestPlan<,>).MakeGenericType(handler.RequestType, handler.ResponseType); - return (RequestPlanBase)Activator.CreateInstance(stagedPlanType, [(object)chain.StageTypes])!; + return (RequestPlanBase)Activator.CreateInstance(stagedPlanType, [chain])!; } } diff --git a/src/RequestFlow/Stages/ChainBuilder.cs b/src/RequestFlow/Stages/ChainBuilder.cs new file mode 100644 index 0000000..38f2fad --- /dev/null +++ b/src/RequestFlow/Stages/ChainBuilder.cs @@ -0,0 +1,51 @@ +using System; + +namespace RequestFlow; + +/// +/// Builds one plan's chain of levels, bottom-up from the handler. +/// +internal static class ChainBuilder +{ + public static LevelEntry Typed(StageChain chain) + where TRequest : IRequest + { + LevelEntry level = TypedHandler(); + + for (int i = chain.StageTypes.Length - 1; i >= 0; i--) + level = LevelFactory.Stage(chain.StageTypes[i], level); + + return level; + } + + /// + /// The chain for a void request, whose stages come in both contract shapes. Which shape each + /// level runs is read from . + /// + public static LevelEntry Void(StageChain chain) + where TRequest : IRequest + { + LevelEntry level = VoidHandler(); + + for (int i = chain.StageTypes.Length - 1; i >= 0; i--) + level = VoidStage(chain.StageTypes[i], chain.TypedShapes[i], level); + + return level; + } + + public static LevelEntry TypedHandler() + where TRequest : IRequest + => LevelFactory.Handler(); + + public static LevelEntry VoidHandler() + where TRequest : IRequest + => LevelFactory.VoidHandler(); + + // A stage that implements both shapes runs as the two-parameter one, which the freeze recorded. + private static LevelEntry VoidStage( + Type stageType, bool typedShape, LevelEntry below) + where TRequest : IRequest + => typedShape + ? LevelFactory.Stage(stageType, below) + : LevelFactory.VoidStage(stageType, below); +} diff --git a/src/RequestFlow/Stages/LevelFactory.cs b/src/RequestFlow/Stages/LevelFactory.cs new file mode 100644 index 0000000..ba0213b --- /dev/null +++ b/src/RequestFlow/Stages/LevelFactory.cs @@ -0,0 +1,71 @@ +// The methods run at freeze. The bodies they return are the dispatch path. + +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace RequestFlow; + +/// +/// Builds one level of a stage chain: a stage, or the handler beneath the last one. A level is built +/// when the dispatch map freezes and closes over its position, reaching its stage or handler through +/// the contract. +/// +/// +/// Every level of every plan runs one shared body, so the call inside it sees every stage type in +/// the application and stays an interface dispatch. Compiling a body per level to make that call +/// monomorphic pays nothing: the cost of the shared body does not separate from zero. +/// +internal static class LevelFactory +{ + public static LevelEntry Stage( + Type stageType, LevelEntry below) + where TRequest : IRequest + => (request, services, cancellationToken) => + { + object stage = services.GetRequiredService(stageType); + var next = new Continuation(below, request, services, cancellationToken); + + return NullTaskGuard.FromStage( + ((IRequestStage)stage).HandleAsync((TRequest)request, next, cancellationToken), + stageType); + }; + + // The void shape's plain Task becomes a Task, so both shapes are entered as one type. + public static LevelEntry VoidStage(Type stageType, LevelEntry below) + where TRequest : IRequest + => (request, services, cancellationToken) => + { + object stage = services.GetRequiredService(stageType); + var next = new Continuation( + new Continuation(below, request, services, cancellationToken)); + + return NullTaskGuard.FromStage( + NoResultBridge.CompleteOrNull( + ((IRequestStage)stage).HandleAsync((TRequest)request, next, cancellationToken)), + stageType); + }; + + // Each typeof stays inside the body, so the body captures nothing and the JIT folds it away. + public static LevelEntry Handler() + where TRequest : IRequest + => (request, services, cancellationToken) => + { + var handler = (IRequestHandler) + services.GetRequiredService(typeof(IRequestHandler)); + + return NullTaskGuard.FromHandler( + handler.HandleAsync((TRequest)request, cancellationToken), typeof(TRequest)); + }; + + public static LevelEntry VoidHandler() + where TRequest : IRequest + => (request, services, cancellationToken) => + { + var handler = (IRequestHandler) + services.GetRequiredService(typeof(IRequestHandler)); + + return NullTaskGuard.FromHandler( + NoResultBridge.CompleteOrNull(handler.HandleAsync((TRequest)request, cancellationToken)), + typeof(TRequest)); + }; +} diff --git a/src/RequestFlow/Stages/StageExecutor.cs b/src/RequestFlow/Stages/StageExecutor.cs deleted file mode 100644 index 22abf83..0000000 --- a/src/RequestFlow/Stages/StageExecutor.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace RequestFlow; - -/// -/// Runs the stage chain by recursion: each level hands its stage the level below it. One -/// instance per dispatch, holding the request and the token the dispatch entered with, and -/// standing in for the level the outermost stage re-enters. -/// -internal abstract class StageExecutor : IContinuation, IContinuation - where TRequest : IRequest -{ - private readonly int _stageCount; - private readonly TRequest _request; - private readonly CancellationToken _cancellationToken; - - protected StageExecutor(int stageCount, TRequest request, CancellationToken cancellationToken) - { - _stageCount = stageCount; - _request = request; - _cancellationToken = cancellationToken; - } - - /// - /// Runs the chain, starting at the outermost stage. - /// - public Task RunAsync() - => _stageCount == 0 - ? StartHandlerAsync(_cancellationToken) - : StartStageAsync(0, ResolveStage(0), this, _cancellationToken); - - /// - public Task InvokeAsync(CancellationToken cancellationToken = default) - => EnterBelowAsync(0, Inherit(cancellationToken, _cancellationToken)); - - /// - Task IContinuation.InvokeAsync(CancellationToken cancellationToken) => InvokeAsync(cancellationToken); - - /// - /// The stage that runs at . Asked once per entry into that level. - /// - protected abstract object ResolveStage(int index); - - /// - /// Invokes the stage at with as the rest - /// of the chain. - /// - protected abstract Task InvokeStageAsync( - int index, object stage, IContinuation next, TRequest request, CancellationToken cancellationToken); - - /// - /// Invokes the handler. Reached only once the chain runs to the bottom, so a stage that - /// short-circuits never resolves it. - /// - protected abstract Task InvokeHandlerAsync(TRequest request, CancellationToken cancellationToken); - - /// - /// The type of the stage at , used to name it in errors. - /// - protected abstract Type StageTypeAt(int index); - - // None means the caller named no token, so the level's own carries on down. - private static CancellationToken Inherit(CancellationToken supplied, CancellationToken current) - => supplied == CancellationToken.None ? current : supplied; - - private Task StartStageAsync( - int index, object stage, IContinuation next, CancellationToken cancellationToken) - { - Task task = InvokeStageAsync(index, stage, next, _request, cancellationToken); - if (task is null) - throw new StageNullTaskException(StageTypeAt(index)); - - return task; - } - - private Task StartHandlerAsync(CancellationToken cancellationToken) - => NullTaskGuard.ThrowIfNull(InvokeHandlerAsync(_request, cancellationToken), typeof(TRequest)); - - // Every call builds its own level below, so repeated and overlapping calls share no state. - private Task EnterBelowAsync(int callerIndex, CancellationToken cancellationToken) - { - int index = callerIndex + 1; - - return index == _stageCount - ? StartHandlerAsync(cancellationToken) - : new Continuation(this, index, cancellationToken).EnterAsync(); - } - - // One level of one call, immutable once built: the token belongs to the call, not the level. - private sealed class Continuation( - StageExecutor executor, int index, CancellationToken cancellationToken) - : IContinuation, IContinuation - { - /// - public Task InvokeAsync(CancellationToken suppliedToken = default) - => executor.EnterBelowAsync(index, Inherit(suppliedToken, cancellationToken)); - - /// - Task IContinuation.InvokeAsync(CancellationToken suppliedToken) => InvokeAsync(suppliedToken); - - internal Task EnterAsync() - => executor.StartStageAsync(index, executor.ResolveStage(index), this, cancellationToken); - } -} diff --git a/src/RequestFlow/Stages/StagedRequestPlan.cs b/src/RequestFlow/Stages/StagedRequestPlan.cs index 899a6c6..bcb4bd1 100644 --- a/src/RequestFlow/Stages/StagedRequestPlan.cs +++ b/src/RequestFlow/Stages/StagedRequestPlan.cs @@ -1,22 +1,21 @@ using System; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; namespace RequestFlow; /// -/// Closed plan for one request/response pair wrapped in stages. The ordered stage types are -/// fixed when the dispatch map freezes; each stage instance and the handler resolve from the -/// supplied provider when their level first runs, so DI lifetimes hold and a level the chain -/// never reaches is never built. +/// Closed plan for one request/response pair wrapped in stages. The chain of levels is built when +/// the dispatch map freezes; each level resolves its stage or handler from the supplied provider +/// when it runs. /// -internal sealed class StagedRequestPlan(Type[] stageTypes) : RequestPlan +internal sealed class StagedRequestPlan(StageChain chain) : RequestPlan where TRequest : IRequest { + private readonly LevelEntry _root = ChainBuilder.Typed(chain); + /// public override Task ExecuteAsync( - IRequest request, IServiceProvider services, CancellationToken cancellationToken) - => new TypedStageExecutor( - stageTypes, services, (TRequest)request, cancellationToken).RunAsync(); + object request, IServiceProvider services, CancellationToken cancellationToken) + => _root(request, services, cancellationToken); } diff --git a/src/RequestFlow/Stages/StagedVoidRequestPlan.cs b/src/RequestFlow/Stages/StagedVoidRequestPlan.cs index 7ca12ba..56c7a66 100644 --- a/src/RequestFlow/Stages/StagedVoidRequestPlan.cs +++ b/src/RequestFlow/Stages/StagedVoidRequestPlan.cs @@ -1,22 +1,21 @@ using System; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; namespace RequestFlow; /// -/// Closed plan for one void request wrapped in stages. The ordered stage types and the -/// contract shape each one runs under are fixed when the dispatch map freezes; each stage -/// instance and the handler resolve from the supplied provider when their level first runs, so -/// DI lifetimes hold and a level the chain never reaches is never built. +/// Closed plan for one void request wrapped in stages. The chain of levels, and the contract shape +/// each one runs under, are settled when the dispatch map freezes; each level resolves its stage or +/// handler from the supplied provider when it runs. /// -internal sealed class StagedVoidRequestPlan(Type[] stageTypes, bool[] typedShapes) : RequestPlan +internal sealed class StagedVoidRequestPlan(StageChain chain) : RequestPlan where TRequest : IRequest { + private readonly LevelEntry _root = ChainBuilder.Void(chain); + /// public override Task ExecuteAsync( - IRequest request, IServiceProvider services, CancellationToken cancellationToken) - => new VoidStageExecutor( - stageTypes, typedShapes, services, (TRequest)request, cancellationToken).RunAsync(); + object request, IServiceProvider services, CancellationToken cancellationToken) + => _root(request, services, cancellationToken); } diff --git a/src/RequestFlow/Stages/TypedStageExecutor.cs b/src/RequestFlow/Stages/TypedStageExecutor.cs deleted file mode 100644 index 932c773..0000000 --- a/src/RequestFlow/Stages/TypedStageExecutor.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; - -namespace RequestFlow; - -/// -/// Stage chain that terminates at . -/// -internal sealed class TypedStageExecutor( - Type[] stageTypes, - IServiceProvider services, - TRequest request, - CancellationToken cancellationToken) - : StageExecutor(stageTypes.Length, request, cancellationToken) - where TRequest : IRequest -{ - /// - protected override object ResolveStage(int index) => services.GetRequiredService(stageTypes[index]); - - /// - protected override Task InvokeStageAsync( - int index, object stage, IContinuation next, TRequest request, CancellationToken cancellationToken) - => ((IRequestStage)stage).HandleAsync(request, next, cancellationToken); - - /// - protected override Task InvokeHandlerAsync(TRequest request, CancellationToken cancellationToken) - => services.GetRequiredService>() - .HandleAsync(request, cancellationToken); - - /// - protected override Type StageTypeAt(int index) => stageTypes[index]; -} diff --git a/src/RequestFlow/Stages/VoidStageExecutor.cs b/src/RequestFlow/Stages/VoidStageExecutor.cs deleted file mode 100644 index b7a0683..0000000 --- a/src/RequestFlow/Stages/VoidStageExecutor.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; - -namespace RequestFlow; - -/// -/// Stage chain that terminates at the standalone . -/// Its stages come in both contract shapes, so which shape each level runs is settled when the -/// dispatch map freezes and read from here. -/// -internal sealed class VoidStageExecutor( - Type[] stageTypes, - bool[] typedShapes, - IServiceProvider services, - TRequest request, - CancellationToken cancellationToken) - : StageExecutor(stageTypes.Length, request, cancellationToken) - where TRequest : IRequest -{ - /// - protected override object ResolveStage(int index) => services.GetRequiredService(stageTypes[index]); - - /// - protected override Task InvokeStageAsync( - int index, object stage, IContinuation next, TRequest request, CancellationToken cancellationToken) - { - if (typedShapes[index]) - return ((IRequestStage)stage).HandleAsync(request, next, cancellationToken); - - // The void shape's Task converts to Task, so both forms reach the same level object. - return NoResultBridge.CompleteOrNull( - ((IRequestStage)stage).HandleAsync(request, (IContinuation)next, cancellationToken)); - } - - /// - protected override Task InvokeHandlerAsync(TRequest request, CancellationToken cancellationToken) - => NoResultBridge.CompleteOrNull( - services.GetRequiredService>() - .HandleAsync(request, cancellationToken)); - - /// - protected override Type StageTypeAt(int index) => stageTypes[index]; -} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 525f361..bba9cd3 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -7,10 +7,10 @@ $(TargetFrameworks);net462 + $(MSBuildThisFileDirectory)tests.runsettings enable false - + true diff --git a/tests/RequestFlow.Tests.Unit/AddRequestFlowMultipleProvidersTests.cs b/tests/RequestFlow.Tests.Unit/AddRequestFlowMultipleProvidersTests.cs index 7091730..95cf677 100644 --- a/tests/RequestFlow.Tests.Unit/AddRequestFlowMultipleProvidersTests.cs +++ b/tests/RequestFlow.Tests.Unit/AddRequestFlowMultipleProvidersTests.cs @@ -47,6 +47,25 @@ public void Given_Assembly_Registered_Again_After_First_Provider_Resolved_Dispat services.Count(d => d.ServiceType == typeof(IRequestHandler)).ShouldBe(1); } + // Every provider built from one collection freezes off the same registry, so a handler declared + // after this provider was built still reaches its map. The descriptor never does, and the + // resolution the plan makes is where that shows up. + [Fact] + public async Task Given_Closing_Declared_After_The_Provider_Was_Built_When_Sending_Request_Via_That_Provider_Then_The_Handler_Cannot_Be_Resolved() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterGenericHandler(typeof(TagHandler<>), typeof(First))); + using ServiceProvider provider = services.BuildServiceProvider(); + services.AddRequestFlow(o => o.RegisterGenericHandler(typeof(TagHandler<>), typeof(Second))); + IRequestDispatcher dispatcher = provider.GetRequiredService(); + + InvalidOperationException thrown = await Should.ThrowAsync( + () => dispatcher.SendAsync(new Tag("x"))); + + thrown.ShouldNotBeOfType(); + thrown.Message.ShouldContain(nameof(IRequestHandler, string>)); + } + #region Helpers public sealed record Tag(string Payload) : IRequest; diff --git a/tests/RequestFlow.Tests.Unit/Doubles/CountingProvider.cs b/tests/RequestFlow.Tests.Unit/Doubles/CountingProvider.cs new file mode 100644 index 0000000..2487966 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Doubles/CountingProvider.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace RequestFlow.Tests.Unit; + +/// +/// A provider that records every service type asked of it and forwards the ask to a real one, so a +/// test can assert what a dispatch resolved and in what order. +/// +internal sealed class CountingProvider(IServiceProvider inner) : IServiceProvider, ISupportRequiredService +{ + internal readonly List Requested = []; + + public object? GetService(Type serviceType) + { + Requested.Add(serviceType); + + return inner.GetService(serviceType); + } + + public object GetRequiredService(Type serviceType) + { + Requested.Add(serviceType); + + return inner.GetRequiredService(serviceType); + } +} diff --git a/tests/RequestFlow.Tests.Unit/RequestFlowRegistryTests.cs b/tests/RequestFlow.Tests.Unit/RequestFlowRegistryTests.cs index 3e773a3..df2a911 100644 --- a/tests/RequestFlow.Tests.Unit/RequestFlowRegistryTests.cs +++ b/tests/RequestFlow.Tests.Unit/RequestFlowRegistryTests.cs @@ -10,7 +10,7 @@ public void Given_One_Applicable_Stage_When_Building_The_Dispatch_Map_Then_Reque { DispatchMap map = BuildMap(o => o.AddStage(typeof(WrapStage<,>))); - map.TryGet(typeof(Echo), out RequestPlanBase? plan); + map.TryGetPlanFor(typeof(Echo), out RequestPlanBase? plan); plan.ShouldBeOfType>(); } @@ -20,7 +20,7 @@ public void Given_One_Applicable_Stage_When_Building_The_Dispatch_Map_Then_Void_ { DispatchMap map = BuildMap(o => o.AddStage(typeof(WrapStage<,>))); - map.TryGet(typeof(Purge), out RequestPlanBase? plan); + map.TryGetPlanFor(typeof(Purge), out RequestPlanBase? plan); plan.ShouldBeOfType>(); } @@ -30,7 +30,7 @@ public void Given_Two_Applicable_Stages_When_Building_The_Dispatch_Map_Then_Requ { DispatchMap map = BuildMap(o => o.AddStage(typeof(WrapStage<,>)).AddStage(typeof(ExtraStage<,>))); - map.TryGet(typeof(Echo), out RequestPlanBase? plan); + map.TryGetPlanFor(typeof(Echo), out RequestPlanBase? plan); plan.ShouldBeOfType>(); } @@ -40,7 +40,7 @@ public void Given_Two_Applicable_Stages_When_Building_The_Dispatch_Map_Then_Void { DispatchMap map = BuildMap(o => o.AddStage(typeof(WrapStage<,>)).AddStage(typeof(ExtraStage<,>))); - map.TryGet(typeof(Purge), out RequestPlanBase? plan); + map.TryGetPlanFor(typeof(Purge), out RequestPlanBase? plan); plan.ShouldBeOfType>(); } @@ -50,14 +50,39 @@ public void Given_No_Stages_When_Building_The_Dispatch_Map_Then_Request_Gets_The { DispatchMap map = BuildMap(); - map.TryGet(typeof(Echo), out RequestPlanBase? plan); + map.TryGetPlanFor(typeof(Echo), out RequestPlanBase? plan); plan.ShouldBeOfType>(); } + // A plan builds its levels when the map freezes, so a dispatch reaches that same plan: it asks + // the container for the levels in chain order and for nothing the freeze itself needed. Building + // a plan per call would take the registry, which holds the reflection, and show up here. + [Fact] + public async Task Given_A_Staged_Request_When_Dispatching_Twice_Then_Both_Calls_Only_Resolve_The_Frozen_Levels() + { + using ServiceProvider provider = BuildProvider(o => o.AddStage(typeof(WrapStage<,>))); + using IServiceScope scope = provider.CreateScope(); + var counting = new CountingProvider(scope.ServiceProvider); + var dispatcher = new RequestDispatcher( + scope.ServiceProvider.GetRequiredService(), counting); + + await dispatcher.SendAsync(new Echo("hi")); + await dispatcher.SendAsync(new Echo("hi")); + + counting.Requested.ShouldBe( + [ + typeof(WrapStage), typeof(IRequestHandler), + typeof(WrapStage), typeof(IRequestHandler), + ]); + } + #region Helpers private static DispatchMap BuildMap(Action? configure = null) + => BuildProvider(configure).GetRequiredService(); + + private static ServiceProvider BuildProvider(Action? configure = null) { var services = new ServiceCollection(); services.AddRequestFlow(o => @@ -66,7 +91,7 @@ private static DispatchMap BuildMap(Action? configure = null configure?.Invoke(o); }); - return services.BuildServiceProvider().GetRequiredService(); + return services.BuildServiceProvider(); } public sealed record Echo(string Text) : IRequest; @@ -89,7 +114,7 @@ public sealed class WrapStage : IRequestStage { public Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } @@ -97,7 +122,7 @@ public sealed class ExtraStage : IRequestStage { public Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } diff --git a/tests/RequestFlow.Tests.Unit/Stages/AddStageTests.cs b/tests/RequestFlow.Tests.Unit/Stages/AddStageTests.cs index b551eb3..3401a1c 100644 --- a/tests/RequestFlow.Tests.Unit/Stages/AddStageTests.cs +++ b/tests/RequestFlow.Tests.Unit/Stages/AddStageTests.cs @@ -258,13 +258,13 @@ private interface ITag private sealed class LoggingStage : IRequestStage where TRequest : IRequest { - public Task HandleAsync(TRequest request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(TRequest request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } private sealed class PingAuditStage : IRequestStage { - public Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } @@ -272,14 +272,14 @@ public Task HandleAsync(Ping request, IContinuation next, Cancel // it even though it implements IRequestStage. private sealed class OneParameterStage : IRequestStage { - public Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } private sealed class ResponseBoundStage : IRequestStage where TRequest : IRequest { - public Task HandleAsync(TRequest request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(TRequest request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } @@ -288,20 +288,20 @@ public Task HandleAsync(TRequest request, IContinuation next, Ca private sealed class SwappedStage : IRequestStage where TRequest : IRequest { - public Task HandleAsync(TRequest request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(TRequest request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } private sealed class VoidOnlyStage : IRequestStage where TRequest : IRequest { - public Task HandleAsync(TRequest request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(TRequest request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } private sealed class WipeAuditStage : IRequestStage { - public Task HandleAsync(Wipe request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Wipe request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } @@ -310,7 +310,7 @@ private sealed class NotAStage private abstract class AbstractStage : IRequestStage { - public abstract Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken); + public abstract Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken); } #endregion diff --git a/tests/RequestFlow.Tests.Unit/Stages/ChainAllocationTests.cs b/tests/RequestFlow.Tests.Unit/Stages/ChainAllocationTests.cs new file mode 100644 index 0000000..a380f6c --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Stages/ChainAllocationTests.cs @@ -0,0 +1,319 @@ +// .NET Framework has no GC.GetAllocatedBytesForCurrentThread, so the whole fixture is compiled out +// there and the other two targets cover it. +#if NET8_0_OR_GREATER +using Microsoft.Extensions.DependencyInjection; +using RequestFlow; + +namespace RequestFlow.Tests.Unit; + +public sealed class ChainAllocationTests +{ + // A staged dispatch allocates nothing of RequestFlow's own, so the only bytes left are the + // handler's task. + [Fact] + public async Task Given_A_Two_Stage_Chain_When_Dispatching_Then_It_Allocates_No_More_Than_A_Plain_Dispatch() + { + // Singletons: a transient stage is an allocation the container makes, not RequestFlow. + IRequestDispatcher staged = Build(o => + { + o.AddStage(typeof(OuterStage), s => s.AsSingleton()); + o.AddStage(typeof(InnerStage), s => s.AsSingleton()); + }); + IRequestDispatcher plain = Build(); + + long stagedBytes = await MeasureAsync(() => staged.SendAsync(new Ping())); + long plainBytes = await MeasureAsync(() => plain.SendAsync(new Bare())); + + stagedBytes.ShouldBe(plainBytes); + } + + // One stage on its own, so a cost that grows per level is caught at the first level too. + [Fact] + public async Task Given_A_One_Stage_Chain_When_Dispatching_Then_It_Allocates_No_More_Than_A_Plain_Dispatch() + { + IRequestDispatcher staged = Build(o => o.AddStage(typeof(OuterStage), s => s.AsSingleton())); + IRequestDispatcher plain = Build(); + + long stagedBytes = await MeasureAsync(() => staged.SendAsync(new Ping())); + long plainBytes = await MeasureAsync(() => plain.SendAsync(new Bare())); + + stagedBytes.ShouldBe(plainBytes); + } + + // A void level hands the container's plain Task to the NoResult bridge. A completed task crosses + // it on the cached task, so a chain that finishes synchronously pays nothing per level. + [Fact] + public async Task Given_A_Two_Stage_Void_Chain_That_Finishes_Synchronously_When_Dispatching_Then_It_Allocates_No_More_Than_A_Plain_Dispatch() + { + IRequestDispatcher staged = Build(o => + { + o.AddStage(typeof(OuterSignalStage), s => s.AsSingleton()); + o.AddStage(typeof(InnerSignalStage), s => s.AsSingleton()); + }); + IRequestDispatcher plain = Build(); + + long stagedBytes = await MeasureAsync(() => staged.SendAsync(new Signal())); + long plainBytes = await MeasureAsync(() => plain.SendAsync(new BareSignal())); + + stagedBytes.ShouldBe(plainBytes); + } + + // The typed shape has no bridge, so suspending changes nothing: the stage's task is the level's + // task and passes through. + [Fact] + public async Task Given_A_Two_Stage_Typed_Chain_That_Suspends_When_Dispatching_Then_It_Allocates_No_More_Than_A_Plain_Dispatch() + { + IRequestDispatcher staged = Build(o => + { + o.AddStage(typeof(OuterHeldStage), s => s.AsSingleton()); + o.AddStage(typeof(InnerHeldStage), s => s.AsSingleton()); + }); + IRequestDispatcher plain = Build(); + + long stagedBytes = await MeasureAsync(() => GatedAsync(gate => staged.SendAsync(new Held(gate)))); + long plainBytes = await MeasureAsync(() => GatedAsync(gate => plain.SendAsync(new BareHeld(gate)))); + + stagedBytes.ShouldBe(plainBytes); + } + + // A pass-through void stage hands back the level's own Task, which the bridge takes as + // it is, so a chain still running when it returns costs no more than one that finished. + [Fact] + public async Task Given_A_Two_Stage_Void_Chain_That_Suspends_When_Dispatching_Then_It_Allocates_No_More_Than_A_Plain_Dispatch() + { + IRequestDispatcher staged = Build(o => + { + o.AddStage(typeof(OuterGatedStage), s => s.AsSingleton()); + o.AddStage(typeof(InnerGatedStage), s => s.AsSingleton()); + }); + IRequestDispatcher plain = Build(); + + long stagedBytes = await MeasureAsync(() => GatedAsync(gate => staged.SendAsync(new Gated(gate)))); + long plainBytes = await MeasureAsync(() => GatedAsync(gate => plain.SendAsync(new BareGated(gate)))); + + stagedBytes.ShouldBe(plainBytes); + } + + // The cost that survives the cast. An async stage returns its own builder's plain Task, so a + // suspended void level of that shape crosses the bridge on a Task of its own. Pinned + // against the typed chain, where the same stage costs its state machine and nothing more, rather + // than against a byte figure that differs per runtime. + [Fact] + public async Task Given_A_Void_Chain_Of_Async_Stages_That_Suspends_When_Dispatching_Then_Every_Level_Costs_More_Than_A_Typed_One() + { + IRequestDispatcher voidOne = Build(o => o.AddStage(typeof(OuterAwaitingStage), s => s.AsSingleton())); + IRequestDispatcher voidTwo = Build(o => + { + o.AddStage(typeof(OuterAwaitingStage), s => s.AsSingleton()); + o.AddStage(typeof(InnerAwaitingStage), s => s.AsSingleton()); + }); + IRequestDispatcher typedOne = Build(o => o.AddStage(typeof(OuterAwaitingHeldStage), s => s.AsSingleton())); + IRequestDispatcher typedTwo = Build(o => + { + o.AddStage(typeof(OuterAwaitingHeldStage), s => s.AsSingleton()); + o.AddStage(typeof(InnerAwaitingHeldStage), s => s.AsSingleton()); + }); + + long voidLevel = + await MeasureAsync(() => GatedAsync(gate => voidTwo.SendAsync(new Gated(gate)))) + - await MeasureAsync(() => GatedAsync(gate => voidOne.SendAsync(new Gated(gate)))); + long typedLevel = + await MeasureAsync(() => GatedAsync(gate => typedTwo.SendAsync(new Held(gate)))) + - await MeasureAsync(() => GatedAsync(gate => typedOne.SendAsync(new Held(gate)))); + + typedLevel.ShouldBeGreaterThan(0); + voidLevel.ShouldBeGreaterThan(typedLevel); + } + + #region Helpers + + private static IRequestDispatcher Build(Action? configure = null) + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => + { + o.RegisterHandlersFromAssemblyContaining(); + configure?.Invoke(o); + }); + + return services.BuildServiceProvider().CreateScope().ServiceProvider + .GetRequiredService(); + } + + // GC.GetAllocatedBytesForCurrentThread is exact for this thread, so one warmed dispatch is + // measurable without BenchmarkDotNet. It also counts only this thread, so every fixture below has + // to finish on the calling one: a handler that really suspends would take the second reading on a + // pool thread and the delta would mean nothing. + private static async Task MeasureAsync(Func dispatch) + { + // Microsoft's container compiles its call sites in the background a few resolutions in, so + // the loop keeps that changeover out of the measurement. + for (int i = 0; i < 64; i++) + { + await dispatch(); + } + + long before = GC.GetAllocatedBytesForCurrentThread(); + await dispatch(); + + return GC.GetAllocatedBytesForCurrentThread() - before; + } + + // The gate is released after every level has returned its task, so each one hands the bridge a + // task that is still running. Completing it on this thread keeps the resulting allocations here, + // where GC.GetAllocatedBytesForCurrentThread can see them. + private static async Task GatedAsync(Func, Task> dispatch) + { + var gate = new TaskCompletionSource(); + + Task walk = dispatch(gate); + gate.SetResult("released"); + + await walk; + } + + public sealed record Ping : IRequest; + + public sealed record Bare : IRequest; + + public sealed record Signal : IRequest; + + public sealed record BareSignal : IRequest; + + public sealed record Gated(TaskCompletionSource Gate) : IRequest; + + public sealed record BareGated(TaskCompletionSource Gate) : IRequest; + + public sealed record Held(TaskCompletionSource Gate) : IRequest; + + public sealed record BareHeld(TaskCompletionSource Gate) : IRequest; + + public sealed class PingHandler : IRequestHandler + { + public Task HandleAsync(Ping request, CancellationToken cancellationToken) + => Task.FromResult("ping"); + } + + public sealed class BareHandler : IRequestHandler + { + public Task HandleAsync(Bare request, CancellationToken cancellationToken) + => Task.FromResult("bare"); + } + + public sealed class OuterStage : IRequestStage + { + public Task HandleAsync( + Ping request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(cancellationToken); + } + + public sealed class InnerStage : IRequestStage + { + public Task HandleAsync( + Ping request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(cancellationToken); + } + + public sealed class SignalHandler : IRequestHandler + { + public Task HandleAsync(Signal request, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + public sealed class BareSignalHandler : IRequestHandler + { + public Task HandleAsync(BareSignal request, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + public sealed class OuterSignalStage : IRequestStage + { + public Task HandleAsync(Signal request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(cancellationToken); + } + + public sealed class InnerSignalStage : IRequestStage + { + public Task HandleAsync(Signal request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(cancellationToken); + } + + public sealed class GatedHandler : IRequestHandler + { + public Task HandleAsync(Gated request, CancellationToken cancellationToken) + => request.Gate.Task; + } + + public sealed class BareGatedHandler : IRequestHandler + { + public Task HandleAsync(BareGated request, CancellationToken cancellationToken) + => request.Gate.Task; + } + + public sealed class OuterGatedStage : IRequestStage + { + public Task HandleAsync(Gated request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(cancellationToken); + } + + public sealed class InnerGatedStage : IRequestStage + { + public Task HandleAsync(Gated request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(cancellationToken); + } + + public sealed class OuterAwaitingStage : IRequestStage + { + public async Task HandleAsync(Gated request, Continuation next, CancellationToken cancellationToken) + => await next.InvokeAsync(cancellationToken); + } + + public sealed class InnerAwaitingStage : IRequestStage + { + public async Task HandleAsync(Gated request, Continuation next, CancellationToken cancellationToken) + => await next.InvokeAsync(cancellationToken); + } + + public sealed class HeldHandler : IRequestHandler + { + public Task HandleAsync(Held request, CancellationToken cancellationToken) + => request.Gate.Task; + } + + public sealed class BareHeldHandler : IRequestHandler + { + public Task HandleAsync(BareHeld request, CancellationToken cancellationToken) + => request.Gate.Task; + } + + public sealed class OuterHeldStage : IRequestStage + { + public Task HandleAsync( + Held request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(cancellationToken); + } + + public sealed class InnerHeldStage : IRequestStage + { + public Task HandleAsync( + Held request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(cancellationToken); + } + + public sealed class OuterAwaitingHeldStage : IRequestStage + { + public async Task HandleAsync( + Held request, Continuation next, CancellationToken cancellationToken) + => await next.InvokeAsync(cancellationToken); + } + + public sealed class InnerAwaitingHeldStage : IRequestStage + { + public async Task HandleAsync( + Held request, Continuation next, CancellationToken cancellationToken) + => await next.InvokeAsync(cancellationToken); + } + + #endregion +} +#endif diff --git a/tests/RequestFlow.Tests.Unit/Stages/StageExecutorTests.cs b/tests/RequestFlow.Tests.Unit/Stages/ChainExecutionTests.cs similarity index 76% rename from tests/RequestFlow.Tests.Unit/Stages/StageExecutorTests.cs rename to tests/RequestFlow.Tests.Unit/Stages/ChainExecutionTests.cs index 6f60dea..b60e7e7 100644 --- a/tests/RequestFlow.Tests.Unit/Stages/StageExecutorTests.cs +++ b/tests/RequestFlow.Tests.Unit/Stages/ChainExecutionTests.cs @@ -4,12 +4,12 @@ namespace RequestFlow.Tests.Unit; -public sealed class StageExecutorTests +public sealed class ChainExecutionTests { [Fact] - public async Task Given_No_Stages_When_Running_Executor_Then_Handler_Produces_Response() + public async Task Given_No_Stages_When_Running_The_Chain_Then_Handler_Produces_Response() { - var sut = PingExecutor(); + var sut = PingChain(); string result = await sut.RunAsync(); @@ -17,11 +17,11 @@ public async Task Given_No_Stages_When_Running_Executor_Then_Handler_Produces_Re } [Fact] - public async Task Given_Two_Stages_When_Running_Executor_Then_First_Registered_Stage_Is_Outermost() + public async Task Given_Two_Stages_When_Running_The_Chain_Then_First_Registered_Stage_Is_Outermost() { List log = []; object[] stages = [new RecordingStage("outer", log), new RecordingStage("inner", log)]; - var sut = PingExecutor(stages); + var sut = PingChain(stages); await sut.RunAsync(); @@ -29,11 +29,11 @@ public async Task Given_Two_Stages_When_Running_Executor_Then_First_Registered_S } [Fact] - public async Task Given_Stage_That_Awaits_Before_Calling_Next_When_Running_Executor_Then_Chain_Completes() + public async Task Given_Stage_That_Awaits_Before_Calling_Next_When_Running_The_Chain_Then_Chain_Completes() { List log = []; object[] stages = [new AwaitBeforeNextStage("outer", log)]; - var sut = PingExecutor(stages); + var sut = PingChain(stages); string result = await sut.RunAsync(); @@ -42,11 +42,11 @@ public async Task Given_Stage_That_Awaits_Before_Calling_Next_When_Running_Execu } [Fact] - public async Task Given_Two_Stages_That_Await_Before_Calling_Next_When_Running_Executor_Then_First_Registered_Stage_Is_Outermost() + public async Task Given_Two_Stages_That_Await_Before_Calling_Next_When_Running_The_Chain_Then_First_Registered_Stage_Is_Outermost() { List log = []; object[] stages = [new AwaitBeforeNextStage("outer", log), new AwaitBeforeNextStage("inner", log)]; - var sut = PingExecutor(stages); + var sut = PingChain(stages); await sut.RunAsync(); @@ -54,10 +54,10 @@ public async Task Given_Two_Stages_That_Await_Before_Calling_Next_When_Running_E } [Fact] - public async Task Given_Stage_That_Skips_Next_When_Running_Executor_Then_Handler_Is_Not_Invoked() + public async Task Given_Stage_That_Skips_Next_When_Running_The_Chain_Then_Handler_Is_Not_Invoked() { object[] stages = [new ShortCircuitStage("cached")]; - var sut = PingExecutor(stages); + var sut = PingChain(stages); string result = await sut.RunAsync(); @@ -66,12 +66,12 @@ public async Task Given_Stage_That_Skips_Next_When_Running_Executor_Then_Handler } [Fact] - public async Task Given_Throwing_Handler_When_Running_Executor_Then_Exception_Propagates_Unwrapped() + public async Task Given_Throwing_Handler_When_Running_The_Chain_Then_Exception_Propagates_Unwrapped() { _pingHandler.HandleAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromException(new InvalidTimeZoneException("no such zone"))); object[] stages = [new RecordingStage("outer", [])]; - var sut = PingExecutor(stages); + var sut = PingChain(stages); var exception = await Should.ThrowAsync(() => sut.RunAsync()); @@ -79,13 +79,12 @@ public async Task Given_Throwing_Handler_When_Running_Executor_Then_Exception_Pr } [Fact] - public async Task Given_Cancellation_Token_When_Running_Executor_Then_Stage_And_Handler_Receive_Same_Token() + public async Task Given_Cancellation_Token_When_Running_The_Chain_Then_Stage_And_Handler_Receive_Same_Token() { using var cts = new CancellationTokenSource(); var stage = new TokenCapturingStage(); object[] stages = [stage]; - var sut = new TypedStageExecutor( - StageTypes(stages), ChainProvider(_pingHandler, stages), new Ping("hi"), cts.Token); + var sut = PingChainWithToken(cts.Token, stages); await sut.RunAsync(); @@ -99,7 +98,7 @@ public async Task Given_Stage_That_Substitutes_A_Token_When_Calling_Next_Then_Ha using var entry = new CancellationTokenSource(); using var substituted = new CancellationTokenSource(); object[] stages = [new SubstitutingStage(substituted.Token)]; - var sut = PingExecutorWithToken(entry.Token, stages); + var sut = PingChainWithToken(entry.Token, stages); await sut.RunAsync(); @@ -115,7 +114,7 @@ public async Task Given_Outer_Stage_Substituted_A_Token_When_Inner_Stage_Omits_O using var substituted = new CancellationTokenSource(); var inner = new TokenCapturingStage(); object[] stages = [new SubstitutingStage(substituted.Token), inner]; - var sut = PingExecutorWithToken(entry.Token, stages); + var sut = PingChainWithToken(entry.Token, stages); await sut.RunAsync(); @@ -130,7 +129,7 @@ public async Task Given_Stage_That_Passes_None_When_Calling_Next_Then_Inner_Leve using var entry = new CancellationTokenSource(); var inner = new TokenCapturingStage(); object[] stages = [new SubstitutingStage(CancellationToken.None), inner]; - var sut = PingExecutorWithToken(entry.Token, stages); + var sut = PingChainWithToken(entry.Token, stages); await sut.RunAsync(); @@ -140,13 +139,13 @@ public async Task Given_Stage_That_Passes_None_When_Calling_Next_Then_Inner_Leve } [Fact] - public async Task Given_Stage_That_Calls_Next_Twice_With_Different_Tokens_When_Running_Executor_Then_Each_Pass_Uses_Its_Own_Token() + public async Task Given_Stage_That_Calls_Next_Twice_With_Different_Tokens_When_Running_The_Chain_Then_Each_Pass_Uses_Its_Own_Token() { using var first = new CancellationTokenSource(); using var second = new CancellationTokenSource(); var inner = new TokenRecordingStage(); object[] stages = [new TwoTokenStage(first.Token, second.Token), inner]; - var sut = PingExecutor(stages); + var sut = PingChain(stages); await sut.RunAsync(); @@ -163,7 +162,7 @@ public async Task Given_Timeout_Stage_That_Cancels_And_Awaits_Its_Call_When_An_O : Task.FromResult("second")); var timeout = new CancelAndAwaitStage(); object[] stages = [new RetryOnceStage([]), timeout]; - var sut = PingExecutor(stages); + var sut = PingChain(stages); string result = await sut.RunAsync(); @@ -172,12 +171,12 @@ public async Task Given_Timeout_Stage_That_Cancels_And_Awaits_Its_Call_When_An_O } [Fact] - public async Task Given_Void_Form_Stage_That_Substitutes_A_Token_When_Running_Void_Executor_Then_Handler_Receives_The_Substituted_Token() + public async Task Given_Void_Form_Stage_That_Substitutes_A_Token_When_Running_The_Void_Chain_Then_Handler_Receives_The_Substituted_Token() { var logHandler = Substitute.For>(); using var substituted = new CancellationTokenSource(); object[] stages = [new SubstitutingVoidStage(substituted.Token)]; - var sut = LogExecutor(logHandler, stages); + var sut = LogChain(logHandler, stages); await sut.RunAsync(); @@ -185,12 +184,12 @@ public async Task Given_Void_Form_Stage_That_Substitutes_A_Token_When_Running_Vo } [Fact] - public async Task Given_Void_Handler_And_One_Stage_When_Running_Executor_Then_Handler_Runs_And_Chain_Completes() + public async Task Given_Void_Handler_And_One_Stage_When_Running_The_Chain_Then_Handler_Runs_And_Chain_Completes() { var logHandler = Substitute.For>(); List log = []; object[] stages = [new RecordingVoidStage(log)]; - var sut = LogExecutor(logHandler, stages); + var sut = LogChain(logHandler, stages); NoResult result = await sut.RunAsync(); @@ -200,11 +199,11 @@ public async Task Given_Void_Handler_And_One_Stage_When_Running_Executor_Then_Ha } [Fact] - public async Task Given_Stage_That_Calls_Next_Twice_When_Running_Executor_Then_Inner_Chain_Runs_Again() + public async Task Given_Stage_That_Calls_Next_Twice_When_Running_The_Chain_Then_Inner_Chain_Runs_Again() { List log = []; object[] stages = [new DoubleNextStage("outer", log), new RecordingStage("inner", log)]; - var sut = PingExecutor(stages); + var sut = PingChain(stages); await sut.RunAsync(); @@ -219,7 +218,7 @@ public async Task Given_Asynchronously_Completing_Handler_When_Stage_Calls_Next_ .Returns(call => YieldThenReturnAsync(call.Arg().Text + ":handled")); List log = []; object[] stages = [new DoubleNextStage("outer", log), new RecordingStage("inner", log)]; - var sut = PingExecutor(stages); + var sut = PingChain(stages); await sut.RunAsync(); @@ -236,7 +235,7 @@ public async Task Given_Failing_Handler_When_Outer_Stage_Retries_Then_Inner_Chai : Task.FromResult("second")); List log = []; object[] stages = [new RetryOnceStage(log), new RecordingStage("inner", log)]; - var sut = PingExecutor(stages); + var sut = PingChain(stages); string result = await sut.RunAsync(); @@ -249,7 +248,7 @@ public async Task Given_Stage_That_Throws_Before_Returning_A_Task_When_Outer_Sta { var flaky = new ThrowOnFirstAttemptStage(); object[] stages = [new RetryOnceStage([]), flaky]; - var sut = PingExecutor(stages); + var sut = PingChain(stages); string result = await sut.RunAsync(); @@ -258,13 +257,13 @@ public async Task Given_Stage_That_Throws_Before_Returning_A_Task_When_Outer_Sta } [Fact] - public async Task Given_Stage_That_Calls_Next_Again_Before_The_First_Call_Completes_When_Running_Executor_Then_Both_Walks_Run_The_Inner_Chain() + public async Task Given_Stage_That_Calls_Next_Again_Before_The_First_Call_Completes_When_Running_The_Chain_Then_Both_Walks_Run_The_Inner_Chain() { var gate = new TaskCompletionSource(); var handler = new GatedPingHandler(gate.Task); var inner = new ConcurrentRecordingStage(); object[] stages = [new OverlappingNextStage(gate), inner]; - var sut = PingExecutorFor(handler, stages); + var sut = PingChainFor(handler, stages); await sut.RunAsync(); @@ -275,13 +274,13 @@ public async Task Given_Stage_That_Calls_Next_Again_Before_The_First_Call_Comple // Inner levels build their continuations elsewhere than the outermost, so this is a separate case. [Fact] - public async Task Given_Inner_Stage_That_Calls_Next_Again_Before_The_First_Call_Completes_When_Running_Executor_Then_Both_Walks_Reach_The_Handler() + public async Task Given_Inner_Stage_That_Calls_Next_Again_Before_The_First_Call_Completes_When_Running_The_Chain_Then_Both_Walks_Reach_The_Handler() { var gate = new TaskCompletionSource(); var handler = new GatedPingHandler(gate.Task); List log = []; object[] stages = [new RecordingStage("outer", log), new OverlappingNextStage(gate)]; - var sut = PingExecutorFor(handler, stages); + var sut = PingChainFor(handler, stages); await sut.RunAsync(); @@ -298,7 +297,7 @@ public async Task Given_Stage_That_Abandoned_A_Pending_Next_Call_When_An_Outer_S _pingHandler.HandleAsync(Arg.Any(), Arg.Any()).Returns(pending.Task); var abandoning = new AbandonPendingNextStage(); object[] stages = [new RetryOnceStage([]), abandoning]; - var sut = PingExecutor(stages); + var sut = PingChain(stages); TimeoutException exception = await Should.ThrowAsync(() => sut.RunAsync()); @@ -307,14 +306,14 @@ public async Task Given_Stage_That_Abandoned_A_Pending_Next_Call_When_An_Outer_S } [Fact] - public async Task Given_Stage_That_Overlaps_Two_Next_Calls_With_Different_Tokens_When_Running_Executor_Then_Each_Walk_Keeps_Its_Own_Token() + public async Task Given_Stage_That_Overlaps_Two_Next_Calls_With_Different_Tokens_When_Running_The_Chain_Then_Each_Walk_Keeps_Its_Own_Token() { using var first = new CancellationTokenSource(); using var second = new CancellationTokenSource(); var gate = new TaskCompletionSource(); var handler = new GatedPingHandler(gate.Task); object[] stages = [new OverlappingTwoTokenStage(gate, first.Token, second.Token), new YieldThenPassThroughStage()]; - var sut = PingExecutorFor(handler, stages); + var sut = PingChainFor(handler, stages); await sut.RunAsync(); @@ -332,8 +331,11 @@ public async Task Given_Transient_Stage_When_An_Outer_Stage_Retries_Then_Each_At ? Task.FromException(new InvalidTimeZoneException("transient")) : Task.FromResult("second")); Type[] stageTypes = [typeof(ParameterlessRetryStage), typeof(CountingConstructionStage)]; - var sut = new TypedStageExecutor( - stageTypes, TransientChainProvider(_pingHandler, stageTypes), new Ping("hi"), CancellationToken.None); + var sut = new ChainRunner( + TypedChain(stageTypes), + new Ping("hi"), + TransientChainProvider(_pingHandler, stageTypes), + CancellationToken.None); string result = await sut.RunAsync(); @@ -344,7 +346,7 @@ public async Task Given_Transient_Stage_When_An_Outer_Stage_Retries_Then_Each_At // Repeated to catch per-level state creeping back: a rejected call throws inside Task.Run, // failing the WhenAll rather than the count. [Fact] - public async Task Given_Stage_That_Calls_Next_From_Two_Threads_At_Once_When_Running_Executor_Then_Both_Calls_Proceed() + public async Task Given_Stage_That_Calls_Next_From_Two_Threads_At_Once_When_Running_The_Chain_Then_Both_Calls_Proceed() { const int attempts = 100; int handlerRuns = 0; @@ -353,7 +355,7 @@ public async Task Given_Stage_That_Calls_Next_From_Two_Threads_At_Once_When_Runn { var gate = new TaskCompletionSource(); var handler = new GatedPingHandler(gate.Task); - var sut = PingExecutorFor(handler, new SimultaneousNextStage(gate)); + var sut = PingChainFor(handler, new SimultaneousNextStage(gate)); await sut.RunAsync(); @@ -364,7 +366,7 @@ public async Task Given_Stage_That_Calls_Next_From_Two_Threads_At_Once_When_Runn } [Fact] - public async Task Given_Inner_Stage_That_Calls_Next_From_Two_Threads_At_Once_When_Running_Executor_Then_Both_Calls_Proceed() + public async Task Given_Inner_Stage_That_Calls_Next_From_Two_Threads_At_Once_When_Running_The_Chain_Then_Both_Calls_Proceed() { const int attempts = 100; int handlerRuns = 0; @@ -378,7 +380,7 @@ public async Task Given_Inner_Stage_That_Calls_Next_From_Two_Threads_At_Once_Whe new RecordingStage("outer", []), new SimultaneousNextStage(gate), ]; - var sut = PingExecutorFor(handler, stages); + var sut = PingChainFor(handler, stages); await sut.RunAsync(); @@ -389,13 +391,13 @@ public async Task Given_Inner_Stage_That_Calls_Next_From_Two_Threads_At_Once_Whe } [Fact] - public async Task Given_Void_Form_Stage_That_Calls_Next_Twice_When_Running_Void_Executor_Then_Inner_Chain_Runs_Again() + public async Task Given_Void_Form_Stage_That_Calls_Next_Twice_When_Running_The_Void_Chain_Then_Inner_Chain_Runs_Again() { var handler = Substitute.For>(); handler.HandleAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); List log = []; object[] stages = [new DoubleNextVoidStage(log), new RecordingVoidStage(log)]; - var sut = LogExecutor(handler, stages); + var sut = LogChain(handler, stages); await sut.RunAsync(); @@ -404,7 +406,7 @@ public async Task Given_Void_Form_Stage_That_Calls_Next_Twice_When_Running_Void_ } [Fact] - public async Task Given_Void_Form_Stage_That_Calls_Next_From_Two_Threads_At_Once_When_Running_Void_Executor_Then_Both_Calls_Proceed() + public async Task Given_Void_Form_Stage_That_Calls_Next_From_Two_Threads_At_Once_When_Running_The_Void_Chain_Then_Both_Calls_Proceed() { const int attempts = 100; int handlerRuns = 0; @@ -414,7 +416,7 @@ public async Task Given_Void_Form_Stage_That_Calls_Next_From_Two_Threads_At_Once var gate = new TaskCompletionSource(); var handler = new GatedLogHandler(gate.Task); object[] stages = [new SimultaneousNextVoidStage(gate)]; - var sut = LogExecutor(handler, stages); + var sut = LogChain(handler, stages); await sut.RunAsync(); @@ -425,7 +427,7 @@ public async Task Given_Void_Form_Stage_That_Calls_Next_From_Two_Threads_At_Once } [Fact] - public async Task Given_Inner_Void_Form_Stage_That_Calls_Next_From_Two_Threads_At_Once_When_Running_Void_Executor_Then_Both_Calls_Proceed() + public async Task Given_Inner_Void_Form_Stage_That_Calls_Next_From_Two_Threads_At_Once_When_Running_The_Void_Chain_Then_Both_Calls_Proceed() { const int attempts = 100; int handlerRuns = 0; @@ -439,7 +441,7 @@ public async Task Given_Inner_Void_Form_Stage_That_Calls_Next_From_Two_Threads_A new VoidFormStage([]), new SimultaneousNextVoidStage(gate), ]; - var sut = LogExecutor(handler, stages); + var sut = LogChain(handler, stages); await sut.RunAsync(); @@ -450,10 +452,10 @@ public async Task Given_Inner_Void_Form_Stage_That_Calls_Next_From_Two_Threads_A } [Fact] - public void Given_Stage_That_Returns_A_Null_Task_When_Running_Executor_Then_Throws_Naming_The_Stage() + public void Given_Stage_That_Returns_A_Null_Task_When_Running_The_Chain_Then_Throws_Naming_The_Stage() { object[] stages = [new NullTaskStage()]; - var sut = PingExecutor(stages); + var sut = PingChain(stages); StageNullTaskException exception = Should.Throw(() => sut.RunAsync()); @@ -462,10 +464,13 @@ public void Given_Stage_That_Returns_A_Null_Task_When_Running_Executor_Then_Thro } [Fact] - public void Given_Handler_That_Returns_A_Null_Task_When_Running_Executor_Then_Throws_Naming_The_Request() + public void Given_Handler_That_Returns_A_Null_Task_When_Running_The_Chain_Then_Throws_Naming_The_Request() { - var sut = new TypedStageExecutor( - [], ChainProvider>(new NilHandler(), []), new Nil(), CancellationToken.None); + var sut = new ChainRunner( + TypedChain([]), + new Nil(), + ChainProvider>(new NilHandler(), []), + CancellationToken.None); HandlerNullTaskException exception = Should.Throw(() => sut.RunAsync()); @@ -478,7 +483,7 @@ public async Task Given_Stage_That_Returned_A_Null_Task_When_Outer_Stage_Retries { var flaky = new NullTaskOnFirstAttemptStage(); object[] stages = [new RetryOnceStage([]), flaky]; - var sut = PingExecutor(stages); + var sut = PingChain(stages); string result = await sut.RunAsync(); @@ -487,12 +492,12 @@ public async Task Given_Stage_That_Returned_A_Null_Task_When_Outer_Stage_Retries } [Fact] - public async Task Given_Void_Form_Stage_When_Running_Void_Executor_Then_It_Wraps_The_Handler() + public async Task Given_Void_Form_Stage_When_Running_The_Void_Chain_Then_It_Wraps_The_Handler() { var logHandler = Substitute.For>(); List log = []; object[] stages = [new VoidFormStage(log)]; - var sut = LogExecutor(logHandler, stages); + var sut = LogChain(logHandler, stages); NoResult result = await sut.RunAsync(); @@ -502,12 +507,12 @@ public async Task Given_Void_Form_Stage_When_Running_Void_Executor_Then_It_Wraps } [Fact] - public async Task Given_Void_Form_Stage_That_Awaits_Before_Calling_Next_When_Running_Void_Executor_Then_Chain_Completes() + public async Task Given_Void_Form_Stage_That_Awaits_Before_Calling_Next_When_Running_The_Void_Chain_Then_Chain_Completes() { var logHandler = Substitute.For>(); List log = []; object[] stages = [new AwaitBeforeNextVoidStage(log)]; - var sut = LogExecutor(logHandler, stages); + var sut = LogChain(logHandler, stages); NoResult result = await sut.RunAsync(); @@ -517,12 +522,12 @@ public async Task Given_Void_Form_Stage_That_Awaits_Before_Calling_Next_When_Run } [Fact] - public async Task Given_Both_Stage_Forms_When_Running_Void_Executor_Then_Registration_Order_Is_Execution_Order() + public async Task Given_Both_Stage_Forms_When_Running_The_Void_Chain_Then_Registration_Order_Is_Execution_Order() { var logHandler = Substitute.For>(); List log = []; object[] stages = [new RecordingVoidStage(log), new VoidFormStage(log)]; - var sut = LogExecutor(logHandler, stages); + var sut = LogChain(logHandler, stages); await sut.RunAsync(); @@ -530,11 +535,11 @@ public async Task Given_Both_Stage_Forms_When_Running_Void_Executor_Then_Registr } [Fact] - public async Task Given_Void_Form_Stage_That_Skips_Next_When_Running_Void_Executor_Then_Handler_Is_Not_Invoked() + public async Task Given_Void_Form_Stage_That_Skips_Next_When_Running_The_Void_Chain_Then_Handler_Is_Not_Invoked() { var logHandler = Substitute.For>(); object[] stages = [new ShortCircuitVoidStage()]; - var sut = LogExecutor(logHandler, stages); + var sut = LogChain(logHandler, stages); await sut.RunAsync(); @@ -542,11 +547,11 @@ public async Task Given_Void_Form_Stage_That_Skips_Next_When_Running_Void_Execut } [Fact] - public void Given_Void_Form_Stage_That_Returns_A_Null_Task_When_Running_Void_Executor_Then_Throws_Naming_The_Stage() + public void Given_Void_Form_Stage_That_Returns_A_Null_Task_When_Running_The_Void_Chain_Then_Throws_Naming_The_Stage() { var logHandler = Substitute.For>(); object[] stages = [new NullTaskVoidStage()]; - var sut = LogExecutor(logHandler, stages); + var sut = LogChain(logHandler, stages); StageNullTaskException exception = Should.Throw(() => sut.RunAsync()); @@ -554,10 +559,13 @@ public void Given_Void_Form_Stage_That_Returns_A_Null_Task_When_Running_Void_Exe } [Fact] - public void Given_Void_Handler_That_Returns_A_Null_Task_When_Running_Void_Executor_Then_Throws_Naming_The_Request() + public void Given_Void_Handler_That_Returns_A_Null_Task_When_Running_The_Void_Chain_Then_Throws_Naming_The_Request() { - var sut = new VoidStageExecutor( - [], [], ChainProvider>(new SilentHandler(), []), new Silent(), CancellationToken.None); + var sut = new ChainRunner( + VoidChain([], []), + new Silent(), + ChainProvider>(new SilentHandler(), []), + CancellationToken.None); HandlerNullTaskException exception = Should.Throw(() => sut.RunAsync()); @@ -574,10 +582,10 @@ public async Task Given_Synchronously_Completed_Task_When_Bridging_To_No_Result_ } [Fact] - public async Task Given_One_Stage_When_Running_Executor_Then_Stage_Wraps_The_Handler() + public async Task Given_One_Stage_When_Running_The_Chain_Then_Stage_Wraps_The_Handler() { List log = []; - var sut = PingExecutor(new RecordingStage("only", log)); + var sut = PingChain(new RecordingStage("only", log)); string result = await sut.RunAsync(); @@ -586,10 +594,10 @@ public async Task Given_One_Stage_When_Running_Executor_Then_Stage_Wraps_The_Han } [Fact] - public async Task Given_One_Stage_That_Calls_Next_Twice_When_Running_Executor_Then_Handler_Runs_Again() + public async Task Given_One_Stage_That_Calls_Next_Twice_When_Running_The_Chain_Then_Handler_Runs_Again() { List log = []; - var sut = PingExecutor(new DoubleNextStage("only", log)); + var sut = PingChain(new DoubleNextStage("only", log)); await sut.RunAsync(); @@ -598,11 +606,11 @@ public async Task Given_One_Stage_That_Calls_Next_Twice_When_Running_Executor_Th } [Fact] - public async Task Given_One_Stage_That_Calls_Next_Again_Before_The_First_Call_Completes_When_Running_Executor_Then_Both_Walks_Reach_The_Handler() + public async Task Given_One_Stage_That_Calls_Next_Again_Before_The_First_Call_Completes_When_Running_The_Chain_Then_Both_Walks_Reach_The_Handler() { var gate = new TaskCompletionSource(); var handler = new GatedPingHandler(gate.Task); - var sut = PingExecutorFor(handler, new OverlappingNextStage(gate)); + var sut = PingChainFor(handler, new OverlappingNextStage(gate)); await sut.RunAsync(); @@ -610,9 +618,9 @@ public async Task Given_One_Stage_That_Calls_Next_Again_Before_The_First_Call_Co } [Fact] - public void Given_One_Stage_That_Returns_A_Null_Task_When_Running_Executor_Then_Throws_Naming_The_Stage() + public void Given_One_Stage_That_Returns_A_Null_Task_When_Running_The_Chain_Then_Throws_Naming_The_Stage() { - var sut = PingExecutor(new NullTaskStage()); + var sut = PingChain(new NullTaskStage()); StageNullTaskException exception = Should.Throw(() => sut.RunAsync()); @@ -620,13 +628,13 @@ public void Given_One_Stage_That_Returns_A_Null_Task_When_Running_Executor_Then_ } [Fact] - public void Given_One_Stage_And_Handler_That_Returns_A_Null_Task_When_Running_Executor_Then_Throws_Naming_The_Request() + public void Given_One_Stage_And_Handler_That_Returns_A_Null_Task_When_Running_The_Chain_Then_Throws_Naming_The_Request() { object[] stages = [new NilPassThroughStage()]; - var sut = new TypedStageExecutor( - StageTypes(stages), - ChainProvider>(new NilHandler(), stages), + var sut = new ChainRunner( + TypedChain(StageTypes(stages)), new Nil(), + ChainProvider>(new NilHandler(), stages), CancellationToken.None); HandlerNullTaskException exception = Should.Throw(() => sut.RunAsync()); @@ -635,11 +643,11 @@ public void Given_One_Stage_And_Handler_That_Returns_A_Null_Task_When_Running_Ex } [Fact] - public async Task Given_One_Typed_Form_Stage_When_Running_Void_Executor_Then_It_Wraps_The_Handler() + public async Task Given_One_Typed_Form_Stage_When_Running_The_Void_Chain_Then_It_Wraps_The_Handler() { var logHandler = Substitute.For>(); List log = []; - var sut = LogExecutor(logHandler, new RecordingVoidStage(log)); + var sut = LogChain(logHandler, new RecordingVoidStage(log)); NoResult result = await sut.RunAsync(); @@ -649,12 +657,12 @@ public async Task Given_One_Typed_Form_Stage_When_Running_Void_Executor_Then_It_ } [Fact] - public async Task Given_One_Void_Form_Stage_That_Calls_Next_Twice_When_Running_Void_Executor_Then_Handler_Runs_Again() + public async Task Given_One_Void_Form_Stage_That_Calls_Next_Twice_When_Running_The_Void_Chain_Then_Handler_Runs_Again() { var handler = Substitute.For>(); handler.HandleAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); List log = []; - var sut = LogExecutor(handler, new DoubleNextVoidStage(log)); + var sut = LogChain(handler, new DoubleNextVoidStage(log)); await sut.RunAsync(); @@ -666,7 +674,7 @@ public async Task Given_One_Void_Form_Stage_That_Calls_Next_Twice_When_Running_V private readonly IRequestHandler _pingHandler; - public StageExecutorTests() + public ChainExecutionTests() { _pingHandler = Substitute.For>(); _pingHandler.HandleAsync(Arg.Any(), Arg.Any()) @@ -677,6 +685,28 @@ public StageExecutorTests() #region Helpers + // Enters the frozen chain directly, the way a dispatch does, without the dispatcher. + private sealed class ChainRunner( + LevelEntry root, + TRequest request, + IServiceProvider services, + CancellationToken cancellationToken) + where TRequest : IRequest + { + public Task RunAsync() => root(request!, services, cancellationToken); + } + + private static LevelEntry TypedChain(Type[] stageTypes) + where TRequest : IRequest + => ChainBuilder.Typed(Chain(stageTypes, [])); + + private static LevelEntry VoidChain(Type[] stageTypes, bool[] typedShapes) + where TRequest : IRequest + => ChainBuilder.Void(Chain(stageTypes, typedShapes)); + + private static StageChain Chain(Type[] stageTypes, bool[] typedShapes) + => new(stageTypes, typedShapes); + // Each level resolves its stage from DI by type, so every level needs a distinct stage type. private static ServiceProvider ChainProvider(THandler handler, object[] stages) where THandler : class @@ -728,22 +758,29 @@ private static bool[] TypedShapes(object[] stages) return shapes; } - private TypedStageExecutor PingExecutor(params object[] stages) - => PingExecutorFor(_pingHandler, stages); + private ChainRunner PingChain(params object[] stages) + => PingChainFor(_pingHandler, stages); - private static TypedStageExecutor PingExecutorFor( + private static ChainRunner PingChainFor( IRequestHandler handler, params object[] stages) - => new(StageTypes(stages), ChainProvider(handler, stages), new Ping("hi"), CancellationToken.None); + => PingRunner(handler, stages, CancellationToken.None); - private TypedStageExecutor PingExecutorWithToken(CancellationToken cancellationToken, object[] stages) - => new(StageTypes(stages), ChainProvider(_pingHandler, stages), new Ping("hi"), cancellationToken); + private ChainRunner PingChainWithToken(CancellationToken cancellationToken, object[] stages) + => PingRunner(_pingHandler, stages, cancellationToken); - private static VoidStageExecutor LogExecutor(IRequestHandler handler, params object[] stages) + private static ChainRunner PingRunner( + IRequestHandler handler, object[] stages, CancellationToken cancellationToken) => new( - StageTypes(stages), - TypedShapes(stages), + TypedChain(StageTypes(stages)), + new Ping("hi"), ChainProvider(handler, stages), + cancellationToken); + + private static ChainRunner LogChain(IRequestHandler handler, params object[] stages) + => new( + VoidChain(StageTypes(stages), TypedShapes(stages)), new Log("hi"), + ChainProvider(handler, stages), CancellationToken.None); // Position markers, so two levels of the same stage class are two registrable types. @@ -791,7 +828,7 @@ public Task HandleAsync(Silent request, CancellationToken cancellationToken) private sealed class RecordingStage(string name, List log) : IRequestStage { - public async Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { log.Add($"{name}:enter"); string response = await next.InvokeAsync(); @@ -802,7 +839,7 @@ public async Task HandleAsync(Ping request, IContinuation next, private sealed class RecordingVoidStage(List log) : IRequestStage { - public async Task HandleAsync(Log request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Log request, Continuation next, CancellationToken cancellationToken) { log.Add("enter"); NoResult response = await next.InvokeAsync(); @@ -813,13 +850,13 @@ public async Task HandleAsync(Log request, IContinuation nex private sealed class ShortCircuitStage(string response) : IRequestStage { - public Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) => Task.FromResult(response); } private sealed class DoubleNextStage(string name, List log) : IRequestStage { - public async Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { log.Add($"{name}:enter"); await next.InvokeAsync(); @@ -831,7 +868,7 @@ public async Task HandleAsync(Ping request, IContinuation next, private sealed class DoubleNextVoidStage(List log) : IRequestStage { - public async Task HandleAsync(Log request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Log request, Continuation next, CancellationToken cancellationToken) { log.Add("void:enter"); await next.InvokeAsync(); @@ -842,7 +879,7 @@ public async Task HandleAsync(Log request, IContinuation next, CancellationToken private sealed class RetryOnceStage(List log) : IRequestStage { - public async Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { log.Add("retry:attempt"); try @@ -857,12 +894,11 @@ public async Task HandleAsync(Ping request, IContinuation next, } } - // Starts the rest of the chain and gives up without awaiting it, leaving the call in flight. private sealed class AbandonPendingNextStage : IRequestStage { public int Attempts { get; private set; } - public Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { Attempts++; _ = next.InvokeAsync(); @@ -875,17 +911,16 @@ private sealed class ThrowOnFirstAttemptStage : IRequestStage { public int Attempts { get; private set; } - public Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { Attempts++; return Attempts == 1 ? throw new InvalidOperationException("sync boom") : next.InvokeAsync(); } } - // Suspends on work of its own before delegating. private sealed class AwaitBeforeNextStage(string name, List log) : IRequestStage { - public async Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { await Task.Yield(); log.Add($"{name}:enter"); @@ -897,7 +932,7 @@ public async Task HandleAsync(Ping request, IContinuation next, private sealed class AwaitBeforeNextVoidStage(List log) : IRequestStage { - public async Task HandleAsync(Log request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Log request, Continuation next, CancellationToken cancellationToken) { await Task.Yield(); log.Add("void:enter"); @@ -952,7 +987,7 @@ private sealed class ConcurrentRecordingStage : IRequestStage public int Exits => Volatile.Read(ref _exits); - public async Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { Interlocked.Increment(ref _entries); string response = await next.InvokeAsync(); @@ -962,10 +997,9 @@ public async Task HandleAsync(Ping request, IContinuation next, } } - // Starts a second call while the first is suspended on the handler, then releases the gate. private sealed class OverlappingNextStage(TaskCompletionSource gate) : IRequestStage { - public async Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { Task first = next.InvokeAsync(); Task second = next.InvokeAsync(); @@ -983,7 +1017,7 @@ private sealed class OverlappingTwoTokenStage( TaskCompletionSource gate, CancellationToken first, CancellationToken second) : IRequestStage { - public async Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { Task firstCall = next.InvokeAsync(first); Task secondCall = next.InvokeAsync(second); @@ -999,7 +1033,7 @@ public async Task HandleAsync(Ping request, IContinuation next, // Yields so both overlapping walks are inside this stage before either calls next. private sealed class YieldThenPassThroughStage : IRequestStage { - public async Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { await Task.Yield(); @@ -1007,10 +1041,9 @@ public async Task HandleAsync(Ping request, IContinuation next, } } - // Releases both callers into next at once, so one level is entered twice simultaneously. private sealed class SimultaneousNextStage(TaskCompletionSource gate) : IRequestStage { - public async Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { using var barrier = new Barrier(2); Task?[] calls = new Task?[2]; @@ -1037,7 +1070,7 @@ Task Caller(int slot) => Task.Run(() => private sealed class SimultaneousNextVoidStage(TaskCompletionSource gate) : IRequestStage { - public async Task HandleAsync(Log request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Log request, Continuation next, CancellationToken cancellationToken) { using var barrier = new Barrier(2); Task?[] calls = new Task?[2]; @@ -1062,7 +1095,7 @@ Task Caller(int slot) => Task.Run(() => // No constructor dependencies, so the container can build it per entry. private sealed class ParameterlessRetryStage : IRequestStage { - public async Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { try { @@ -1081,20 +1114,20 @@ private sealed class CountingConstructionStage : IRequestStage public CountingConstructionStage() => Interlocked.Increment(ref Constructions); - public Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } private sealed class NullTaskStage : IRequestStage { - public Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) => null!; } // Delegates straight to next, so the null task the handler returns is the one reported. private sealed class NilPassThroughStage : IRequestStage { - public Task HandleAsync(Nil request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Nil request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } @@ -1102,7 +1135,7 @@ private sealed class NullTaskOnFirstAttemptStage : IRequestStage { public int Attempts { get; private set; } - public Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { Attempts++; return Attempts == 1 ? null! : next.InvokeAsync(); @@ -1111,7 +1144,7 @@ public Task HandleAsync(Ping request, IContinuation next, Cancel private sealed class VoidFormStage(List log) : IRequestStage { - public async Task HandleAsync(Log request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Log request, Continuation next, CancellationToken cancellationToken) { log.Add("void:enter"); await next.InvokeAsync(); @@ -1121,13 +1154,13 @@ public async Task HandleAsync(Log request, IContinuation next, CancellationToken private sealed class ShortCircuitVoidStage : IRequestStage { - public Task HandleAsync(Log request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Log request, Continuation next, CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class NullTaskVoidStage : IRequestStage { - public Task HandleAsync(Log request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Log request, Continuation next, CancellationToken cancellationToken) => null!; } @@ -1141,7 +1174,7 @@ private sealed class TokenCapturingStage : IRequestStage { public CancellationToken CapturedToken { get; private set; } - public Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { CapturedToken = cancellationToken; return next.InvokeAsync(); @@ -1150,13 +1183,13 @@ public Task HandleAsync(Ping request, IContinuation next, Cancel private sealed class SubstitutingStage(CancellationToken substitute) : IRequestStage { - public Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(substitute); } private sealed class SubstitutingVoidStage(CancellationToken substitute) : IRequestStage { - public Task HandleAsync(Log request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Log request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(substitute); } @@ -1164,7 +1197,7 @@ private sealed class TokenRecordingStage : IRequestStage { public List CapturedTokens { get; } = []; - public Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { CapturedTokens.Add(cancellationToken); return next.InvokeAsync(); @@ -1173,7 +1206,7 @@ public Task HandleAsync(Ping request, IContinuation next, Cancel private sealed class TwoTokenStage(CancellationToken first, CancellationToken second) : IRequestStage { - public async Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { await next.InvokeAsync(first); @@ -1181,13 +1214,12 @@ public async Task HandleAsync(Ping request, IContinuation next, } } - // Cancels the call it started and awaits it out before giving up. Cancelling at once keeps - // the test fast. + // Cancelling at once keeps the test fast. private sealed class CancelAndAwaitStage : IRequestStage { public int Attempts { get; private set; } - public async Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) { Attempts++; if (Attempts > 1) diff --git a/tests/RequestFlow.Tests.Unit/Stages/ContinuationTests.cs b/tests/RequestFlow.Tests.Unit/Stages/ContinuationTests.cs new file mode 100644 index 0000000..002d493 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Stages/ContinuationTests.cs @@ -0,0 +1,274 @@ +#if NET8_0_OR_GREATER +using System.Runtime.CompilerServices; +#endif +using RequestFlow; + +namespace RequestFlow.Tests.Unit; + +public sealed class ContinuationTests +{ + [Theory] + [InlineData(typeof(Continuation))] + [InlineData(typeof(Continuation))] + public void Given_A_Continuation_Shape_When_Inspecting_Its_Type_Then_It_Is_A_Value_Type(Type shape) + { + shape.IsValueType.ShouldBeTrue(); + } + + // The struct is copied into every stage call, so it has to stay small. +#if NET8_0_OR_GREATER + [Fact] + public void Given_A_Continuation_When_Measuring_It_Then_It_Holds_Only_Its_Three_References_And_A_Token() + { + Unsafe.SizeOf>().ShouldBeLessThanOrEqualTo(32); + } +#endif + + [Fact] + public async Task Given_A_Continuation_Over_A_Level_When_Invoking_It_Then_The_Level_Receives_The_Request_And_Token() + { + var level = new RecordingLevel(); + using var source = new CancellationTokenSource(); + var request = new Ping(); + var continuation = new Continuation( + level.EnterAsync, request, EmptyProvider.Instance, source.Token); + + (await continuation.InvokeAsync()).ShouldBe("entered"); + level.Request.ShouldBeSameAs(request); + level.Token.ShouldBe(source.Token); + } + + [Fact] + public async Task Given_A_Continuation_When_Invoking_It_With_A_Token_Then_That_Token_Reaches_The_Level() + { + var level = new RecordingLevel(); + using var inherited = new CancellationTokenSource(); + using var supplied = new CancellationTokenSource(); + var continuation = new Continuation( + level.EnterAsync, new Ping(), EmptyProvider.Instance, inherited.Token); + + await continuation.InvokeAsync(supplied.Token); + + level.Token.ShouldBe(supplied.Token); + } + + [Fact] + public async Task Given_A_Stage_Under_Test_When_Handed_A_Continuation_Over_A_Delegate_Then_It_Runs_With_No_Container() + { + var stage = new AnnotatingStage(); + + string result = await stage.HandleAsync( + new Ping(), Continuation.Over(_ => Task.FromResult("from the chain")), CancellationToken.None); + + result.ShouldBe("[from the chain]"); + } + + [Fact] + public async Task Given_A_Continuation_Over_A_Delegate_When_A_Stage_Overrides_The_Token_Then_The_Delegate_Sees_Its_Token() + { + using var inherited = new CancellationTokenSource(); + using var replacement = new CancellationTokenSource(); + CancellationToken seen = default; + + await new TokenReplacingStage(replacement.Token).HandleAsync( + new Ping(), + Continuation.Over( + token => + { + seen = token; + return Task.FromResult("done"); + }, + inherited.Token), + inherited.Token); + + seen.ShouldBe(replacement.Token); + } + + // Without the second argument there would be nothing for None to fall back to. + [Fact] + public async Task Given_A_Continuation_Over_A_Delegate_When_A_Stage_Names_No_Token_Then_The_Delegate_Sees_The_Inherited_One() + { + using var inherited = new CancellationTokenSource(); + CancellationToken seen = default; + + await new AnnotatingStage().HandleAsync( + new Ping(), + Continuation.Over( + token => + { + seen = token; + return Task.FromResult("done"); + }, + inherited.Token), + inherited.Token); + + seen.ShouldBe(inherited.Token); + } + + [Fact] + public async Task Given_A_Void_Stage_Under_Test_When_Handed_A_Continuation_Over_A_Delegate_Then_It_Runs_With_No_Container() + { + int ran = 0; + using var supplied = new CancellationTokenSource(); + CancellationToken seen = default; + + await new VoidCountingStage().HandleAsync( + new Note(), + Continuation.Over( + token => + { + ran++; + seen = token; + return Task.CompletedTask; + }, + supplied.Token), + supplied.Token); + + ran.ShouldBe(1); + seen.ShouldBe(supplied.Token); + } + + // A repeated call runs the rest of the chain again, so a continuation built by Over has to + // allow that too. + [Fact] + public async Task Given_A_Continuation_Over_A_Delegate_When_A_Stage_Invokes_It_Twice_Then_The_Delegate_Runs_Twice() + { + int calls = 0; + Continuation next = Continuation.Over(_ => Task.FromResult($"{++calls}")); + + await new DoubleInvokingStage().HandleAsync(new Ping(), next, CancellationToken.None); + + calls.ShouldBe(2); + } + + // A real next can throw before there is a task to hand back, so the double reproduces that shape. + [Fact] + public void Given_A_Continuation_Over_A_Delegate_That_Throws_When_Invoking_It_Then_The_Exception_Escapes_Synchronously() + { + Continuation next = Continuation.Over(_ => throw new InvalidTimeZoneException("no task")); + + var thrown = Should.Throw(() => { _ = next.InvokeAsync(); }); + + thrown.Message.ShouldBe("no task"); + } + + [Fact] + public void Given_A_Void_Continuation_Over_A_Delegate_That_Throws_When_Invoking_It_Then_The_Exception_Escapes_Synchronously() + { + Continuation next = Continuation.Over(_ => throw new InvalidTimeZoneException("no task")); + + var thrown = Should.Throw(() => { _ = next.InvokeAsync(); }); + + thrown.Message.ShouldBe("no task"); + } + + [Fact] + public void Given_No_Delegate_When_Building_A_Continuation_Over_It_Then_It_Refuses() + { + Should.Throw(() => { _ = Continuation.Over(null!); }); + } + + [Fact] + public void Given_No_Delegate_When_Building_A_Void_Continuation_Over_It_Then_It_Refuses() + { + Should.Throw(() => { _ = Continuation.Over(null!); }); + } + + // A stage handed the default value holds no chain, so the failure has to say so rather than + // surface as a null dereference. + [Fact] + public void Given_A_Default_Continuation_When_Invoking_It_Then_It_Says_It_Was_Never_Built() + { + Continuation next = default; + + var thrown = Should.Throw(() => next.InvokeAsync()); + + thrown.Message.ShouldContain("default"); + thrown.Message.ShouldContain("Over"); + } + + [Fact] + public void Given_A_Default_Void_Continuation_When_Invoking_It_Then_It_Says_It_Was_Never_Built() + { + Continuation next = default; + + var thrown = Should.Throw(() => next.InvokeAsync()); + + thrown.Message.ShouldContain("default"); + thrown.Message.ShouldContain("Over"); + } + + #region Helpers + + public sealed record Ping : IRequest; + + // The assembly scan demands one handler per request type, even for requests only used here. + public sealed class PingHandler : IRequestHandler + { + public Task HandleAsync(Ping request, CancellationToken cancellationToken) + => Task.FromResult("handled"); + } + + public sealed record Note : IRequest; + + public sealed class NoteHandler : IRequestHandler + { + public Task HandleAsync(Note request, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + public sealed class AnnotatingStage : IRequestStage + { + public async Task HandleAsync( + Ping request, Continuation next, CancellationToken cancellationToken) + => $"[{await next.InvokeAsync()}]"; + } + + public sealed class TokenReplacingStage(CancellationToken replacement) : IRequestStage + { + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(replacement); + } + + public sealed class DoubleInvokingStage : IRequestStage + { + public async Task HandleAsync( + Ping request, Continuation next, CancellationToken cancellationToken) + { + await next.InvokeAsync(); + + return await next.InvokeAsync(); + } + } + + public sealed class VoidCountingStage : IRequestStage + { + public Task HandleAsync(Note request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(); + } + + // A level is a delegate, so this records through a method group rather than an interface. + private sealed class RecordingLevel + { + internal object? Request; + internal CancellationToken Token; + + public Task EnterAsync( + object request, IServiceProvider services, CancellationToken cancellationToken) + { + Request = request; + Token = cancellationToken; + + return Task.FromResult("entered"); + } + } + + private sealed class EmptyProvider : IServiceProvider + { + internal static readonly EmptyProvider Instance = new(); + + public object? GetService(Type serviceType) => null; + } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Stages/LevelResolutionTests.cs b/tests/RequestFlow.Tests.Unit/Stages/LevelResolutionTests.cs new file mode 100644 index 0000000..9d702a1 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Stages/LevelResolutionTests.cs @@ -0,0 +1,138 @@ +using Microsoft.Extensions.DependencyInjection; +using RequestFlow; + +namespace RequestFlow.Tests.Unit; + +/// +/// Every level asks the container for its stage or handler on every entry, whatever lifetime it was +/// registered with. Scoped is the case worth pinning, since the container hands back the same +/// instance either way and only the count of asks tells a resolution apart from a cache. +/// +public sealed class LevelResolutionTests +{ + [Fact] + public async Task Given_A_Scoped_Chain_When_Dispatching_Twice_Then_Every_Level_Is_Resolved_Again() + { + using IServiceScope scope = ScopeOver(typeof(FirstStage), typeof(SecondStage)); + RequestDispatcher dispatcher = CountingDispatcher(scope, out CountingProvider counting); + + await dispatcher.SendAsync(new Resolved()); + counting.Requested.Clear(); + await dispatcher.SendAsync(new Resolved()); + + counting.Requested.ShouldBe( + [typeof(FirstStage), typeof(SecondStage), typeof(IRequestHandler)]); + } + + [Fact] + public async Task Given_A_Scoped_Void_Chain_When_Dispatching_Twice_Then_Every_Level_Is_Resolved_Again() + { + using IServiceScope scope = ScopeOver(typeof(FirstVoidStage), typeof(SecondVoidStage)); + RequestDispatcher dispatcher = CountingDispatcher(scope, out CountingProvider counting); + + await dispatcher.SendAsync(new ResolvedVoid()); + counting.Requested.Clear(); + await dispatcher.SendAsync(new ResolvedVoid()); + + counting.Requested.ShouldBe( + [typeof(FirstVoidStage), typeof(SecondVoidStage), typeof(IRequestHandler)]); + } + + // A plan with no stages reaches its handler through a level of its own, so it is pinned apart + // from the chains above. + [Fact] + public async Task Given_A_Scoped_Handler_With_No_Stages_When_Dispatching_Twice_Then_It_Is_Resolved_Again() + { + using IServiceScope scope = ScopeOver(); + RequestDispatcher dispatcher = CountingDispatcher(scope, out CountingProvider counting); + + await dispatcher.SendAsync(new Resolved()); + counting.Requested.Clear(); + await dispatcher.SendAsync(new Resolved()); + + counting.Requested.ShouldBe([typeof(IRequestHandler)]); + } + + [Fact] + public async Task Given_A_Scoped_Void_Handler_With_No_Stages_When_Dispatching_Twice_Then_It_Is_Resolved_Again() + { + using IServiceScope scope = ScopeOver(); + RequestDispatcher dispatcher = CountingDispatcher(scope, out CountingProvider counting); + + await dispatcher.SendAsync(new ResolvedVoid()); + counting.Requested.Clear(); + await dispatcher.SendAsync(new ResolvedVoid()); + + counting.Requested.ShouldBe([typeof(IRequestHandler)]); + } + + #region Helpers + + // Handlers and stages all scoped, so a level that resolved once could serve both dispatches. + private static IServiceScope ScopeOver(params Type[] stageTypes) + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => + { + o.RegisterHandlersFromAssemblyContaining(); + o.WithScopedHandlers(); + foreach (Type stageType in stageTypes) + { + o.AddStage(stageType, s => s.AsScoped()); + } + }); + + return services.BuildServiceProvider().CreateScope(); + } + + private static RequestDispatcher CountingDispatcher(IServiceScope scope, out CountingProvider counting) + { + counting = new CountingProvider(scope.ServiceProvider); + + return new RequestDispatcher(scope.ServiceProvider.GetRequiredService(), counting); + } + + public sealed record Resolved : IRequest; + + public sealed class ResolvedHandler : IRequestHandler + { + public Task HandleAsync(Resolved request, CancellationToken cancellationToken) + => Task.FromResult("resolved"); + } + + public sealed record ResolvedVoid : IRequest; + + public sealed class ResolvedVoidHandler : IRequestHandler + { + public Task HandleAsync(ResolvedVoid request, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + public sealed class FirstStage : IRequestStage + { + public Task HandleAsync( + Resolved request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(); + } + + public sealed class SecondStage : IRequestStage + { + public Task HandleAsync( + Resolved request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(); + } + + public sealed class FirstVoidStage : IRequestStage + { + public Task HandleAsync(ResolvedVoid request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(); + } + + public sealed class SecondVoidStage : IRequestStage + { + public Task HandleAsync(ResolvedVoid request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(); + } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Stages/LevelTests.cs b/tests/RequestFlow.Tests.Unit/Stages/LevelTests.cs new file mode 100644 index 0000000..8c317dd --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Stages/LevelTests.cs @@ -0,0 +1,349 @@ +using Microsoft.Extensions.DependencyInjection; +using RequestFlow; + +namespace RequestFlow.Tests.Unit; + +/// +/// What one level of a chain does when it is entered, with no dispatcher in the way. +/// +public sealed class LevelTests +{ + [Fact] + public async Task Given_A_Typed_Stage_Level_When_Entering_It_Then_The_Stage_Receives_The_Request() + { + var below = new RecordingBelow("below"); + LevelEntry sut = TypedStage(typeof(RecordingStage), below.EnterAsync); + using var cts = new CancellationTokenSource(); + + string result = await sut(new Ping("hi"), Provider(new RecordingStage()), cts.Token); + + result.ShouldBe("hi:below"); + below.Token.ShouldBe(cts.Token); + } + + [Fact] + public async Task Given_A_Void_Form_Stage_Level_When_Entering_It_Then_Its_Plain_Task_Completes_With_No_Result() + { + List log = []; + LevelEntry sut = VoidStage(typeof(VoidShapeStage), VoidBelow); + + NoResult result = await sut(new Log(), Provider(new VoidShapeStage(log)), CancellationToken.None); + + result.ShouldBe(NoResult.Value); + log.ShouldBe(["void"]); + } + + [Fact] + public async Task Given_A_Typed_Stage_That_Returns_Null_When_Entering_It_Then_Throws_Naming_The_Stage() + { + LevelEntry sut = TypedStage(typeof(NullTaskStage), NullBelow); + + StageNullTaskException exception = await Should.ThrowAsync( + () => sut(new Ping("hi"), Provider(new NullTaskStage()), CancellationToken.None)); + + exception.StageType.ShouldBe(typeof(NullTaskStage)); + } + + [Fact] + public async Task Given_A_Void_Form_Stage_That_Returns_Null_When_Entering_It_Then_Throws_Naming_The_Stage() + { + LevelEntry sut = VoidStage(typeof(NullTaskVoidStage), VoidBelow); + + StageNullTaskException exception = await Should.ThrowAsync( + () => sut(new Log(), Provider(new NullTaskVoidStage()), CancellationToken.None)); + + exception.StageType.ShouldBe(typeof(NullTaskVoidStage)); + } + + [Fact] + public async Task Given_A_Typed_Handler_Level_When_Entering_It_Then_It_Produces_The_Response() + { + LevelEntry sut = LevelFactory.Handler(); + + string result = await sut(new Ping("hi"), Provider(new PingHandler()), CancellationToken.None); + + result.ShouldBe("hi"); + } + + [Fact] + public async Task Given_A_Void_Handler_Level_When_Entering_It_Then_It_Completes_With_No_Result() + { + LevelEntry sut = LevelFactory.VoidHandler(); + + NoResult result = await sut(new Log(), Provider(new LogHandler()), CancellationToken.None); + + result.ShouldBe(NoResult.Value); + } + + [Fact] + public async Task Given_A_Handler_That_Returns_Null_When_Entering_Its_Level_Then_Throws_Naming_The_Request() + { + LevelEntry sut = LevelFactory.Handler(); + + HandlerNullTaskException exception = await Should.ThrowAsync( + () => sut(new Broken(), Provider(new BrokenHandler()), CancellationToken.None)); + + exception.RequestType.ShouldBe(typeof(Broken)); + } + + [Fact] + public async Task Given_A_Request_Of_Another_Type_When_Entering_A_Stage_Level_Then_Throws_Invalid_Cast_Exception() + { + LevelEntry sut = TypedStage(typeof(RecordingStage), NullBelow); + + await Should.ThrowAsync( + () => sut(new Log(), Provider(new RecordingStage()), CancellationToken.None)); + } + + // A whole chain, not one level: which level a position gets depends on the position and the + // stage shape. + [Fact] + public async Task Given_A_Two_Stage_Chain_When_Entering_It_Then_The_First_Stage_Is_Outermost() + { + List log = []; + ServiceProvider provider = Provider(new OuterStage(log), new InnerStage(log), new PingHandler()); + LevelEntry root = ChainBuilder.Typed( + Chain([typeof(OuterStage), typeof(InnerStage)], [])); + + string result = await root(new Ping("hi"), provider, CancellationToken.None); + + result.ShouldBe("hi"); + log.ShouldBe(["outer", "inner"]); + } + + [Fact] + public async Task Given_A_Void_Chain_Of_Both_Stage_Shapes_When_Entering_It_Then_Both_Shapes_Run() + { + List log = []; + ServiceProvider provider = Provider( + new TypedShapeVoidStage(log), new VoidShapeStage(log), new LogHandler()); + LevelEntry root = ChainBuilder.Void( + Chain([typeof(TypedShapeVoidStage), typeof(VoidShapeStage)], [true, false])); + + await root(new Log(), provider, CancellationToken.None); + + log.ShouldBe(["typed", "void"]); + } + + [Fact] + public async Task Given_A_Stage_That_Short_Circuits_When_Entering_The_Chain_Then_The_Handler_Is_Never_Resolved() + { + ServiceProvider provider = Provider(new ShortCircuitStage()); + LevelEntry root = ChainBuilder.Typed(Chain([typeof(ShortCircuitStage)], [])); + + string result = await root(new Ping("hi"), provider, CancellationToken.None); + + result.ShouldBe("hi:stopped"); + } + + // A stage declared for a base request reaches a derived one through the in TRequest variance + // without implementing the closed interface, so a level has to cast to the contract. + [Fact] + public async Task Given_A_Stage_Declared_For_A_Base_Request_When_Entering_A_Derived_Chain_Then_It_Runs() + { + List log = []; + ServiceProvider provider = Provider(new BaseCommandStage(log), new ResetHandler()); + LevelEntry root = ChainBuilder.Typed(Chain([typeof(BaseCommandStage)], [])); + + string result = await root(new Reset(), provider, CancellationToken.None); + + result.ShouldBe("reset"); + log.ShouldBe(["base"]); + } + + [Fact] + public async Task Given_Two_Stage_Types_When_Building_Levels_Then_Each_Reaches_Its_Own_Stage() + { + LevelEntry forwarding = LevelFactory.Stage( + typeof(RecordingStage), new RecordingBelow("below").EnterAsync); + LevelEntry stopping = LevelFactory.Stage(typeof(ShortCircuitStage), NullBelow); + ServiceProvider provider = Provider(new RecordingStage(), new ShortCircuitStage()); + + string forwarded = await forwarding(new Ping("hi"), provider, CancellationToken.None); + string stopped = await stopping(new Ping("hi"), provider, CancellationToken.None); + + forwarded.ShouldBe("hi:below"); + stopped.ShouldBe("hi:stopped"); + } + + #region Helpers + + private static LevelEntry TypedStage(Type stageType, LevelEntry below) + => LevelFactory.Stage(stageType, below); + + private static LevelEntry VoidStage(Type stageType, LevelEntry below) + => LevelFactory.VoidStage(stageType, below); + + private static StageChain Chain(Type[] stageTypes, bool[] typedShapes) + => new(stageTypes, typedShapes); + + private static Task NullBelow( + object request, IServiceProvider services, CancellationToken cancellationToken) + => Task.FromResult("unreached"); + + private static Task VoidBelow( + object request, IServiceProvider services, CancellationToken cancellationToken) + => NoResult.Task; + + // Every level is registered under the type it will be asked for, handlers under their contract. + private static ServiceProvider Provider(params object[] levels) + { + var services = new ServiceCollection(); + foreach (object level in levels) + { + switch (level) + { + case PingHandler handler: + services.AddSingleton>(handler); + break; + case LogHandler handler: + services.AddSingleton>(handler); + break; + case ResetHandler handler: + services.AddSingleton>(handler); + break; + case BrokenHandler handler: + services.AddSingleton>(handler); + break; + default: + services.AddSingleton(level.GetType(), level); + break; + } + } + + return services.BuildServiceProvider(); + } + + private sealed class RecordingBelow(string value) + { + internal CancellationToken Token; + + public Task EnterAsync( + object request, IServiceProvider services, CancellationToken cancellationToken) + { + Token = cancellationToken; + + return Task.FromResult(value); + } + } + + public sealed record Ping(string Text) : IRequest; + + public sealed record Log : IRequest; + + public sealed record Broken : IRequest; + + public record Command : IRequest; + + public sealed record Reset : Command; + + // The assembly scan demands one handler per request type, even for requests nothing here + // dispatches. + private sealed class PingHandler : IRequestHandler + { + public Task HandleAsync(Ping request, CancellationToken cancellationToken) + => Task.FromResult(request.Text); + } + + private sealed class LogHandler : IRequestHandler + { + public Task HandleAsync(Log request, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + private sealed class BrokenHandler : IRequestHandler + { + public Task HandleAsync(Broken request, CancellationToken cancellationToken) + => null!; + } + + private sealed class CommandHandler : IRequestHandler + { + public Task HandleAsync(Command request, CancellationToken cancellationToken) + => Task.FromResult("command"); + } + + private sealed class ResetHandler : IRequestHandler + { + public Task HandleAsync(Reset request, CancellationToken cancellationToken) + => Task.FromResult("reset"); + } + + private sealed class RecordingStage : IRequestStage + { + public async Task HandleAsync( + Ping request, Continuation next, CancellationToken cancellationToken) + => request.Text + ":" + await next.InvokeAsync(cancellationToken); + } + + private sealed class ShortCircuitStage : IRequestStage + { + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) + => Task.FromResult(request.Text + ":stopped"); + } + + private sealed class NullTaskStage : IRequestStage + { + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) + => null!; + } + + private sealed class OuterStage(List log) : IRequestStage + { + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) + { + log.Add("outer"); + + return next.InvokeAsync(cancellationToken); + } + } + + private sealed class InnerStage(List log) : IRequestStage + { + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) + { + log.Add("inner"); + + return next.InvokeAsync(cancellationToken); + } + } + + private sealed class TypedShapeVoidStage(List log) : IRequestStage + { + public Task HandleAsync(Log request, Continuation next, CancellationToken cancellationToken) + { + log.Add("typed"); + + return next.InvokeAsync(cancellationToken); + } + } + + private sealed class VoidShapeStage(List log) : IRequestStage + { + public Task HandleAsync(Log request, Continuation next, CancellationToken cancellationToken) + { + log.Add("void"); + + return next.InvokeAsync(cancellationToken); + } + } + + private sealed class NullTaskVoidStage : IRequestStage + { + public Task HandleAsync(Log request, Continuation next, CancellationToken cancellationToken) + => null!; + } + + private sealed class BaseCommandStage(List log) : IRequestStage + { + public Task HandleAsync( + Command request, Continuation next, CancellationToken cancellationToken) + { + log.Add("base"); + + return next.InvokeAsync(cancellationToken); + } + } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Stages/StageClosingTests.cs b/tests/RequestFlow.Tests.Unit/Stages/StageClosingTests.cs index 877b046..65ed007 100644 --- a/tests/RequestFlow.Tests.Unit/Stages/StageClosingTests.cs +++ b/tests/RequestFlow.Tests.Unit/Stages/StageClosingTests.cs @@ -172,20 +172,20 @@ public Task HandleAsync(Log request, CancellationToken cancellationToken) private sealed class LoggingStage : IRequestStage where TRequest : IRequest { - public Task HandleAsync(TRequest request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(TRequest request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } private sealed class TaggedOnlyStage : IRequestStage where TRequest : IRequest, ITag { - public Task HandleAsync(TRequest request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(TRequest request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } private sealed class PingAuditStage : IRequestStage { - public Task HandleAsync(Ping request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Ping request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } diff --git a/tests/RequestFlow.Tests.Unit/Stages/StageLifetimeTests.cs b/tests/RequestFlow.Tests.Unit/Stages/StageLifetimeTests.cs index 12bb913..350078e 100644 --- a/tests/RequestFlow.Tests.Unit/Stages/StageLifetimeTests.cs +++ b/tests/RequestFlow.Tests.Unit/Stages/StageLifetimeTests.cs @@ -408,7 +408,6 @@ private static ServiceProvider BuildStageChain(params Type[] stageTypes) private static ServiceProvider BuildTraceChain(Type outerStageType) => BuildStageChain(outerStageType, typeof(CountingStage)); - // The counting level declared scoped, so the container hands the same instance back on re-entry. private static ServiceProvider BuildScopedTraceChain(Type outerStageType) { var services = new ServiceCollection(); @@ -423,7 +422,6 @@ private static ServiceProvider BuildScopedTraceChain(Type outerStageType) return services.BuildServiceProvider(); } - // The counting level under a stage that runs it twice at once, on the lifetime the test names. private static ServiceProvider BuildOverlapChain(Action? lifetime = null) { var services = new ServiceCollection(); @@ -491,7 +489,7 @@ public sealed class MarkerStage(ScopeMarker marker) : IRequ where TRequest : IRequest { public Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { StageMarkers.Add(marker); StageInstances.Add(this); @@ -505,7 +503,7 @@ public sealed class ProbeStage : IRequestStage { public Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { StageInstances.Add(this); @@ -537,13 +535,13 @@ public Task HandleAsync(VoidTrace request, CancellationToken cancellationToken) public sealed class SkipNextVoidStage : IRequestStage { - public Task HandleAsync(VoidTrace request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(VoidTrace request, Continuation next, CancellationToken cancellationToken) => Task.CompletedTask; } public sealed class DoubleNextVoidStage : IRequestStage { - public async Task HandleAsync(VoidTrace request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(VoidTrace request, Continuation next, CancellationToken cancellationToken) { await next.InvokeAsync(); await next.InvokeAsync(); @@ -552,14 +550,14 @@ public async Task HandleAsync(VoidTrace request, IContinuation next, Cancellatio public sealed class SkipNextStage : IRequestStage { - public Task HandleAsync(Trace request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Trace request, Continuation next, CancellationToken cancellationToken) => Task.FromResult("short-circuited"); } public sealed class DoubleNextStage : IRequestStage { public async Task HandleAsync( - Trace request, IContinuation next, CancellationToken cancellationToken) + Trace request, Continuation next, CancellationToken cancellationToken) { await next.InvokeAsync(); return await next.InvokeAsync(); @@ -571,24 +569,22 @@ public sealed class CountingStage : IRequestStage public CountingStage() => CountingStageConstructions++; - public Task HandleAsync(Trace request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Trace request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } public sealed record Overlap : IRequest; - // Hands every walk the same gate task, so no walk finishes before the stage above releases it. public sealed class OverlapHandler : IRequestHandler { public Task HandleAsync(Overlap request, CancellationToken cancellationToken) => OverlapGate.Task; } - // Starts a second walk while the first is suspended on the handler, then releases the gate. public sealed class OverlapNextStage : IRequestStage { public async Task HandleAsync( - Overlap request, IContinuation next, CancellationToken cancellationToken) + Overlap request, Continuation next, CancellationToken cancellationToken) { Task first = next.InvokeAsync(); Task second = next.InvokeAsync(); @@ -610,7 +606,7 @@ public ConcurrentCountingStage() => ConcurrentStageConstructions++; public async Task HandleAsync( - Overlap request, IContinuation next, CancellationToken cancellationToken) + Overlap request, Continuation next, CancellationToken cancellationToken) { int inside = Interlocked.Increment(ref ConcurrentStageEntries); if (inside > ConcurrentStagePeak) @@ -627,8 +623,7 @@ public async Task HandleAsync( } } - // Builds on the first entry and refuses on the second, the container failure a fan-out stage - // can hit on its second call while the first one is still running. + // The container failure a fan-out stage can hit on its second call while the first is still running. public sealed class SecondEntryFailsStage : IRequestStage { public SecondEntryFailsStage() @@ -638,17 +633,16 @@ public SecondEntryFailsStage() } public Task HandleAsync( - Overlap request, IContinuation next, CancellationToken cancellationToken) + Overlap request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } - // Starts a second call while the first is parked on the handler. The second call throws before - // it can hand back a task, so `second` stays unassigned and the first walk is settled here - // rather than left running with nobody awaiting it. + // The second call throws before it can hand back a task, so `second` stays unassigned and the + // first walk is settled here rather than left running with nobody awaiting it. public sealed class OverlapObservingStage : IRequestStage { public async Task HandleAsync( - Overlap request, IContinuation next, CancellationToken cancellationToken) + Overlap request, Continuation next, CancellationToken cancellationToken) { Task first = next.InvokeAsync(); Task? second = null; @@ -676,14 +670,14 @@ public sealed class UnbuildableStage : IRequestStage public UnbuildableStage(MissingDependency dependency) { } - public Task HandleAsync(Trace request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Trace request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } public sealed class CatchingStage : IRequestStage { public async Task HandleAsync( - Trace request, IContinuation next, CancellationToken cancellationToken) + Trace request, Continuation next, CancellationToken cancellationToken) { try { @@ -710,7 +704,7 @@ public Task HandleAsync(Unbuildable request, CancellationToken cancellat public sealed class CatchingUnbuildableStage : IRequestStage { public async Task HandleAsync( - Unbuildable request, IContinuation next, CancellationToken cancellationToken) + Unbuildable request, Continuation next, CancellationToken cancellationToken) { try { diff --git a/tests/RequestFlow.Tests.Unit/Stages/StagePipelineTests.cs b/tests/RequestFlow.Tests.Unit/Stages/StagePipelineTests.cs index 96a4308..063ba4b 100644 --- a/tests/RequestFlow.Tests.Unit/Stages/StagePipelineTests.cs +++ b/tests/RequestFlow.Tests.Unit/Stages/StagePipelineTests.cs @@ -316,7 +316,7 @@ public sealed class RecordingStage : IRequestStage { public async Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { Trace.Add("Recording:enter"); TResponse response = await next.InvokeAsync(); @@ -329,7 +329,7 @@ public sealed class SecondStage : IRequestStage { public async Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { Trace.Add("Second:enter"); TResponse response = await next.InvokeAsync(); @@ -342,7 +342,7 @@ public sealed class TaggedOnlyStage : IRequestStage, ITag { public async Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { Trace.Add("TaggedOnly:enter"); TResponse response = await next.InvokeAsync(); @@ -355,7 +355,7 @@ public sealed class UnreachableStage : IRequestStage, INothingImplementsThis { public Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { Trace.Add("Unreachable:enter"); return next.InvokeAsync(); @@ -367,7 +367,7 @@ public sealed class ResponseBoundStage : IRequestStage { public async Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { Trace.Add("ResponseBound:enter"); string response = await next.InvokeAsync(); @@ -379,7 +379,7 @@ public async Task HandleAsync( public sealed class VoidRecordingStage : IRequestStage where TRequest : IRequest { - public async Task HandleAsync(TRequest request, IContinuation next, CancellationToken cancellationToken) + public async Task HandleAsync(TRequest request, Continuation next, CancellationToken cancellationToken) { Trace.Add("VoidRecording:enter"); await next.InvokeAsync(); @@ -392,7 +392,7 @@ public async Task HandleAsync(TRequest request, IContinuation next, Cancellation public sealed class NotificationStage : IRequestStage { public async Task HandleAsync( - Notification request, IContinuation next, CancellationToken cancellationToken) + Notification request, Continuation next, CancellationToken cancellationToken) { Trace.Add("Notification:enter"); string response = await next.InvokeAsync(); @@ -404,7 +404,7 @@ public async Task HandleAsync( public sealed class PingOnlyStage : IRequestStage { public async Task HandleAsync( - Ping request, IContinuation next, CancellationToken cancellationToken) + Ping request, Continuation next, CancellationToken cancellationToken) { Trace.Add("PingOnly:enter"); string response = await next.InvokeAsync(); diff --git a/tests/RequestFlow.Tests.Unit/Stages/StageSemanticsTests.cs b/tests/RequestFlow.Tests.Unit/Stages/StageSemanticsTests.cs index 1d993d7..5191c87 100644 --- a/tests/RequestFlow.Tests.Unit/Stages/StageSemanticsTests.cs +++ b/tests/RequestFlow.Tests.Unit/Stages/StageSemanticsTests.cs @@ -234,7 +234,7 @@ public sealed class ThrowAfterNextStage : IRequestStage { public async Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { await next.InvokeAsync(); throw new TimeoutException("after next"); @@ -245,7 +245,7 @@ public sealed class ThrowingStage : IRequestStage { public Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) => throw new InvalidTimeZoneException("from stage"); } @@ -253,7 +253,7 @@ public sealed class PassThroughStage : IRequestStage { public Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } @@ -261,7 +261,7 @@ public sealed class SecondPassThroughStage : IRequestStage< where TRequest : IRequest { public Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) => next.InvokeAsync(); } @@ -269,7 +269,7 @@ public sealed class TokenForwardingStage : IRequestStage { public Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) Trace.Add("Token:cancelled"); @@ -278,12 +278,12 @@ public Task HandleAsync( } } - // Suspends on work of its own before delegating, the shape of a validation or caching stage. + // The shape of a validation or caching stage. public sealed class AwaitBeforeNextStage : IRequestStage where TRequest : IRequest { public async Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { await Task.Yield(); Trace.Add("Await:enter"); @@ -297,7 +297,7 @@ public sealed class TracingStage : IRequestStage { public async Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { Trace.Add("Tracing:enter"); TResponse response = await next.InvokeAsync(); @@ -310,7 +310,7 @@ public sealed class DoubleNextStage : IRequestStage { public async Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { await next.InvokeAsync(); return await next.InvokeAsync(); @@ -321,13 +321,13 @@ public async Task HandleAsync( // offer both of. Each records which one ran. public sealed class BothShapesStage : IRequestStage, IRequestStage { - public Task HandleAsync(Wipe request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Wipe request, Continuation next, CancellationToken cancellationToken) { Trace.Add("BothShapes:typed"); return next.InvokeAsync(); } - public Task HandleAsync(Wipe request, IContinuation next, CancellationToken cancellationToken) + public Task HandleAsync(Wipe request, Continuation next, CancellationToken cancellationToken) { Trace.Add("BothShapes:void"); return next.InvokeAsync(); @@ -338,7 +338,7 @@ public sealed class RetryOnceStage : IRequestStage { public async Task HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { try { @@ -357,7 +357,7 @@ public sealed class CountingStage : IRequestStage HandleAsync( - TRequest request, IContinuation next, CancellationToken cancellationToken) + TRequest request, Continuation next, CancellationToken cancellationToken) { Entries++; return next.InvokeAsync(); diff --git a/tests/tests.runsettings b/tests/tests.runsettings new file mode 100644 index 0000000..a2c18ee --- /dev/null +++ b/tests/tests.runsettings @@ -0,0 +1,9 @@ + + + + + + true + + +