Skip to content

Commit b16697f

Browse files
Merge pull request #89 from salihcantekin/dev
fix: Global Pipeline Feature Fixes and Improvements
2 parents 7119786 + 91bf6fe commit b16697f

26 files changed

Lines changed: 585 additions & 99 deletions

README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,38 @@ public class LoggingPipeline
105105
}
106106
```
107107

108+
### Global Pipelines
109+
110+
Global pipelines automatically apply to **all handlers** with matching request/response types - ideal for cross-cutting concerns like logging, validation, and exception handling.
111+
112+
```csharp
113+
public class GlobalLoggingPipeline
114+
{
115+
[GlobalPipeline(Order = 10, ExecutionStage = GlobalPipelineExecutionStage.BeforeHandler)]
116+
public async ValueTask<TResponse> Log<TRequest, TResponse>(
117+
PipelineContext<TRequest> ctx,
118+
PipelineDelegate<TRequest, TResponse> next)
119+
{
120+
Console.WriteLine($"Handling {typeof(TRequest).Name}");
121+
var response = await next(ctx);
122+
Console.WriteLine($"Completed {typeof(TRequest).Name}");
123+
return response;
124+
}
125+
}
126+
```
127+
128+
**Key Features:**
129+
- **Automatic application**: No need to attach to each handler individually
130+
- **Execution stages**: Control when pipelines run relative to handler-specific pipelines
131+
- `BeforeHandler` (default): Outermost layer, runs first
132+
- `BeforeHandlerInner`: After handler pipelines, before handler
133+
- `AfterHandlerInner`: After handler, before handler pipelines unwind
134+
- `AfterHandler`: Outermost post-handler layer
135+
- **Zero overhead**: When no global pipelines are registered, no performance impact
136+
- **Type-safe**: Validated at compile time by the source generator
137+
138+
For complete documentation including execution flow, best practices, and common patterns, see [GlobalPipelines.md](docs/GlobalPipelines.md).
139+
108140
### Notifications
109141
```csharp
110142
public sealed record UserLoggedIn(string UserName);
@@ -173,6 +205,7 @@ For complete details, migration guidance, and troubleshooting, see [MultiProject
173205
- Minimal boilerplate: annotate methods directly with `[Handle]`, `[Pipeline]`, `[Notification]`
174206
- Named handlers (multiple strategies for same request/response)
175207
- Orderable pipelines + early system module execution
208+
- **Global pipelines** for cross-cutting concerns (logging, validation, exception handling)
176209
- Extensible module model (e.g., cache) before user pipelines
177210
- High-performance async signatures (`ValueTask`)
178211
- Parallel or sequential notification dispatch
@@ -197,6 +230,7 @@ Primary docs in `docs/`:
197230
| Project Overview | [ProjectDoc.en.md](docs/ProjectDoc.en.md) |
198231
| Handlers | [Handlers](docs/Handlers.md) |
199232
| Pipelines | [Pipelines](docs/Pipelines.md) |
233+
| Global Pipelines | [GlobalPipelines](docs/GlobalPipelines.md) |
200234
| Notifications | [Notifications](docs/Notifications.md) |
201235
| Modules | [Modules](docs/Modules.md) |
202236
| Multi-Project Setup | [MultiProjectSetup](docs/MultiProjectSetup.md) |
@@ -241,6 +275,7 @@ Space is a high-performance, source-generator powered mediator/messaging framewo
241275
## Quick links
242276
- Handlers: docs/Handlers.md
243277
- Pipelines: docs/Pipelines.md
278+
- Global Pipelines: docs/GlobalPipelines.md
244279
- Notifications: docs/Notifications.md
245280
- Known Issues: docs/KnownIssues.md
246281
- Developer Recommendations: docs/DeveloperRecommendations.md
@@ -253,3 +288,62 @@ See `.github/copilot-instructions.md` for environment and common commands.
253288

254289

255290

291+
292+
293+
294+
295+
296+
297+
298+
299+
300+
301+
302+
303+
304+
305+
306+
307+
308+
309+
310+
311+
312+
313+
314+
315+
316+
317+
318+
319+
320+
321+
322+
323+
324+
325+
326+
327+
328+
329+
330+
331+
332+
333+
334+
335+
336+
337+
338+
339+
340+
341+
342+
343+
344+
345+
346+
347+
348+
349+

src/Space.Abstraction/Registry/HandlerEntry.cs

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ internal abstract class HandlerEntry<TRequest, TResponse> : IObjectHandlerEntry
2727
// Pipeline storage - separating handler pipelines from global pipelines
2828
private readonly List<(int Order, PipelineInvoker<TRequest, TResponse> Invoker)> handlerPipelines;
2929
private readonly List<(int Order, int ExecutionStage, PipelineInvoker<TRequest, TResponse> Invoker)> globalPipelines;
30-
private bool hasPipelines;
3130

3231
private readonly object composeLock = new();
3332
private PipelineInvoker<TRequest, TResponse>[] orderedPipelines;
@@ -41,9 +40,9 @@ internal abstract class HandlerEntry<TRequest, TResponse> : IObjectHandlerEntry
4140
private PipelineInvoker<TRequest, TResponse> singlePipelineInvoker;
4241
private PipelineDelegate<TRequest, TResponse> cachedFinalDelegate;
4342

44-
// Virtual properties so specialized entries can override
45-
internal virtual bool IsPipelineFree => !hasPipelines;
46-
internal virtual bool HasLightInvoker => lightInvoker != null && !hasPipelines;
43+
// Dynamic check for pipeline-free status (supports AddPipeline after construction)
44+
internal virtual bool IsPipelineFree => handlerPipelines.Count == 0 && globalPipelines.Count == 0;
45+
internal virtual bool HasLightInvoker => lightInvoker != null && IsPipelineFree;
4746

4847
protected HandlerEntry(
4948
HandlerInvoker<TRequest, TResponse> handlerInvoker,
@@ -75,10 +74,8 @@ protected HandlerEntry(
7574
}
7675
}
7776

78-
hasPipelines = handlerPipelines.Count > 0 || globalPipelines.Count > 0;
79-
8077
// Pre-compose at construction time for performance
81-
if (hasPipelines)
78+
if (!IsPipelineFree)
8279
{
8380
EnsureComposed();
8481
}
@@ -92,9 +89,9 @@ protected HandlerEntry(
9289
internal void AddPipeline(PipelineInvoker<TRequest, TResponse> invoker, PipelineConfig pipelineConfig)
9390
{
9491
handlerPipelines.Add((pipelineConfig.Order, invoker));
95-
hasPipelines = true;
96-
orderedPipelines = null;
92+
// Mark composition as dirty so it will be recomposed on next Invoke
9793
compositionDirty = true;
94+
orderedPipelines = null; // Clear cached ordered list
9895
}
9996

10097
private PipelineInvoker<TRequest, TResponse>[] GetOrdered()
@@ -168,6 +165,7 @@ protected void EnsureComposed()
168165

169166
if (totalPipelines == 0)
170167
{
168+
// Pipeline-free: composed invoke is just the handler
171169
composedInvoke = handlerInvoker;
172170
}
173171
else
@@ -254,6 +252,12 @@ internal virtual ValueTask<TResponse> InvokeLight(IServiceProvider sp, ISpace sp
254252
public virtual ValueTask<TResponse> Invoke(HandlerContext<TRequest> handlerContext)
255253
{
256254
handlerContext.CancellationToken.ThrowIfCancellationRequested();
255+
256+
// Ultra-fast path: no pipelines or global pipelines attached
257+
if (IsPipelineFree)
258+
{
259+
return handlerInvoker(handlerContext);
260+
}
257261

258262
if (compositionDirty)
259263
EnsureComposed();

src/Space.DependencyInjection/Space.cs

Lines changed: 37 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -85,33 +85,45 @@ private ValueTask<TResponse> SendCore<TRequest, TResponse>(in TRequest request,
8585
where TRequest : notnull
8686
where TResponse : notnull
8787
{
88-
// ULTRA-FAST PATH: Singleton/Transient + unnamed + pipeline-free
89-
// Uses direct delegate invocation - NO virtual calls
88+
// ULTRA-FAST PATH: Singleton/Transient + unnamed + pipeline-free (LightHandlerEntry)
9089
if (string.IsNullOrEmpty(name) && IsFastPath(spaceRegistry.HandlerLifetime))
9190
{
92-
// Hot path: Already initialized and is light handler
9391
if (DirectInvokerCache<TRequest, TResponse>.Initialized)
9492
{
9593
if (DirectInvokerCache<TRequest, TResponse>.IsLight)
9694
{
9795
// Single cancellation check - ThrowIfCancellationRequested handles both
9896
ct.ThrowIfCancellationRequested();
99-
var lctx = new LightHandlerContext<TRequest>(request, rootProvider, this, ct);
100-
return DirectInvokerCache<TRequest, TResponse>.LightInvoker(in lctx);
97+
return DirectInvokerCache<TRequest, TResponse>.LightInvoker(
98+
new LightHandlerContext<TRequest>(request, rootProvider, this, ct));
10199
}
102-
// Not light - fall through to standard path (has pipelines)
103-
}
104-
else
105-
{
106-
// Cold path: Initialize cache (NoInlining to keep hot path small)
107-
return InitializeAndInvoke<TRequest, TResponse>(in request, ct);
100+
// Has pipelines - use entry path
101+
return SendViaEntry<TRequest, TResponse>(in request, ct);
108102
}
103+
104+
// Cold path: Initialize and invoke
105+
return InitializeAndInvoke<TRequest, TResponse>(in request, ct);
109106
}
110107

111-
// Standard path (named handlers, pipeline handlers, scoped)
108+
// Named or scoped path
112109
return SendCoreStandard<TRequest, TResponse>(in request, name, ct);
113110
}
114111

112+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
113+
private ValueTask<TResponse> SendViaEntry<TRequest, TResponse>(in TRequest request, CancellationToken ct)
114+
where TRequest : notnull
115+
where TResponse : notnull
116+
{
117+
ct.ThrowIfCancellationRequested();
118+
var entry = EntryCache<TRequest, TResponse>.Entry;
119+
120+
if (entry.HasLightInvoker)
121+
return entry.InvokeLight(rootProvider, this, request, ct);
122+
123+
var ctx = HandlerContext<TRequest>.Create(rootProvider, request, ct);
124+
return entry.Invoke(ctx).AwaitAndReturnHandlerInvoke(ctx);
125+
}
126+
115127
[MethodImpl(MethodImplOptions.NoInlining)]
116128
private ValueTask<TResponse> InitializeAndInvoke<TRequest, TResponse>(in TRequest request, CancellationToken ct)
117129
where TRequest : notnull
@@ -130,19 +142,14 @@ private ValueTask<TResponse> InitializeAndInvoke<TRequest, TResponse>(in TReques
130142
DirectInvokerCache<TRequest, TResponse>.Initialized = true;
131143

132144
ct.ThrowIfCancellationRequested();
133-
var lctx = new LightHandlerContext<TRequest>(request, rootProvider, this, ct);
134-
return DirectInvokerCache<TRequest, TResponse>.LightInvoker(in lctx);
145+
return DirectInvokerCache<TRequest, TResponse>.LightInvoker(
146+
new LightHandlerContext<TRequest>(request, rootProvider, this, ct));
135147
}
136148

137149
DirectInvokerCache<TRequest, TResponse>.IsLight = false;
138150
DirectInvokerCache<TRequest, TResponse>.Initialized = true;
139151

140-
// Has pipelines - invoke through entry
141-
if (entry.HasLightInvoker)
142-
return entry.InvokeLight(rootProvider, this, request, ct);
143-
144-
var ctx = HandlerContext<TRequest>.Create(rootProvider, request, ct);
145-
return entry.Invoke(ctx).AwaitAndReturnHandlerInvoke(ctx);
152+
return SendViaEntry<TRequest, TResponse>(in request, ct);
146153
}
147154

148155
DirectInvokerCache<TRequest, TResponse>.IsLight = false;
@@ -285,10 +292,7 @@ public ValueTask<TResponse> Send<TResponse>(IRequest<TResponse> request, string
285292
if (vto.IsCompletedSuccessfully)
286293
return new ValueTask<TResponse>((TResponse)vto.Result!);
287294

288-
return AwaitFast1(vto);
289-
290-
static async ValueTask<TResponse> AwaitFast1(ValueTask<object> vt)
291-
=> (TResponse)await vt.ConfigureAwait(false);
295+
return AwaitCast<TResponse>(vto);
292296
}
293297

294298
// Fallback: object dispatch through registry (still no expression compile)
@@ -297,10 +301,7 @@ static async ValueTask<TResponse> AwaitFast1(ValueTask<object> vt)
297301
if (vtoFallback.IsCompletedSuccessfully)
298302
return new ValueTask<TResponse>((TResponse)vtoFallback.Result!);
299303

300-
return AwaitFast2(vtoFallback);
301-
302-
static async ValueTask<TResponse> AwaitFast2(ValueTask<object> vt)
303-
=> (TResponse)await vt.ConfigureAwait(false);
304+
return AwaitCast<TResponse>(vtoFallback);
304305
}
305306

306307
// Scoped path
@@ -312,14 +313,15 @@ static async ValueTask<TResponse> AwaitFast2(ValueTask<object> vt)
312313
}
313314

314315
var vts = spaceRegistry.DispatchHandler(request, name, typeof(TResponse), scope.ServiceProvider, ct);
315-
316316
if (vts.IsCompletedSuccessfully)
317317
{
318318
scope.Dispose();
319319
return new ValueTask<TResponse>((TResponse)vts.Result!);
320320
}
321-
322321
return AwaitDispose(vts.ContinueWithCast<TResponse>(), scope);
322+
323+
static async ValueTask<T> AwaitCast<T>(ValueTask<object> vt)
324+
=> (T)await vt.ConfigureAwait(false);
323325
}
324326

325327
#endregion
@@ -339,34 +341,28 @@ public ValueTask<TResponse> Send<TResponse>(object request, string name = null,
339341
var key = (type, name ?? string.Empty);
340342

341343
if (GenericDispatcherCache<TResponse>.Map.TryGetValue(key, out var f))
342-
{
343344
return f(this, request, ct);
344-
}
345345

346346
// Build and cache dispatcher to typed Send<TRuntime,TResponse> when possible,
347347
// otherwise fall back to registry object dispatch.
348348
var del = BuildTypedDispatcher<TResponse>(type, name);
349349
GenericDispatcherCache<TResponse>.Map[key] = del;
350-
351350
return del(this, request, ct);
352351
}
353352

354353
var scope = scopeFactory.CreateScope();
355-
356354
if (ct.IsCancellationRequested)
357355
{
358356
scope.Dispose();
359357
return ValueTask.FromCanceled<TResponse>(ct);
360358
}
361359

362360
var vt = spaceRegistry.DispatchHandler(request, name, typeof(TResponse), scope.ServiceProvider, ct);
363-
364361
if (vt.IsCompletedSuccessfully)
365362
{
366363
scope.Dispose();
367364
return new ValueTask<TResponse>((TResponse)vt.Result!);
368365
}
369-
370366
return AwaitDispose(vt.ContinueWithCast<TResponse>(), scope);
371367
}
372368

