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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,20 @@ builder.Services.AddTransientTraxRoute<IProcessOrderTrain, ProcessOrderTrain>();

Or use `AddServiceTrainBus` (from [Trax.Mediator](https://www.nuget.org/packages/Trax.Mediator/)) to auto-register all trains in an assembly.

## Related Packages

| Package | Purpose |
|---------|---------|
| [Trax.Core](https://www.nuget.org/packages/Trax.Core/) | The locomotive — `Train`, steps, railway programming |
| [Trax.Mediator](https://www.nuget.org/packages/Trax.Mediator/) | Dispatch station — route cargo to the right train via `TrainBus` |
| [Trax.Scheduler](https://www.nuget.org/packages/Trax.Scheduler/) | Timetables — recurring trains with retries and dead-lettering |
| [Trax.Dashboard](https://www.nuget.org/packages/Trax.Dashboard/) | Control room — monitor every journey on the network |
## Part of Trax

Trax is a layered framework — each package builds on the one below it. Stop at whatever layer solves your problem.

```
Trax.Core pipelines, steps, railway error propagation
└→ Trax.Effect ← you are here
└→ Trax.Mediator + decoupled dispatch via TrainBus
└→ Trax.Scheduler + cron schedules, retries, dead-letter queues
└→ Trax.Api + GraphQL API for remote access
└→ Trax.Dashboard + Blazor monitoring UI
```

**Next layer:** When you need decoupled dispatch (callers don't know which train handles a request), add [Trax.Mediator](https://www.nuget.org/packages/Trax.Mediator/).

Full documentation: [traxsharp.github.io/Trax.Docs](https://traxsharp.github.io/Trax.Docs)

Expand Down
13 changes: 13 additions & 0 deletions src/Trax.Effect/Attributes/TraxBroadcastAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Trax.Effect.Attributes;

/// <summary>
/// Opts a train into broadcasting lifecycle events to GraphQL subscribers.
/// Only trains decorated with this attribute will have their lifecycle transitions
/// (started, completed, failed, cancelled) published to WebSocket subscribers.
/// </summary>
/// <remarks>
/// Place this attribute on the concrete train class. Trains without this attribute
/// will not emit subscription events, even if lifecycle hooks are registered.
/// </remarks>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public class TraxBroadcastAttribute : Attribute { }
62 changes: 62 additions & 0 deletions src/Trax.Effect/Attributes/TraxMutationAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
namespace Trax.Effect.Attributes;

/// <summary>
/// Exposes a train as typed GraphQL mutation field(s) under <c>dispatch</c>.
/// Generates <c>run{Name}</c> and/or <c>queue{Name}</c> mutations based on
/// the <see cref="Operations"/> property.
/// </summary>
/// <remarks>
/// Place this attribute on the concrete train class. The generated field names are derived
/// by stripping the <c>I</c> prefix and <c>Train</c> suffix from the service type name,
/// then prepending <c>run</c> or <c>queue</c>
/// (e.g. <c>IBanPlayerTrain</c> → <c>runBanPlayer</c> / <c>queueBanPlayer</c>).
///
/// Trains without this attribute (or <see cref="TraxQueryAttribute"/>) are not exposed
/// as GraphQL endpoints. A train cannot have both attributes.
/// </remarks>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public class TraxMutationAttribute : Attribute
{
/// <summary>
/// Overrides the auto-derived GraphQL field name.
/// When null, the name is derived by stripping the "I" prefix and "Train" suffix
/// from the service type name (e.g. <c>IBanPlayerTrain</c> → <c>BanPlayer</c>).
/// This produces <c>runBanPlayer</c> and/or <c>queueBanPlayer</c>.
/// </summary>
public string? Name { get; init; }

/// <summary>
/// A human-readable description that appears in the GraphQL schema documentation.
/// </summary>
public string? Description { get; init; }

/// <summary>
/// Marks the generated mutations as deprecated in the GraphQL schema.
/// Clients see a deprecation warning during introspection.
/// </summary>
public string? DeprecationReason { get; init; }

/// <summary>
/// Controls which mutation operations are generated.
/// Defaults to <see cref="GraphQLOperation.Run"/> (synchronous execution only).
/// Set to <see cref="GraphQLOperation.Queue"/> for scheduler dispatch only,
/// or <see cref="GraphQLOperation.RunAndQueue"/> for both.
/// </summary>
public GraphQLOperation Operations { get; init; } = GraphQLOperation.Run;
}

/// <summary>
/// Controls which typed mutation operations are generated for a train.
/// </summary>
[Flags]
public enum GraphQLOperation
{
/// <summary>Generate only the <c>run{Name}</c> mutation (synchronous execution).</summary>
Run = 1,

/// <summary>Generate only the <c>queue{Name}</c> mutation (scheduler dispatch).</summary>
Queue = 2,

/// <summary>Generate both <c>run{Name}</c> and <c>queue{Name}</c> mutations.</summary>
RunAndQueue = Run | Queue,
}
35 changes: 35 additions & 0 deletions src/Trax.Effect/Attributes/TraxQueryAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace Trax.Effect.Attributes;

/// <summary>
/// Exposes a train as a typed GraphQL query field under <c>discover</c>.
/// Query trains always execute synchronously via <c>RunAsync</c> on the current server.
/// </summary>
/// <remarks>
/// Place this attribute on the concrete train class. The generated field name is derived
/// by stripping the <c>I</c> prefix and <c>Train</c> suffix from the service type name
/// (e.g. <c>ILookupPlayerTrain</c> → <c>lookupPlayer</c>), or overridden via <see cref="Name"/>.
///
/// Trains without this attribute (or <see cref="TraxMutationAttribute"/>) are not exposed
/// as GraphQL endpoints. A train cannot have both attributes.
/// </remarks>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public class TraxQueryAttribute : Attribute
{
/// <summary>
/// Overrides the auto-derived GraphQL field name.
/// When null, the name is derived by stripping the "I" prefix and "Train" suffix
/// from the service type name (e.g. <c>ILookupPlayerTrain</c> → <c>lookupPlayer</c>).
/// </summary>
public string? Name { get; init; }

/// <summary>
/// A human-readable description that appears in the GraphQL schema documentation.
/// </summary>
public string? Description { get; init; }

/// <summary>
/// Marks the generated field as deprecated in the GraphQL schema.
/// Clients see a deprecation warning during introspection.
/// </summary>
public string? DeprecationReason { get; init; }
}
65 changes: 64 additions & 1 deletion src/Trax.Effect/Extensions/ServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
using Trax.Effect.Services.EffectProviderFactory;
using Trax.Effect.Services.EffectRegistry;
using Trax.Effect.Services.EffectRunner;
using Trax.Effect.Services.LifecycleHookRunner;
using Trax.Effect.Services.StepEffectProviderFactory;
using Trax.Effect.Services.StepEffectRunner;
using Trax.Effect.Services.TrainLifecycleHookFactory;

namespace Trax.Effect.Extensions;

Expand All @@ -29,7 +31,8 @@ public static IServiceCollection AddTraxEffects(
.AddSingleton<IEffectRegistry>(registry)
.AddSingleton<ITraxEffectConfiguration>(configuration)
.AddTransient<IEffectRunner, EffectRunner>()
.AddTransient<IStepEffectRunner, StepEffectRunner>();
.AddTransient<IStepEffectRunner, StepEffectRunner>()
.AddTransient<ILifecycleHookRunner, LifecycleHookRunner>();
}

private static TraxEffectConfiguration BuildConfiguration(
Expand Down Expand Up @@ -236,6 +239,66 @@ public static TraxEffectConfigurationBuilder AddStepEffect<TStepEffectProviderFa

#endregion

#region LifecycleHook

public static TraxEffectConfigurationBuilder AddLifecycleHook<
TILifecycleHookFactory,
TLifecycleHookFactory
>(
this TraxEffectConfigurationBuilder builder,
TLifecycleHookFactory factory,
bool toggleable = true
)
where TILifecycleHookFactory : class, ITrainLifecycleHookFactory
where TLifecycleHookFactory : class, TILifecycleHookFactory
{
builder
.ServiceCollection.AddSingleton<TLifecycleHookFactory>(factory)
.AddSingleton<ITrainLifecycleHookFactory>(sp =>
sp.GetRequiredService<TLifecycleHookFactory>()
)
.AddSingleton<TILifecycleHookFactory>(sp =>
sp.GetRequiredService<TLifecycleHookFactory>()
);

builder.EffectRegistry?.Register(typeof(TLifecycleHookFactory), toggleable: toggleable);

return builder;
}

public static TraxEffectConfigurationBuilder AddLifecycleHook<TLifecycleHookFactory>(
this TraxEffectConfigurationBuilder builder,
bool toggleable = true
)
where TLifecycleHookFactory : class, ITrainLifecycleHookFactory
{
builder
.ServiceCollection.AddSingleton<TLifecycleHookFactory>()
.AddSingleton<ITrainLifecycleHookFactory>(sp =>
sp.GetRequiredService<TLifecycleHookFactory>()
);

builder.EffectRegistry?.Register(typeof(TLifecycleHookFactory), toggleable: toggleable);

return builder;
}

public static TraxEffectConfigurationBuilder AddLifecycleHook<TLifecycleHookFactory>(
this TraxEffectConfigurationBuilder builder,
TLifecycleHookFactory factory,
bool toggleable = true
)
where TLifecycleHookFactory : class, ITrainLifecycleHookFactory
{
builder.ServiceCollection.AddSingleton<ITrainLifecycleHookFactory>(factory);

builder.EffectRegistry?.Register(typeof(TLifecycleHookFactory), toggleable: toggleable);

return builder;
}

#endregion

#region StepInjection

public static IServiceCollection AddScopedTraxStep<TService, TImplementation>(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Trax.Effect.Models.Metadata;

namespace Trax.Effect.Services.LifecycleHookRunner;

/// <summary>
/// Coordinates multiple <see cref="TrainLifecycleHook.ITrainLifecycleHook"/> implementations,
/// broadcasting train lifecycle events to all registered hooks.
/// </summary>
public interface ILifecycleHookRunner : IDisposable
{
Task OnStarted(Metadata metadata, CancellationToken ct);
Task OnCompleted(Metadata metadata, CancellationToken ct);
Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct);
Task OnCancelled(Metadata metadata, CancellationToken ct);
}
135 changes: 135 additions & 0 deletions src/Trax.Effect/Services/LifecycleHookRunner/LifecycleHookRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using Microsoft.Extensions.Logging;
using Trax.Effect.Models.Metadata;
using Trax.Effect.Services.EffectRegistry;
using Trax.Effect.Services.TrainLifecycleHook;
using Trax.Effect.Services.TrainLifecycleHookFactory;

namespace Trax.Effect.Services.LifecycleHookRunner;

/// <summary>
/// Composite that broadcasts train lifecycle events to all registered hooks.
/// Exceptions in individual hooks are caught and logged — a failing hook never causes
/// the train itself to fail.
/// </summary>
public class LifecycleHookRunner : ILifecycleHookRunner
{
private readonly List<ITrainLifecycleHook> _hooks;
private readonly ILogger<LifecycleHookRunner>? _logger;

public LifecycleHookRunner(
IEnumerable<ITrainLifecycleHookFactory> hookFactories,
IEffectRegistry effectRegistry,
ILogger<LifecycleHookRunner>? logger = null
)
{
_logger = logger;
_hooks = hookFactories
.Where(factory => effectRegistry.IsEnabled(factory.GetType()))
.Select(factory => factory.Create())
.ToList();
}

public async Task OnStarted(Metadata metadata, CancellationToken ct)
{
foreach (var hook in _hooks)
{
try
{
await hook.OnStarted(metadata, ct);
}
catch (Exception ex)
{
_logger?.LogError(
ex,
"Lifecycle hook ({HookType}) threw on OnStarted for train ({TrainName}).",
hook.GetType().Name,
metadata.Name
);
}
}
}

public async Task OnCompleted(Metadata metadata, CancellationToken ct)
{
foreach (var hook in _hooks)
{
try
{
await hook.OnCompleted(metadata, ct);
}
catch (Exception ex)
{
_logger?.LogError(
ex,
"Lifecycle hook ({HookType}) threw on OnCompleted for train ({TrainName}).",
hook.GetType().Name,
metadata.Name
);
}
}
}

public async Task OnFailed(Metadata metadata, Exception exception, CancellationToken ct)
{
foreach (var hook in _hooks)
{
try
{
await hook.OnFailed(metadata, exception, ct);
}
catch (Exception ex)
{
_logger?.LogError(
ex,
"Lifecycle hook ({HookType}) threw on OnFailed for train ({TrainName}).",
hook.GetType().Name,
metadata.Name
);
}
}
}

public async Task OnCancelled(Metadata metadata, CancellationToken ct)
{
foreach (var hook in _hooks)
{
try
{
await hook.OnCancelled(metadata, ct);
}
catch (Exception ex)
{
_logger?.LogError(
ex,
"Lifecycle hook ({HookType}) threw on OnCancelled for train ({TrainName}).",
hook.GetType().Name,
metadata.Name
);
}
}
}

public void Dispose()
{
foreach (var hook in _hooks)
{
if (hook is IDisposable disposable)
{
try
{
disposable.Dispose();
}
catch (Exception ex)
{
_logger?.LogError(
ex,
"Failed to dispose lifecycle hook ({HookType}).",
hook.GetType().Name
);
}
}
}

_hooks.Clear();
}
}
Loading
Loading