@@ -377,12 +373,10 @@ public ValueTask Send(object request, string name = null, CancellationToken ct =
377373
var vt = Send<Nothing>(request, name, ct);
378374
if (vt.IsCompletedSuccessfully)
379375
return default;
380-
return Await(vt);
376+
return AwaitNothing(vt);
381377

382-
static async ValueTask Await(ValueTask<Nothing> inner)
383-
{
384-
await inner.ConfigureAwait(false);
385-
}
378+
static async ValueTask AwaitNothing(ValueTask<Nothing> inner)
379+
=> await inner.ConfigureAwait(false);
386380
}
387381

388382
private static Func<Space, object, CancellationToken, ValueTask<TRes>> BuildTypedDispatcher<TRes>(Type requestType, string name)
@@ -435,8 +429,7 @@ public ValueTask Publish<TRequest>(TRequest request, CancellationToken ct = defa
435429
if (IsFastPath(spaceRegistry.HandlerLifetime))
436430
{
437431
var ctxFast = NotificationContext<TRequest>.Create(rootProvider, request, ct);
438-
var vt = spaceRegistry.FastDispatchNotification(ctxFast).AwaitAndReturnNotificationInvoke(ctxFast);
439-
return vt;
432+
return spaceRegistry.FastDispatchNotification(ctxFast).AwaitAndReturnNotificationInvoke(ctxFast);
440433
}
441434

442435
return SlowPublishScoped(request, ct);
@@ -465,8 +458,7 @@ public ValueTask Publish<TRequest>(TRequest request, NotificationDispatchType di
465458
if (IsFastPath(spaceRegistry.HandlerLifetime))
466459
{
467460
var ctxFast = NotificationContext<TRequest>.Create(rootProvider, request, ct);
468-
var vt = spaceRegistry.FastDispatchNotification(ctxFast, dispatchType).AwaitAndReturnNotificationInvoke(ctxFast);
469-
return vt;
461+
return spaceRegistry.FastDispatchNotification(ctxFast, dispatchType).AwaitAndReturnNotificationInvoke(ctxFast);
470462
}
471463

472464
return SlowPublishScoped(request, dispatchType, ct);

0 commit comments

Comments
 (0)