diff --git a/Directory.Packages.props b/Directory.Packages.props
index ab2abddb2..fa192536d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -37,6 +37,8 @@
+
+
@@ -106,6 +108,7 @@
+
@@ -117,6 +120,7 @@
+
@@ -132,6 +136,7 @@
+
diff --git a/src/Http/Wolverine.Http.Marten/Wolverine.Http.Marten.csproj b/src/Http/Wolverine.Http.Marten/Wolverine.Http.Marten.csproj
index 0e492335b..4051eb8ba 100644
--- a/src/Http/Wolverine.Http.Marten/Wolverine.Http.Marten.csproj
+++ b/src/Http/Wolverine.Http.Marten/Wolverine.Http.Marten.csproj
@@ -13,7 +13,7 @@
-
+
diff --git a/src/Http/Wolverine.Http/HttpCapabilityDescriptor.cs b/src/Http/Wolverine.Http/HttpCapabilityDescriptor.cs
new file mode 100644
index 000000000..9992977c6
--- /dev/null
+++ b/src/Http/Wolverine.Http/HttpCapabilityDescriptor.cs
@@ -0,0 +1,59 @@
+using JasperFx.Core;
+using JasperFx.Core.Reflection;
+using JasperFx.Descriptors;
+using Wolverine.Configuration.Capabilities;
+using Wolverine.Http.CodeGen;
+
+namespace Wolverine.Http;
+
+///
+/// Describes Wolverine.HTTP capabilities including options and all HTTP routes
+/// for display in CritterWatch.
+///
+public class HttpCapabilityDescriptor : ICapabilityDescriptor
+{
+ private readonly WolverineHttpOptions _options;
+
+ public HttpCapabilityDescriptor(WolverineHttpOptions options)
+ {
+ _options = options;
+ }
+
+ public OptionsDescription Describe()
+ {
+ var description = new OptionsDescription
+ {
+ Subject = "Wolverine.Http"
+ };
+
+ description.AddTag("http");
+
+ description.AddValue(nameof(_options.WarmUpRoutes), _options.WarmUpRoutes);
+ description.AddValue(nameof(_options.ServiceProviderSource), _options.ServiceProviderSource);
+
+ var endpoints = _options.Endpoints;
+ if (endpoints != null)
+ {
+ var routeSet = description.AddChildSet("Routes");
+ routeSet.SummaryColumns = ["HttpMethods", "Route", "Endpoint"];
+
+ foreach (var chain in endpoints.Chains.OrderBy(c => c.RoutePattern?.RawText ?? string.Empty))
+ {
+ var routeDescription = new OptionsDescription
+ {
+ Subject = chain.RoutePattern?.RawText ?? string.Empty,
+ Title = chain.RoutePattern?.RawText ?? string.Empty
+ };
+
+ routeDescription.AddValue("HttpMethods", chain.HttpMethods.Join(", "));
+ routeDescription.AddValue("Route", chain.RoutePattern?.RawText ?? string.Empty);
+ routeDescription.AddValue("Endpoint",
+ $"{chain.Method.HandlerType.FullNameInCode()}.{chain.Method.Method.Name}");
+
+ routeSet.Rows.Add(routeDescription);
+ }
+ }
+
+ return description;
+ }
+}
diff --git a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs
index c67f65ac4..d11bf5681 100644
--- a/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs
+++ b/src/Http/Wolverine.Http/WolverineHttpEndpointRouteBuilderExtensions.cs
@@ -11,6 +11,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Wolverine.Configuration;
+using Wolverine.Configuration.Capabilities;
using Wolverine.Http.CodeGen;
using Wolverine.Http.Transport;
using Wolverine.Http.Validation;
@@ -161,10 +162,12 @@ public static IServiceCollection AddWolverineHttp(this IServiceCollection servic
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
-
+
services.AddSingleton(typeof(IProblemDetailSource<>), typeof(ProblemDetailSource<>));
services.AddSingleton();
+ services.AddSingleton();
+
services.ConfigureWolverine(opts =>
{
opts.CodeGeneration.Sources.Add(new NullableHttpContextSource());
diff --git a/src/Persistence/MySql/Wolverine.MySql/MySqlMessageStore.cs b/src/Persistence/MySql/Wolverine.MySql/MySqlMessageStore.cs
index 4ea81cc8e..1761d4f6c 100644
--- a/src/Persistence/MySql/Wolverine.MySql/MySqlMessageStore.cs
+++ b/src/Persistence/MySql/Wolverine.MySql/MySqlMessageStore.cs
@@ -499,6 +499,7 @@ public override IEnumerable AllObjects()
var tenantTable = new Table(new DbObjectName(SchemaName, DatabaseConstants.TenantsTableName));
tenantTable.AddColumn(StorageConstants.TenantIdColumn).AsPrimaryKey();
tenantTable.AddColumn(StorageConstants.ConnectionStringColumn).NotNull();
+ tenantTable.AddColumn(DatabaseConstants.DisabledColumn).DefaultValueByExpression("false").NotNull();
yield return tenantTable;
}
diff --git a/src/Persistence/MySql/Wolverine.MySql/Wolverine.MySql.csproj b/src/Persistence/MySql/Wolverine.MySql/Wolverine.MySql.csproj
index 737a3d449..85a8aa60f 100644
--- a/src/Persistence/MySql/Wolverine.MySql/Wolverine.MySql.csproj
+++ b/src/Persistence/MySql/Wolverine.MySql/Wolverine.MySql.csproj
@@ -16,7 +16,7 @@
-
+
diff --git a/src/Persistence/Oracle/Wolverine.Oracle/OracleMessageStore.cs b/src/Persistence/Oracle/Wolverine.Oracle/OracleMessageStore.cs
index dd41c65e3..8dca219ab 100644
--- a/src/Persistence/Oracle/Wolverine.Oracle/OracleMessageStore.cs
+++ b/src/Persistence/Oracle/Wolverine.Oracle/OracleMessageStore.cs
@@ -248,6 +248,7 @@ public IEnumerable AllObjects()
var tenantTable = new Table(new OracleObjectName(SchemaName, DatabaseConstants.TenantsTableName.ToUpperInvariant()));
tenantTable.AddColumn("tenant_id", "VARCHAR2(100)").AsPrimaryKey();
tenantTable.AddColumn("connection_string", "VARCHAR2(500)").NotNull();
+ tenantTable.AddColumn("disabled", "NUMBER(1)").DefaultValueByExpression("0").NotNull();
yield return tenantTable;
}
@@ -473,7 +474,7 @@ public async Task>> LoadAllTenantConnectionStri
try
{
var cmd = conn.CreateCommand(
- $"SELECT tenant_id, connection_string FROM {SchemaName}.{DatabaseConstants.TenantsTableName}");
+ $"SELECT tenant_id, connection_string FROM {SchemaName}.{DatabaseConstants.TenantsTableName} WHERE disabled = 0");
await using var reader = await cmd.ExecuteReaderAsync(_cancellation);
while (await reader.ReadAsync(_cancellation))
@@ -520,4 +521,84 @@ public async Task SeedDatabasesAsync(ITenantedSource tenantConnectionStr
await conn.CloseAsync();
}
}
+
+ public async Task AddTenantRecordAsync(string tenantId, string connectionString)
+ {
+ await using var conn = CreateConnection();
+ await conn.OpenAsync(_cancellation);
+ try
+ {
+ // Oracle MERGE for upsert
+ var cmd = conn.CreateCommand(
+ $"MERGE INTO {SchemaName}.{DatabaseConstants.TenantsTableName} t USING (SELECT :id AS tenant_id FROM DUAL) s ON (t.tenant_id = s.tenant_id) WHEN MATCHED THEN UPDATE SET connection_string = :conn, disabled = 0 WHEN NOT MATCHED THEN INSERT (tenant_id, connection_string, disabled) VALUES (:id, :conn, 0)");
+ cmd.With("id", tenantId);
+ cmd.With("conn", connectionString);
+ await cmd.ExecuteNonQueryAsync(_cancellation);
+ }
+ finally
+ {
+ await conn.CloseAsync();
+ }
+ }
+
+ public async Task SetTenantDisabledAsync(string tenantId, bool disabled)
+ {
+ await using var conn = CreateConnection();
+ await conn.OpenAsync(_cancellation);
+ try
+ {
+ var cmd = conn.CreateCommand(
+ $"UPDATE {SchemaName}.{DatabaseConstants.TenantsTableName} SET disabled = :disabled WHERE tenant_id = :id");
+ cmd.With("id", tenantId);
+ cmd.With("disabled", disabled ? 1 : 0);
+ await cmd.ExecuteNonQueryAsync(_cancellation);
+ }
+ finally
+ {
+ await conn.CloseAsync();
+ }
+ }
+
+ public async Task DeleteTenantRecordAsync(string tenantId)
+ {
+ await using var conn = CreateConnection();
+ await conn.OpenAsync(_cancellation);
+ try
+ {
+ var cmd = conn.CreateCommand(
+ $"DELETE FROM {SchemaName}.{DatabaseConstants.TenantsTableName} WHERE tenant_id = :id");
+ cmd.With("id", tenantId);
+ await cmd.ExecuteNonQueryAsync(_cancellation);
+ }
+ finally
+ {
+ await conn.CloseAsync();
+ }
+ }
+
+ public async Task> LoadDisabledTenantIdsAsync()
+ {
+ var list = new List();
+ await using var conn = CreateConnection();
+ await conn.OpenAsync(_cancellation);
+ try
+ {
+ await using var reader = await conn.CreateCommand(
+ $"SELECT tenant_id FROM {SchemaName}.{DatabaseConstants.TenantsTableName} WHERE disabled = 1")
+ .ExecuteReaderAsync(_cancellation);
+
+ while (await reader.ReadAsync(_cancellation))
+ {
+ list.Add(await reader.GetFieldValueAsync(0));
+ }
+
+ await reader.CloseAsync();
+ }
+ finally
+ {
+ await conn.CloseAsync();
+ }
+
+ return list;
+ }
}
diff --git a/src/Persistence/Oracle/Wolverine.Oracle/Wolverine.Oracle.csproj b/src/Persistence/Oracle/Wolverine.Oracle/Wolverine.Oracle.csproj
index 171ceec48..699c3386a 100644
--- a/src/Persistence/Oracle/Wolverine.Oracle/Wolverine.Oracle.csproj
+++ b/src/Persistence/Oracle/Wolverine.Oracle/Wolverine.Oracle.csproj
@@ -16,7 +16,7 @@
-
+
diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Wolverine.EntityFrameworkCore.csproj b/src/Persistence/Wolverine.EntityFrameworkCore/Wolverine.EntityFrameworkCore.csproj
index 043a41392..e4e78cfbe 100644
--- a/src/Persistence/Wolverine.EntityFrameworkCore/Wolverine.EntityFrameworkCore.csproj
+++ b/src/Persistence/Wolverine.EntityFrameworkCore/Wolverine.EntityFrameworkCore.csproj
@@ -15,7 +15,7 @@
-
+
diff --git a/src/Persistence/Wolverine.Marten/Distribution/EventSubscriptionAgent.cs b/src/Persistence/Wolverine.Marten/Distribution/EventSubscriptionAgent.cs
index ece2c7773..31002f92a 100644
--- a/src/Persistence/Wolverine.Marten/Distribution/EventSubscriptionAgent.cs
+++ b/src/Persistence/Wolverine.Marten/Distribution/EventSubscriptionAgent.cs
@@ -1,6 +1,8 @@
using JasperFx;
using JasperFx.Events.Daemon;
using JasperFx.Events.Projections;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
using Wolverine.Runtime.Agents;
using ISubscriptionAgent = JasperFx.Events.Daemon.ISubscriptionAgent;
@@ -10,15 +12,43 @@ public class EventSubscriptionAgent : IEventSubscriptionAgent
{
private readonly ShardName _shardName;
private readonly IProjectionDaemon _daemon;
+ private readonly ILogger _logger;
private ISubscriptionAgent? _innerAgent;
- public EventSubscriptionAgent(Uri uri, ShardName shardName, IProjectionDaemon daemon)
+ // Health check stall tracking
+ private long _lastKnownSequence;
+ private DateTimeOffset _lastAdvancedAt = DateTimeOffset.UtcNow;
+ private int _consecutiveStallCount;
+
+ // Configurable thresholds (loaded from progression table, with defaults)
+ private long? _warningThreshold;
+ private long? _criticalThreshold;
+ private bool _thresholdsLoaded;
+
+ private const long DefaultWarningThreshold = 1000;
+ private const long DefaultCriticalThreshold = 5000;
+ private static readonly TimeSpan StallTimeout = TimeSpan.FromSeconds(60);
+ private const int MaxConsecutiveStallsBeforeRestart = 3;
+
+ ///
+ /// Callback fired when the agent is auto-restarted due to stalls.
+ /// Parameters: agentUri, reason, timestamp
+ ///
+ public Action? OnRestarted { get; set; }
+
+ public EventSubscriptionAgent(Uri uri, ShardName shardName, IProjectionDaemon daemon, ILogger logger)
{
_shardName = shardName;
_daemon = daemon;
+ _logger = logger;
Uri = uri;
}
+ public EventSubscriptionAgent(Uri uri, ShardName shardName, IProjectionDaemon daemon)
+ : this(uri, shardName, daemon, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)
+ {
+ }
+
public async Task StartAsync(CancellationToken cancellationToken)
{
_innerAgent = await _daemon.StartAgentAsync(_shardName, cancellationToken);
@@ -40,4 +70,128 @@ public async Task RebuildAsync(CancellationToken cancellationToken)
// Be nice for this to get the Paused too
public AgentStatus Status { get; private set; } = AgentStatus.Stopped;
-}
\ No newline at end of file
+
+ public Task CheckHealthAsync(HealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ if (Status == AgentStatus.Paused)
+ {
+ return Task.FromResult(HealthCheckResult.Unhealthy($"Projection {Uri} paused due to errors"));
+ }
+
+ if (Status != AgentStatus.Running)
+ {
+ return Task.FromResult(HealthCheckResult.Unhealthy($"Agent {Uri} is {Status}"));
+ }
+
+ // Load thresholds on first health check
+ if (!_thresholdsLoaded)
+ {
+ LoadThresholds();
+ }
+
+ var tracker = _daemon.Tracker;
+ var highWaterMark = tracker.HighWaterMark;
+ var currentSequence = _innerAgent?.Position ?? 0;
+
+ var warningThreshold = _warningThreshold ?? DefaultWarningThreshold;
+ var criticalThreshold = _criticalThreshold ?? DefaultCriticalThreshold;
+
+ // Check if behind thresholds
+ var behindCount = highWaterMark - currentSequence;
+ if (behindCount > criticalThreshold)
+ {
+ return Task.FromResult(HealthCheckResult.Unhealthy(
+ $"Projection {Uri} is {behindCount} events behind (critical threshold: {criticalThreshold})"));
+ }
+
+ if (behindCount > warningThreshold)
+ {
+ return Task.FromResult(HealthCheckResult.Degraded(
+ $"Projection {Uri} is {behindCount} events behind (warning threshold: {warningThreshold})"));
+ }
+
+ // Check for stalled projection
+ if (currentSequence != _lastKnownSequence)
+ {
+ // Sequence has advanced, reset stall tracking
+ _lastKnownSequence = currentSequence;
+ _lastAdvancedAt = DateTimeOffset.UtcNow;
+ _consecutiveStallCount = 0;
+ }
+ else if (currentSequence < highWaterMark &&
+ DateTimeOffset.UtcNow - _lastAdvancedAt > StallTimeout)
+ {
+ // Sequence hasn't changed, there are events ahead, and it's been stalled
+ _consecutiveStallCount++;
+
+ if (_consecutiveStallCount >= MaxConsecutiveStallsBeforeRestart)
+ {
+ // Trigger auto-restart
+ _ = Task.Run(() => AttemptAutoRestartAsync(cancellationToken), cancellationToken);
+
+ return Task.FromResult(HealthCheckResult.Unhealthy(
+ $"Projection {Uri} has been stalled for {_consecutiveStallCount} consecutive health checks. Attempting auto-restart."));
+ }
+
+ return Task.FromResult(HealthCheckResult.Degraded(
+ $"Projection {Uri} stalled at sequence {currentSequence} (high water mark: {highWaterMark})"));
+ }
+
+ return Task.FromResult(HealthCheckResult.Healthy());
+ }
+
+ private async Task AttemptAutoRestartAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ _logger.LogWarning(
+ "Projection {Uri} has been stalled for {StallCount} consecutive health checks. Attempting auto-restart",
+ Uri, _consecutiveStallCount);
+
+ await StopAsync(cancellationToken);
+ await StartAsync(cancellationToken);
+
+ _consecutiveStallCount = 0;
+ _lastAdvancedAt = DateTimeOffset.UtcNow;
+
+ _logger.LogInformation("Projection {Uri} auto-restart completed successfully", Uri);
+
+ try
+ {
+ OnRestarted?.Invoke(
+ Uri.ToString(),
+ $"Auto-restarted after {MaxConsecutiveStallsBeforeRestart} consecutive stalled health checks",
+ DateTimeOffset.UtcNow);
+ }
+ catch (Exception callbackEx)
+ {
+ _logger.LogDebug(callbackEx, "Error invoking OnRestarted callback for {Uri}", Uri);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to auto-restart projection {Uri}", Uri);
+ }
+ }
+
+ private void LoadThresholds()
+ {
+ // Thresholds will be loaded from the progression table when CritterWatch pushes config.
+ // For now, use defaults. The threshold columns are read when they exist in the table.
+ _warningThreshold = DefaultWarningThreshold;
+ _criticalThreshold = DefaultCriticalThreshold;
+ _thresholdsLoaded = true;
+ }
+
+ ///
+ /// Sets the warning and critical behind thresholds for this agent's health check.
+ /// Called when CritterWatch pushes threshold configuration.
+ ///
+ public void SetThresholds(long? warningBehindThreshold, long? criticalBehindThreshold)
+ {
+ _warningThreshold = warningBehindThreshold ?? DefaultWarningThreshold;
+ _criticalThreshold = criticalBehindThreshold ?? DefaultCriticalThreshold;
+ _thresholdsLoaded = true;
+ }
+}
diff --git a/src/Persistence/Wolverine.Marten/MartenMessageDatabaseSource.cs b/src/Persistence/Wolverine.Marten/MartenMessageDatabaseSource.cs
index a54a113ac..f9c13b04f 100644
--- a/src/Persistence/Wolverine.Marten/MartenMessageDatabaseSource.cs
+++ b/src/Persistence/Wolverine.Marten/MartenMessageDatabaseSource.cs
@@ -148,6 +148,40 @@ public IReadOnlyList> AllActiveByTenant()
return _databases.Enumerate().Select(x => new Assignment(x.Key, x.Value)).ToList();
}
+ ///
+ /// Evict and dispose the cached Wolverine message store for a given tenant.
+ /// Called when a tenant is disabled or removed from dynamic tenancy.
+ ///
+ public async ValueTask RemoveTenantStoreAsync(string tenantId)
+ {
+ IMessageStore? storeToDispose = null;
+
+ lock (_locker)
+ {
+ if (_stores.TryFind(tenantId, out var store))
+ {
+ _stores = _stores.Remove(tenantId);
+
+ // Also remove from _databases if this store is cached there
+ foreach (var entry in _databases.Enumerate())
+ {
+ if (ReferenceEquals(entry.Value, store))
+ {
+ _databases = _databases.Remove(entry.Key);
+ break;
+ }
+ }
+
+ storeToDispose = store;
+ }
+ }
+
+ if (storeToDispose != null)
+ {
+ await storeToDispose.DisposeAsync();
+ }
+ }
+
public async ValueTask ConfigureDatabaseAsync(Func configureDatabase)
{
foreach (var database in AllActive().OfType()) await configureDatabase(database);
diff --git a/src/Persistence/Wolverine.Marten/Wolverine.Marten.csproj b/src/Persistence/Wolverine.Marten/Wolverine.Marten.csproj
index eeacb1aad..9f62070a1 100644
--- a/src/Persistence/Wolverine.Marten/Wolverine.Marten.csproj
+++ b/src/Persistence/Wolverine.Marten/Wolverine.Marten.csproj
@@ -12,7 +12,7 @@
-
+
diff --git a/src/Persistence/Wolverine.Polecat/Distribution/EventSubscriptionAgent.cs b/src/Persistence/Wolverine.Polecat/Distribution/EventSubscriptionAgent.cs
index c7cdfa1c9..8791749f9 100644
--- a/src/Persistence/Wolverine.Polecat/Distribution/EventSubscriptionAgent.cs
+++ b/src/Persistence/Wolverine.Polecat/Distribution/EventSubscriptionAgent.cs
@@ -1,6 +1,8 @@
using JasperFx;
using JasperFx.Events.Daemon;
using JasperFx.Events.Projections;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
using Wolverine.Runtime.Agents;
using ISubscriptionAgent = JasperFx.Events.Daemon.ISubscriptionAgent;
@@ -10,15 +12,43 @@ public class EventSubscriptionAgent : IEventSubscriptionAgent
{
private readonly ShardName _shardName;
private readonly IProjectionDaemon _daemon;
+ private readonly ILogger _logger;
private ISubscriptionAgent? _innerAgent;
- public EventSubscriptionAgent(Uri uri, ShardName shardName, IProjectionDaemon daemon)
+ // Health check stall tracking
+ private long _lastKnownSequence;
+ private DateTimeOffset _lastAdvancedAt = DateTimeOffset.UtcNow;
+ private int _consecutiveStallCount;
+
+ // Configurable thresholds (loaded from progression table, with defaults)
+ private long? _warningThreshold;
+ private long? _criticalThreshold;
+ private bool _thresholdsLoaded;
+
+ private const long DefaultWarningThreshold = 1000;
+ private const long DefaultCriticalThreshold = 5000;
+ private static readonly TimeSpan StallTimeout = TimeSpan.FromSeconds(60);
+ private const int MaxConsecutiveStallsBeforeRestart = 3;
+
+ ///
+ /// Callback fired when the agent is auto-restarted due to stalls.
+ /// Parameters: agentUri, reason, timestamp
+ ///
+ public Action? OnRestarted { get; set; }
+
+ public EventSubscriptionAgent(Uri uri, ShardName shardName, IProjectionDaemon daemon, ILogger logger)
{
_shardName = shardName;
_daemon = daemon;
+ _logger = logger;
Uri = uri;
}
+ public EventSubscriptionAgent(Uri uri, ShardName shardName, IProjectionDaemon daemon)
+ : this(uri, shardName, daemon, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)
+ {
+ }
+
public async Task StartAsync(CancellationToken cancellationToken)
{
_innerAgent = await _daemon.StartAgentAsync(_shardName, cancellationToken);
@@ -39,4 +69,128 @@ public async Task RebuildAsync(CancellationToken cancellationToken)
public Uri Uri { get; }
public AgentStatus Status { get; private set; } = AgentStatus.Stopped;
+
+ public Task CheckHealthAsync(HealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ if (Status == AgentStatus.Paused)
+ {
+ return Task.FromResult(HealthCheckResult.Unhealthy($"Projection {Uri} paused due to errors"));
+ }
+
+ if (Status != AgentStatus.Running)
+ {
+ return Task.FromResult(HealthCheckResult.Unhealthy($"Agent {Uri} is {Status}"));
+ }
+
+ // Load thresholds on first health check
+ if (!_thresholdsLoaded)
+ {
+ LoadThresholds();
+ }
+
+ var tracker = _daemon.Tracker;
+ var highWaterMark = tracker.HighWaterMark;
+ var currentSequence = _innerAgent?.Position ?? 0;
+
+ var warningThreshold = _warningThreshold ?? DefaultWarningThreshold;
+ var criticalThreshold = _criticalThreshold ?? DefaultCriticalThreshold;
+
+ // Check if behind thresholds
+ var behindCount = highWaterMark - currentSequence;
+ if (behindCount > criticalThreshold)
+ {
+ return Task.FromResult(HealthCheckResult.Unhealthy(
+ $"Projection {Uri} is {behindCount} events behind (critical threshold: {criticalThreshold})"));
+ }
+
+ if (behindCount > warningThreshold)
+ {
+ return Task.FromResult(HealthCheckResult.Degraded(
+ $"Projection {Uri} is {behindCount} events behind (warning threshold: {warningThreshold})"));
+ }
+
+ // Check for stalled projection
+ if (currentSequence != _lastKnownSequence)
+ {
+ // Sequence has advanced, reset stall tracking
+ _lastKnownSequence = currentSequence;
+ _lastAdvancedAt = DateTimeOffset.UtcNow;
+ _consecutiveStallCount = 0;
+ }
+ else if (currentSequence < highWaterMark &&
+ DateTimeOffset.UtcNow - _lastAdvancedAt > StallTimeout)
+ {
+ // Sequence hasn't changed, there are events ahead, and it's been stalled
+ _consecutiveStallCount++;
+
+ if (_consecutiveStallCount >= MaxConsecutiveStallsBeforeRestart)
+ {
+ // Trigger auto-restart
+ _ = Task.Run(() => AttemptAutoRestartAsync(cancellationToken), cancellationToken);
+
+ return Task.FromResult(HealthCheckResult.Unhealthy(
+ $"Projection {Uri} has been stalled for {_consecutiveStallCount} consecutive health checks. Attempting auto-restart."));
+ }
+
+ return Task.FromResult(HealthCheckResult.Degraded(
+ $"Projection {Uri} stalled at sequence {currentSequence} (high water mark: {highWaterMark})"));
+ }
+
+ return Task.FromResult(HealthCheckResult.Healthy());
+ }
+
+ private async Task AttemptAutoRestartAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ _logger.LogWarning(
+ "Projection {Uri} has been stalled for {StallCount} consecutive health checks. Attempting auto-restart",
+ Uri, _consecutiveStallCount);
+
+ await StopAsync(cancellationToken);
+ await StartAsync(cancellationToken);
+
+ _consecutiveStallCount = 0;
+ _lastAdvancedAt = DateTimeOffset.UtcNow;
+
+ _logger.LogInformation("Projection {Uri} auto-restart completed successfully", Uri);
+
+ try
+ {
+ OnRestarted?.Invoke(
+ Uri.ToString(),
+ $"Auto-restarted after {MaxConsecutiveStallsBeforeRestart} consecutive stalled health checks",
+ DateTimeOffset.UtcNow);
+ }
+ catch (Exception callbackEx)
+ {
+ _logger.LogDebug(callbackEx, "Error invoking OnRestarted callback for {Uri}", Uri);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to auto-restart projection {Uri}", Uri);
+ }
+ }
+
+ private void LoadThresholds()
+ {
+ // Thresholds will be loaded from the progression table when CritterWatch pushes config.
+ // For now, use defaults. The threshold columns are read when they exist in the table.
+ _warningThreshold = DefaultWarningThreshold;
+ _criticalThreshold = DefaultCriticalThreshold;
+ _thresholdsLoaded = true;
+ }
+
+ ///
+ /// Sets the warning and critical behind thresholds for this agent's health check.
+ /// Called when CritterWatch pushes threshold configuration.
+ ///
+ public void SetThresholds(long? warningBehindThreshold, long? criticalBehindThreshold)
+ {
+ _warningThreshold = warningBehindThreshold ?? DefaultWarningThreshold;
+ _criticalThreshold = criticalBehindThreshold ?? DefaultCriticalThreshold;
+ _thresholdsLoaded = true;
+ }
}
diff --git a/src/Persistence/Wolverine.Polecat/Wolverine.Polecat.csproj b/src/Persistence/Wolverine.Polecat/Wolverine.Polecat.csproj
index 9d63c8c5d..46b7fd066 100644
--- a/src/Persistence/Wolverine.Polecat/Wolverine.Polecat.csproj
+++ b/src/Persistence/Wolverine.Polecat/Wolverine.Polecat.csproj
@@ -14,7 +14,7 @@
-
+
diff --git a/src/Persistence/Wolverine.Postgresql/PostgresqlMessageStore.cs b/src/Persistence/Wolverine.Postgresql/PostgresqlMessageStore.cs
index 7e3258ad6..3c22ff5b6 100644
--- a/src/Persistence/Wolverine.Postgresql/PostgresqlMessageStore.cs
+++ b/src/Persistence/Wolverine.Postgresql/PostgresqlMessageStore.cs
@@ -581,6 +581,7 @@ public override IEnumerable AllObjects()
var tenantTable = new Table(new DbObjectName(SchemaName, DatabaseConstants.TenantsTableName));
tenantTable.AddColumn(StorageConstants.TenantIdColumn).AsPrimaryKey();
tenantTable.AddColumn(StorageConstants.ConnectionStringColumn).NotNull();
+ tenantTable.AddColumn(DatabaseConstants.DisabledColumn).DefaultValueByExpression("false").NotNull();
yield return tenantTable;
}
diff --git a/src/Persistence/Wolverine.Postgresql/Wolverine.Postgresql.csproj b/src/Persistence/Wolverine.Postgresql/Wolverine.Postgresql.csproj
index 11b341f44..fd868f21f 100644
--- a/src/Persistence/Wolverine.Postgresql/Wolverine.Postgresql.csproj
+++ b/src/Persistence/Wolverine.Postgresql/Wolverine.Postgresql.csproj
@@ -15,7 +15,7 @@
-
+
diff --git a/src/Persistence/Wolverine.RDBMS/DatabaseConstants.cs b/src/Persistence/Wolverine.RDBMS/DatabaseConstants.cs
index 7cc642d98..b2705a008 100644
--- a/src/Persistence/Wolverine.RDBMS/DatabaseConstants.cs
+++ b/src/Persistence/Wolverine.RDBMS/DatabaseConstants.cs
@@ -28,6 +28,7 @@ public class DatabaseConstants
public const string NodeTableName = "wolverine_nodes";
public const string NodeAssignmentsTableName = "wolverine_node_assignments";
public const string TenantsTableName = "wolverine_tenants";
+ public const string DisabledColumn = "disabled";
public const string Timestamp = "timestamp";
diff --git a/src/Persistence/Wolverine.RDBMS/DurabilityAgent.cs b/src/Persistence/Wolverine.RDBMS/DurabilityAgent.cs
index fb62efc1d..ab59253b4 100644
--- a/src/Persistence/Wolverine.RDBMS/DurabilityAgent.cs
+++ b/src/Persistence/Wolverine.RDBMS/DurabilityAgent.cs
@@ -2,6 +2,7 @@
using JasperFx.Blocks;
using JasperFx.Core;
using JasperFx.Core.Reflection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Weasel.Core;
using Wolverine.Persistence;
@@ -28,6 +29,10 @@ internal class DurabilityAgent : IAgent
private Timer? _recoveryTimer;
private Timer? _scheduledJobTimer;
+ private int _successCount;
+ private int _exceptionCount;
+ private DateTime _lastHealthCheck = DateTime.UtcNow;
+
public DurabilityAgent(IWolverineRuntime runtime, IMessageDatabase database)
{
_runtime = runtime;
@@ -50,9 +55,11 @@ public DurabilityAgent(IWolverineRuntime runtime, IMessageDatabase database)
try
{
await executor.InvokeAsync(batch, new MessageBus(runtime));
+ Interlocked.Increment(ref _successCount);
}
catch (Exception e)
{
+ Interlocked.Increment(ref _exceptionCount);
_logger.LogError(e, "Error trying to run durability agent commands");
}
});
@@ -224,4 +231,29 @@ public void StartScheduledJobPolling()
_ => { _runningBlock.Post(new RunScheduledMessagesOperation(_database, _settings)); },
_settings, _settings.ScheduledJobFirstExecution, _settings.ScheduledJobPollingTime);
}
+
+ public Task CheckHealthAsync(HealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ if (Status != AgentStatus.Running)
+ {
+ return Task.FromResult(HealthCheckResult.Unhealthy($"Agent {Uri} is {Status}"));
+ }
+
+ var exceptions = Interlocked.Exchange(ref _exceptionCount, 0);
+ var successes = Interlocked.Exchange(ref _successCount, 0);
+ _lastHealthCheck = DateTime.UtcNow;
+
+ if (exceptions > 0 && successes == 0)
+ {
+ return Task.FromResult(HealthCheckResult.Unhealthy("All database operations failed"));
+ }
+
+ if (exceptions > 0)
+ {
+ return Task.FromResult(HealthCheckResult.Degraded("Some database operations failed"));
+ }
+
+ return Task.FromResult(HealthCheckResult.Healthy());
+ }
}
\ No newline at end of file
diff --git a/src/Persistence/Wolverine.RDBMS/MessageDatabase.Tenants.cs b/src/Persistence/Wolverine.RDBMS/MessageDatabase.Tenants.cs
index f0bf9deac..14df14076 100644
--- a/src/Persistence/Wolverine.RDBMS/MessageDatabase.Tenants.cs
+++ b/src/Persistence/Wolverine.RDBMS/MessageDatabase.Tenants.cs
@@ -92,7 +92,7 @@ public async Task>> LoadAllTenantConnectionStri
{
await using var reader =
await conn.CreateCommand(
- $"select {StorageConstants.TenantIdColumn}, {StorageConstants.ConnectionStringColumn} from {Settings.SchemaName}.{DatabaseConstants.TenantsTableName}")
+ $"select {StorageConstants.TenantIdColumn}, {StorageConstants.ConnectionStringColumn} from {Settings.SchemaName}.{DatabaseConstants.TenantsTableName} where {DatabaseConstants.DisabledColumn} = false")
.ExecuteReaderAsync(_cancellation);
while (await reader.ReadAsync(_cancellation))
@@ -115,4 +115,83 @@ await conn.CreateCommand(
}
public IDatabaseProvider Provider { get; }
+
+ public async Task AddTenantRecordAsync(string tenantId, string connectionString)
+ {
+ await using var conn = CreateConnection();
+ await conn.OpenAsync(_cancellation);
+ try
+ {
+ await conn.CreateCommand(
+ $"insert into {Settings.SchemaName}.{DatabaseConstants.TenantsTableName} ({StorageConstants.TenantIdColumn}, {StorageConstants.ConnectionStringColumn}, {DatabaseConstants.DisabledColumn}) values (@id, @connection, false) on conflict ({StorageConstants.TenantIdColumn}) do update set {StorageConstants.ConnectionStringColumn} = @connection, {DatabaseConstants.DisabledColumn} = false")
+ .With("id", tenantId)
+ .With("connection", connectionString)
+ .ExecuteNonQueryAsync(_cancellation);
+ }
+ finally
+ {
+ await conn.CloseAsync();
+ }
+ }
+
+ public async Task SetTenantDisabledAsync(string tenantId, bool disabled)
+ {
+ await using var conn = CreateConnection();
+ await conn.OpenAsync(_cancellation);
+ try
+ {
+ await conn.CreateCommand(
+ $"update {Settings.SchemaName}.{DatabaseConstants.TenantsTableName} set {DatabaseConstants.DisabledColumn} = @disabled where {StorageConstants.TenantIdColumn} = @id")
+ .With("id", tenantId)
+ .With("disabled", disabled)
+ .ExecuteNonQueryAsync(_cancellation);
+ }
+ finally
+ {
+ await conn.CloseAsync();
+ }
+ }
+
+ public async Task DeleteTenantRecordAsync(string tenantId)
+ {
+ await using var conn = CreateConnection();
+ await conn.OpenAsync(_cancellation);
+ try
+ {
+ await conn.CreateCommand(
+ $"delete from {Settings.SchemaName}.{DatabaseConstants.TenantsTableName} where {StorageConstants.TenantIdColumn} = @id")
+ .With("id", tenantId)
+ .ExecuteNonQueryAsync(_cancellation);
+ }
+ finally
+ {
+ await conn.CloseAsync();
+ }
+ }
+
+ public async Task> LoadDisabledTenantIdsAsync()
+ {
+ var list = new List();
+ await using var conn = CreateConnection();
+ await conn.OpenAsync(_cancellation);
+ try
+ {
+ await using var reader = await conn.CreateCommand(
+ $"select {StorageConstants.TenantIdColumn} from {Settings.SchemaName}.{DatabaseConstants.TenantsTableName} where {DatabaseConstants.DisabledColumn} = true")
+ .ExecuteReaderAsync(_cancellation);
+
+ while (await reader.ReadAsync(_cancellation))
+ {
+ list.Add(await reader.GetFieldValueAsync(0, _cancellation));
+ }
+
+ await reader.CloseAsync();
+ }
+ finally
+ {
+ await conn.CloseAsync();
+ }
+
+ return list;
+ }
}
\ No newline at end of file
diff --git a/src/Persistence/Wolverine.RDBMS/MultiTenancy/MasterTenantSource.cs b/src/Persistence/Wolverine.RDBMS/MultiTenancy/MasterTenantSource.cs
index 71bef9d8e..61d0a91ef 100644
--- a/src/Persistence/Wolverine.RDBMS/MultiTenancy/MasterTenantSource.cs
+++ b/src/Persistence/Wolverine.RDBMS/MultiTenancy/MasterTenantSource.cs
@@ -10,11 +10,31 @@ public interface ITenantDatabaseRegistry
{
Task TryFindTenantConnectionString(string tenantId);
Task>> LoadAllTenantConnectionStrings();
-
+
IDatabaseProvider Provider { get; }
+
+ ///
+ /// Add or update a tenant record in the master tenants table.
+ ///
+ Task AddTenantRecordAsync(string tenantId, string connectionString);
+
+ ///
+ /// Set the disabled flag on a tenant record.
+ ///
+ Task SetTenantDisabledAsync(string tenantId, bool disabled);
+
+ ///
+ /// Delete a tenant record entirely from the master tenants table.
+ ///
+ Task DeleteTenantRecordAsync(string tenantId);
+
+ ///
+ /// Load all tenant IDs that are currently disabled.
+ ///
+ Task> LoadDisabledTenantIdsAsync();
}
-public class MasterTenantSource : ITenantedSource
+public class MasterTenantSource : IDynamicTenantSource
{
private readonly ITenantDatabaseRegistry _tenantRegistry;
private readonly WolverineOptions _options;
@@ -75,4 +95,46 @@ public IReadOnlyList> AllActiveByTenant()
{
return _values.Enumerate().Select(pair => new Assignment(pair.Key, pair.Value)).ToList();
}
+
+ public async Task AddTenantAsync(string tenantId, string connectionValue)
+ {
+ tenantId = _options.Durability.TenantIdStyle.MaybeCorrectTenantId(tenantId);
+ await _tenantRegistry.AddTenantRecordAsync(tenantId, connectionValue);
+
+ var connectionString = _tenantRegistry.Provider.AddApplicationNameToConnectionString(connectionValue, _options.ServiceName);
+ _values = _values.AddOrUpdate(tenantId, connectionString);
+ }
+
+ public async Task DisableTenantAsync(string tenantId)
+ {
+ tenantId = _options.Durability.TenantIdStyle.MaybeCorrectTenantId(tenantId);
+ await _tenantRegistry.SetTenantDisabledAsync(tenantId, true);
+ _values = _values.Remove(tenantId);
+ }
+
+ public async Task RemoveTenantAsync(string tenantId)
+ {
+ tenantId = _options.Durability.TenantIdStyle.MaybeCorrectTenantId(tenantId);
+ await _tenantRegistry.DeleteTenantRecordAsync(tenantId);
+ _values = _values.Remove(tenantId);
+ }
+
+ public async Task> AllDisabledAsync()
+ {
+ return await _tenantRegistry.LoadDisabledTenantIdsAsync();
+ }
+
+ public async Task EnableTenantAsync(string tenantId)
+ {
+ tenantId = _options.Durability.TenantIdStyle.MaybeCorrectTenantId(tenantId);
+ await _tenantRegistry.SetTenantDisabledAsync(tenantId, false);
+
+ // Re-populate the cache for this tenant
+ var connectionString = await _tenantRegistry.TryFindTenantConnectionString(tenantId);
+ if (connectionString.IsNotEmpty())
+ {
+ connectionString = _tenantRegistry.Provider.AddApplicationNameToConnectionString(connectionString, _options.ServiceName);
+ _values = _values.AddOrUpdate(tenantId, connectionString);
+ }
+ }
}
\ No newline at end of file
diff --git a/src/Persistence/Wolverine.RDBMS/Wolverine.RDBMS.csproj b/src/Persistence/Wolverine.RDBMS/Wolverine.RDBMS.csproj
index 1b9054da1..ea65cd880 100644
--- a/src/Persistence/Wolverine.RDBMS/Wolverine.RDBMS.csproj
+++ b/src/Persistence/Wolverine.RDBMS/Wolverine.RDBMS.csproj
@@ -14,7 +14,7 @@
-
+
diff --git a/src/Persistence/Wolverine.SqlServer/Persistence/SqlServerMessageStore.cs b/src/Persistence/Wolverine.SqlServer/Persistence/SqlServerMessageStore.cs
index 01c84f526..74bc7e8de 100644
--- a/src/Persistence/Wolverine.SqlServer/Persistence/SqlServerMessageStore.cs
+++ b/src/Persistence/Wolverine.SqlServer/Persistence/SqlServerMessageStore.cs
@@ -504,6 +504,7 @@ public override IEnumerable AllObjects()
var tenantTable = new Table(new DbObjectName(SchemaName, DatabaseConstants.TenantsTableName));
tenantTable.AddColumn(StorageConstants.TenantIdColumn, "varchar(100)").AsPrimaryKey();
tenantTable.AddColumn(StorageConstants.ConnectionStringColumn, "varchar(500)").NotNull();
+ tenantTable.AddColumn(DatabaseConstants.DisabledColumn, "bit").DefaultValueByExpression("0").NotNull();
yield return tenantTable;
}
diff --git a/src/Persistence/Wolverine.SqlServer/Wolverine.SqlServer.csproj b/src/Persistence/Wolverine.SqlServer/Wolverine.SqlServer.csproj
index f4ca5ac90..68d611f32 100644
--- a/src/Persistence/Wolverine.SqlServer/Wolverine.SqlServer.csproj
+++ b/src/Persistence/Wolverine.SqlServer/Wolverine.SqlServer.csproj
@@ -13,7 +13,7 @@
-
+
diff --git a/src/Persistence/Wolverine.Sqlite/SqliteMessageStore.cs b/src/Persistence/Wolverine.Sqlite/SqliteMessageStore.cs
index 41026afa5..3dd8376a6 100644
--- a/src/Persistence/Wolverine.Sqlite/SqliteMessageStore.cs
+++ b/src/Persistence/Wolverine.Sqlite/SqliteMessageStore.cs
@@ -436,6 +436,7 @@ public override IEnumerable AllObjects()
var tenantTable = new Weasel.Sqlite.Tables.Table(new SqliteObjectName(DatabaseConstants.TenantsTableName));
tenantTable.AddColumn(StorageConstants.TenantIdColumn, "TEXT").AsPrimaryKey();
tenantTable.AddColumn(StorageConstants.ConnectionStringColumn, "TEXT").NotNull();
+ tenantTable.AddColumn(DatabaseConstants.DisabledColumn, "INTEGER").DefaultValueByExpression("0").NotNull();
yield return tenantTable;
}
diff --git a/src/Persistence/Wolverine.Sqlite/Wolverine.Sqlite.csproj b/src/Persistence/Wolverine.Sqlite/Wolverine.Sqlite.csproj
index f93e4591c..dc2c66565 100644
--- a/src/Persistence/Wolverine.Sqlite/Wolverine.Sqlite.csproj
+++ b/src/Persistence/Wolverine.Sqlite/Wolverine.Sqlite.csproj
@@ -15,7 +15,7 @@
-
+
diff --git a/src/Wolverine.SourceGeneration/HandlerTypeInfo.cs b/src/Wolverine.SourceGeneration/HandlerTypeInfo.cs
new file mode 100644
index 000000000..13d241f78
--- /dev/null
+++ b/src/Wolverine.SourceGeneration/HandlerTypeInfo.cs
@@ -0,0 +1,28 @@
+//
+using System.Collections.Generic;
+
+namespace Wolverine.SourceGeneration
+{
+ ///
+ /// Metadata about a discovered handler type collected during syntax/semantic analysis.
+ ///
+ internal sealed class HandlerTypeInfo
+ {
+ public HandlerTypeInfo(string fullName, string namespaceName, string className)
+ {
+ FullName = fullName;
+ NamespaceName = namespaceName;
+ ClassName = className;
+ }
+
+ public string FullName { get; }
+ public string NamespaceName { get; }
+ public string ClassName { get; }
+
+ ///
+ /// Message types discovered from handler method first parameters.
+ /// Each entry is (FullTypeName, Alias) where alias is the simple type name.
+ ///
+ public List<(string FullTypeName, string Alias)> MessageTypes { get; } = new List<(string, string)>();
+ }
+}
diff --git a/src/Wolverine.SourceGeneration/MessageTypeInfo.cs b/src/Wolverine.SourceGeneration/MessageTypeInfo.cs
new file mode 100644
index 000000000..2ecba1fb3
--- /dev/null
+++ b/src/Wolverine.SourceGeneration/MessageTypeInfo.cs
@@ -0,0 +1,17 @@
+namespace Wolverine.SourceGeneration
+{
+ ///
+ /// Metadata about a discovered message type.
+ ///
+ internal sealed class MessageTypeInfo
+ {
+ public MessageTypeInfo(string fullName, string alias)
+ {
+ FullName = fullName;
+ Alias = alias;
+ }
+
+ public string FullName { get; }
+ public string Alias { get; }
+ }
+}
diff --git a/src/Wolverine.SourceGeneration/Wolverine.SourceGeneration.csproj b/src/Wolverine.SourceGeneration/Wolverine.SourceGeneration.csproj
new file mode 100644
index 000000000..bf07449a0
--- /dev/null
+++ b/src/Wolverine.SourceGeneration/Wolverine.SourceGeneration.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Roslyn source generator for compile-time Wolverine handler and message type discovery
+ netstandard2.0
+ 12
+ $(NoWarn);CS8603
+ true
+ true
+ enable
+ false
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/src/Wolverine.SourceGeneration/WolverineTypeManifestGenerator.cs b/src/Wolverine.SourceGeneration/WolverineTypeManifestGenerator.cs
new file mode 100644
index 000000000..250545649
--- /dev/null
+++ b/src/Wolverine.SourceGeneration/WolverineTypeManifestGenerator.cs
@@ -0,0 +1,656 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Text;
+
+namespace Wolverine.SourceGeneration
+{
+ ///
+ /// Roslyn incremental source generator that discovers Wolverine handler types,
+ /// message types, pre-generated handler code, and extension types at compile time,
+ /// emitting an IWolverineTypeLoader implementation that eliminates runtime assembly
+ /// scanning during startup.
+ ///
+ [Generator]
+ public class WolverineTypeManifestGenerator : IIncrementalGenerator
+ {
+ // Handler type name suffixes matching Wolverine conventions
+ private const string HandlerSuffix = "Handler";
+ private const string ConsumerSuffix = "Consumer";
+
+ // Well-known Wolverine attribute and interface full names
+ private const string WolverineHandlerAttributeFullName = "Wolverine.Attributes.WolverineHandlerAttribute";
+ private const string WolverineIgnoreAttributeFullName = "Wolverine.Attributes.WolverineIgnoreAttribute";
+ private const string WolverineMessageAttributeFullName = "Wolverine.Attributes.WolverineMessageAttribute";
+ private const string IWolverineHandlerFullName = "Wolverine.IWolverineHandler";
+ private const string SagaFullName = "Wolverine.Saga";
+ private const string IMessageFullName = "Wolverine.IMessage";
+
+ // Phase D: Pre-generated handler types
+ private const string MessageHandlerFullName = "Wolverine.Runtime.Handlers.MessageHandler";
+ internal const string WolverineHandlersNamespaceConst = "WolverineHandlers";
+
+ // Phase E: Extension discovery
+ private const string IWolverineExtensionFullName = "Wolverine.IWolverineExtension";
+ private const string WolverineModuleAttributeFullName = "Wolverine.Attributes.WolverineModuleAttribute";
+
+ // Valid handler method names (matching HandlerChain and SagaChain constants)
+ private static readonly HashSet ValidMethodNames = new HashSet(StringComparer.Ordinal)
+ {
+ "Handle", "Handles", "HandleAsync", "HandlesAsync",
+ "Consume", "Consumes", "ConsumeAsync", "ConsumesAsync",
+ "Orchestrate", "Orchestrates", "OrchestrateAsync", "OrchestratesAsync",
+ "Start", "Starts", "StartAsync", "StartsAsync",
+ "StartOrHandle", "StartsOrHandles", "StartOrHandleAsync", "StartsOrHandlesAsync",
+ "NotFound", "NotFoundAsync"
+ };
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ // Step 1: Find candidate class declarations that might be handlers or message types
+ var classDeclarations = context.SyntaxProvider
+ .CreateSyntaxProvider(
+ predicate: static (node, _) => IsCandidate(node),
+ transform: static (ctx, _) => ClassifyType(ctx))
+ .Where(static result => result != null);
+
+ // Step 2: Combine with compilation for final resolution
+ var compilationAndClasses = context.CompilationProvider
+ .Combine(classDeclarations.Collect());
+
+ // Step 3: Emit the source
+ context.RegisterSourceOutput(compilationAndClasses,
+ static (spc, source) => Execute(source.Left, source.Right!, spc));
+ }
+
+ ///
+ /// Fast syntactic predicate: is this node a class/record declaration that could
+ /// potentially be a handler or message type?
+ ///
+ private static bool IsCandidate(SyntaxNode node)
+ {
+ // Accept class declarations and record declarations
+ if (node is ClassDeclarationSyntax classDecl)
+ {
+ // Must be public (or nested public)
+ if (!HasPublicModifier(classDecl.Modifiers))
+ return false;
+
+ // Must not be abstract (unless it's checked later for Saga subclass)
+ // We let everything through that's public; semantic analysis will refine
+ return true;
+ }
+
+ if (node is RecordDeclarationSyntax recordDecl)
+ {
+ if (!HasPublicModifier(recordDecl.Modifiers))
+ return false;
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool HasPublicModifier(SyntaxTokenList modifiers)
+ {
+ foreach (var modifier in modifiers)
+ {
+ if (modifier.IsKind(SyntaxKind.PublicKeyword))
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Semantic transform: classify a type as a handler, message type, or both.
+ /// Returns null if the type doesn't match any Wolverine conventions.
+ ///
+ private static DiscoveredType? ClassifyType(GeneratorSyntaxContext context)
+ {
+ INamedTypeSymbol? classSymbol = null;
+
+ if (context.Node is ClassDeclarationSyntax)
+ {
+ classSymbol = context.SemanticModel.GetDeclaredSymbol(context.Node) as INamedTypeSymbol;
+ }
+ else if (context.Node is RecordDeclarationSyntax)
+ {
+ classSymbol = context.SemanticModel.GetDeclaredSymbol(context.Node) as INamedTypeSymbol;
+ }
+
+ if (classSymbol == null) return null;
+ if (classSymbol.IsAbstract) return null;
+ if (classSymbol.IsGenericType && !classSymbol.IsDefinition) return null;
+ // Skip open generic type definitions (e.g., Handler) -- we only want concrete types
+ if (classSymbol.IsGenericType) return null;
+ if (classSymbol.DeclaredAccessibility != Accessibility.Public) return null;
+
+ // Check for [WolverineIgnore]
+ if (HasAttribute(classSymbol, WolverineIgnoreAttributeFullName))
+ return null;
+
+ var isHandler = IsHandlerType(classSymbol);
+ var isMessage = IsMessageType(classSymbol);
+
+ if (!isHandler && !isMessage) return null;
+
+ var result = new DiscoveredType(
+ classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
+ classSymbol.Name,
+ classSymbol.ContainingNamespace?.ToDisplayString() ?? "",
+ isHandler,
+ isMessage);
+
+ // If it's a handler, find message types from method parameters
+ if (isHandler)
+ {
+ FindMessageTypesFromMethods(classSymbol, result);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Determines if a type qualifies as a Wolverine handler.
+ /// Matches the same rules as HandlerDiscovery.specifyConventionalHandlerDiscovery().
+ ///
+ private static bool IsHandlerType(INamedTypeSymbol symbol)
+ {
+ // Rule 1: Name ends with "Handler" or "Consumer"
+ if (symbol.Name.EndsWith(HandlerSuffix, StringComparison.Ordinal) ||
+ symbol.Name.EndsWith(ConsumerSuffix, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ // Rule 2: Decorated with [WolverineHandler]
+ if (HasAttribute(symbol, WolverineHandlerAttributeFullName))
+ {
+ return true;
+ }
+
+ // Rule 3: Implements IWolverineHandler
+ if (ImplementsInterface(symbol, IWolverineHandlerFullName))
+ {
+ return true;
+ }
+
+ // Rule 4: Inherits from Saga (directly or indirectly)
+ if (InheritsFrom(symbol, SagaFullName))
+ {
+ return true;
+ }
+
+ // Rule 5: Has methods decorated with [WolverineHandler]
+ foreach (var member in symbol.GetMembers())
+ {
+ if (member is IMethodSymbol method && HasAttribute(method, WolverineHandlerAttributeFullName))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Determines if a type qualifies as a Wolverine message type.
+ ///
+ private static bool IsMessageType(INamedTypeSymbol symbol)
+ {
+ if (symbol.IsStatic) return false;
+ if (!symbol.IsReferenceType && !symbol.IsValueType) return false;
+
+ // Implements IMessage
+ if (ImplementsInterface(symbol, IMessageFullName))
+ return true;
+
+ // Decorated with [WolverineMessage]
+ if (HasAttribute(symbol, WolverineMessageAttributeFullName))
+ return true;
+
+ return false;
+ }
+
+ ///
+ /// Find message types by inspecting the first parameter of handler methods.
+ ///
+ private static void FindMessageTypesFromMethods(INamedTypeSymbol handlerType, DiscoveredType result)
+ {
+ foreach (var member in handlerType.GetMembers())
+ {
+ if (!(member is IMethodSymbol method)) continue;
+ if (method.DeclaredAccessibility != Accessibility.Public) continue;
+ if (method.MethodKind != MethodKind.Ordinary) continue;
+ if (method.Parameters.Length == 0) continue;
+
+ // Check if method name matches handler conventions or has [WolverineHandler]
+ var isHandlerMethod = ValidMethodNames.Contains(method.Name) ||
+ HasAttribute(method, WolverineHandlerAttributeFullName);
+
+ if (!isHandlerMethod) continue;
+
+ // Check for [WolverineIgnore]
+ if (HasAttribute(method, WolverineIgnoreAttributeFullName)) continue;
+
+ // First parameter is the message type
+ var firstParam = method.Parameters[0];
+ var paramType = firstParam.Type;
+
+ // Skip primitives, object, arrays of object, etc.
+ if (paramType.SpecialType != SpecialType.None) continue;
+ if (paramType.TypeKind == TypeKind.Interface) continue; // Skip interface params
+ if (paramType.TypeKind == TypeKind.TypeParameter) continue; // Skip generic params
+
+ // Skip open generic types (e.g., when handler method uses T as parameter type)
+ if (paramType is INamedTypeSymbol namedParamType && namedParamType.IsGenericType)
+ {
+ // Only allow closed generic types (all type arguments are concrete)
+ var hasUnboundTypeArgs = false;
+ foreach (var typeArg in namedParamType.TypeArguments)
+ {
+ if (typeArg.TypeKind == TypeKind.TypeParameter)
+ {
+ hasUnboundTypeArgs = true;
+ break;
+ }
+ }
+ if (hasUnboundTypeArgs) continue;
+ }
+
+ var fullTypeName = paramType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var alias = paramType.Name;
+
+ result.MethodMessageTypes.Add((fullTypeName, alias));
+ }
+ }
+
+ private static bool HasAttribute(ISymbol symbol, string attributeFullName)
+ {
+ foreach (var attr in symbol.GetAttributes())
+ {
+ var attrClass = attr.AttributeClass;
+ if (attrClass != null && attrClass.ToDisplayString() == attributeFullName)
+ return true;
+ }
+ return false;
+ }
+
+ private static bool ImplementsInterface(INamedTypeSymbol symbol, string interfaceFullName)
+ {
+ foreach (var iface in symbol.AllInterfaces)
+ {
+ if (iface.ToDisplayString() == interfaceFullName)
+ return true;
+ }
+ return false;
+ }
+
+ private static bool InheritsFrom(INamedTypeSymbol symbol, string baseClassFullName)
+ {
+ var current = symbol.BaseType;
+ while (current != null)
+ {
+ if (current.ToDisplayString() == baseClassFullName)
+ return true;
+ current = current.BaseType;
+ }
+ return false;
+ }
+
+ ///
+ /// Final emission step: generate the IWolverineTypeLoader implementation.
+ ///
+ private static void Execute(
+ Compilation compilation,
+ ImmutableArray discoveredTypes,
+ SourceProductionContext context)
+ {
+ if (discoveredTypes.IsDefaultOrEmpty) return;
+
+ // Check if the compilation references Wolverine (has IWolverineTypeLoader)
+ var typeLoaderSymbol = compilation.GetTypeByMetadataName("Wolverine.Runtime.IWolverineTypeLoader");
+ if (typeLoaderSymbol == null)
+ {
+ // This assembly doesn't reference Wolverine, skip generation
+ return;
+ }
+
+ // Deduplicate and categorize
+ var handlerTypes = new List();
+ var messageTypes = new Dictionary(); // FullName -> Alias
+ var handlerTypeNames = new HashSet();
+
+ foreach (var type in discoveredTypes)
+ {
+ if (type == null) continue;
+
+ if (type.IsHandler && handlerTypeNames.Add(type.FullName))
+ {
+ handlerTypes.Add(type.FullName);
+
+ // Add message types from handler method params
+ foreach (var (msgFullName, msgAlias) in type.MethodMessageTypes)
+ {
+ if (!messageTypes.ContainsKey(msgFullName))
+ {
+ messageTypes[msgFullName] = msgAlias;
+ }
+ }
+ }
+
+ if (type.IsMessage)
+ {
+ var fullName = type.FullName;
+ if (!messageTypes.ContainsKey(fullName))
+ {
+ messageTypes[fullName] = type.ClassName;
+ }
+ }
+ }
+
+ // Phase D: Find pre-generated handler types in the WolverineHandlers namespace
+ // that inherit from MessageHandler. These are emitted by Wolverine's code generation
+ // (dotnet run -- codegen) and can be looked up via dictionary instead of linear scan.
+ var preGenHandlerTypes = FindPreGeneratedHandlerTypes(compilation);
+
+ // Phase E: Find extension types implementing IWolverineExtension
+ var extensionTypes = FindExtensionTypes(compilation);
+
+ // Don't emit if we found nothing
+ if (handlerTypes.Count == 0 && messageTypes.Count == 0
+ && preGenHandlerTypes.Count == 0 && extensionTypes.Count == 0) return;
+
+ var source = EmitTypeLoaderSource(handlerTypes, messageTypes, preGenHandlerTypes, extensionTypes);
+ context.AddSource("WolverineTypeManifest.g.cs", SourceText.From(source, Encoding.UTF8));
+ }
+
+ ///
+ /// Phase D: Scan for types in the WolverineHandlers namespace that inherit from
+ /// Wolverine.Runtime.Handlers.MessageHandler. These are pre-generated handler types
+ /// emitted by Wolverine's code generation step (dotnet run -- codegen).
+ ///
+ private static List<(string FullName, string ClassName)> FindPreGeneratedHandlerTypes(Compilation compilation)
+ {
+ var result = new List<(string, string)>();
+
+ var messageHandlerSymbol = compilation.GetTypeByMetadataName(MessageHandlerFullName);
+ if (messageHandlerSymbol == null) return result;
+
+ // Scan all types in the compilation's source assembly
+ var visitor = new PreGeneratedHandlerVisitor(messageHandlerSymbol, result);
+ visitor.Visit(compilation.Assembly.GlobalNamespace);
+
+ return result;
+ }
+
+ ///
+ /// Phase E: Scan for types implementing IWolverineExtension or decorated with
+ /// [WolverineModule] attribute in the compilation's source assembly.
+ ///
+ private static List FindExtensionTypes(Compilation compilation)
+ {
+ var result = new List();
+
+ var extensionInterfaceSymbol = compilation.GetTypeByMetadataName(IWolverineExtensionFullName);
+ if (extensionInterfaceSymbol == null) return result;
+
+ var visitor = new ExtensionTypeVisitor(extensionInterfaceSymbol, result);
+ visitor.Visit(compilation.Assembly.GlobalNamespace);
+
+ return result;
+ }
+
+ private static string EmitTypeLoaderSource(
+ List handlerTypes,
+ Dictionary messageTypes,
+ List<(string FullName, string ClassName)> preGenHandlerTypes,
+ List extensionTypes)
+ {
+ var sb = new StringBuilder();
+ var hasPreGenHandlers = preGenHandlerTypes.Count > 0;
+
+ sb.AppendLine("// ");
+ sb.AppendLine("// Generated by Wolverine.SourceGeneration");
+ sb.AppendLine("#nullable enable");
+ sb.AppendLine();
+ sb.AppendLine("using System;");
+ sb.AppendLine("using System.Collections.Generic;");
+ sb.AppendLine("using Wolverine.Attributes;");
+ sb.AppendLine("using Wolverine.Runtime;");
+ sb.AppendLine();
+ sb.AppendLine("[assembly: WolverineTypeManifest(typeof(Wolverine.Generated.GeneratedWolverineTypeLoader))]");
+ sb.AppendLine();
+ sb.AppendLine("namespace Wolverine.Generated");
+ sb.AppendLine("{");
+ sb.AppendLine(" /// ");
+ sb.AppendLine(" /// Source-generated implementation of IWolverineTypeLoader that provides");
+ sb.AppendLine(" /// compile-time handler and message type discovery, eliminating runtime");
+ sb.AppendLine(" /// assembly scanning during Wolverine startup.");
+ sb.AppendLine(" /// ");
+ sb.AppendLine(" internal sealed class GeneratedWolverineTypeLoader : IWolverineTypeLoader");
+ sb.AppendLine(" {");
+
+ // DiscoveredHandlerTypes
+ sb.AppendLine(" private static readonly IReadOnlyList _handlerTypes = new Type[]");
+ sb.AppendLine(" {");
+ foreach (var handler in handlerTypes)
+ {
+ sb.AppendLine($" typeof({handler}),");
+ }
+ sb.AppendLine(" };");
+ sb.AppendLine();
+ sb.AppendLine(" public IReadOnlyList DiscoveredHandlerTypes => _handlerTypes;");
+ sb.AppendLine();
+
+ // DiscoveredMessageTypes
+ sb.AppendLine(" private static readonly IReadOnlyList<(Type MessageType, string Alias)> _messageTypes = new (Type, string)[]");
+ sb.AppendLine(" {");
+ foreach (var kvp in messageTypes)
+ {
+ sb.AppendLine($" (typeof({kvp.Key}), \"{kvp.Value}\"),");
+ }
+ sb.AppendLine(" };");
+ sb.AppendLine();
+ sb.AppendLine(" public IReadOnlyList<(Type MessageType, string Alias)> DiscoveredMessageTypes => _messageTypes;");
+ sb.AppendLine();
+
+ // DiscoveredHttpEndpointTypes (not yet implemented)
+ sb.AppendLine(" public IReadOnlyList DiscoveredHttpEndpointTypes => Array.Empty();");
+ sb.AppendLine();
+
+ // Phase E: DiscoveredExtensionTypes
+ if (extensionTypes.Count > 0)
+ {
+ sb.AppendLine(" private static readonly IReadOnlyList _extensionTypes = new Type[]");
+ sb.AppendLine(" {");
+ foreach (var ext in extensionTypes)
+ {
+ sb.AppendLine($" typeof({ext}),");
+ }
+ sb.AppendLine(" };");
+ sb.AppendLine();
+ sb.AppendLine(" public IReadOnlyList DiscoveredExtensionTypes => _extensionTypes;");
+ }
+ else
+ {
+ sb.AppendLine(" public IReadOnlyList DiscoveredExtensionTypes => Array.Empty();");
+ }
+ sb.AppendLine();
+
+ // Phase D: HasPreGeneratedHandlers and PreGeneratedHandlerTypes
+ sb.AppendLine($" public bool HasPreGeneratedHandlers => {(hasPreGenHandlers ? "true" : "false")};");
+ sb.AppendLine();
+
+ if (hasPreGenHandlers)
+ {
+ sb.AppendLine(" private static Dictionary? _preGenTypes;");
+ sb.AppendLine();
+ sb.AppendLine(" public IReadOnlyDictionary? PreGeneratedHandlerTypes => _preGenTypes ??= BuildPreGenTypes();");
+ sb.AppendLine();
+ sb.AppendLine(" private static Dictionary BuildPreGenTypes()");
+ sb.AppendLine(" {");
+ sb.AppendLine($" var dict = new Dictionary({preGenHandlerTypes.Count});");
+ foreach (var (fullName, className) in preGenHandlerTypes)
+ {
+ sb.AppendLine($" dict[\"{className}\"] = typeof({fullName});");
+ }
+ sb.AppendLine(" return dict;");
+ sb.AppendLine(" }");
+ sb.AppendLine();
+ sb.AppendLine(" public Type? TryFindPreGeneratedType(string typeName)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" var types = PreGeneratedHandlerTypes;");
+ sb.AppendLine(" if (types != null && types.TryGetValue(typeName, out var type))");
+ sb.AppendLine(" {");
+ sb.AppendLine(" return type;");
+ sb.AppendLine(" }");
+ sb.AppendLine(" return null;");
+ sb.AppendLine(" }");
+ }
+ else
+ {
+ sb.AppendLine(" public IReadOnlyDictionary? PreGeneratedHandlerTypes => null;");
+ sb.AppendLine();
+ sb.AppendLine(" public Type? TryFindPreGeneratedType(string typeName) => null;");
+ }
+
+ sb.AppendLine(" }");
+ sb.AppendLine("}");
+
+ return sb.ToString();
+ }
+ }
+
+ ///
+ /// Intermediate result from the syntax/semantic analysis phase.
+ ///
+ internal sealed class DiscoveredType
+ {
+ public DiscoveredType(string fullName, string className, string namespaceName, bool isHandler, bool isMessage)
+ {
+ FullName = fullName;
+ ClassName = className;
+ NamespaceName = namespaceName;
+ IsHandler = isHandler;
+ IsMessage = isMessage;
+ }
+
+ public string FullName { get; }
+ public string ClassName { get; }
+ public string NamespaceName { get; }
+ public bool IsHandler { get; }
+ public bool IsMessage { get; }
+
+ ///
+ /// Message types discovered from handler method first parameters.
+ /// (FullTypeName, Alias)
+ ///
+ public List<(string FullTypeName, string Alias)> MethodMessageTypes { get; } = new List<(string, string)>();
+ }
+
+ ///
+ /// Phase D: Visits all namespaces in the compilation to find types in the
+ /// WolverineHandlers namespace that inherit from MessageHandler.
+ /// These represent pre-generated handler code from Wolverine's codegen step.
+ ///
+ internal sealed class PreGeneratedHandlerVisitor : SymbolVisitor
+ {
+ private readonly INamedTypeSymbol _messageHandlerSymbol;
+ private readonly List<(string FullName, string ClassName)> _result;
+
+ public PreGeneratedHandlerVisitor(
+ INamedTypeSymbol messageHandlerSymbol,
+ List<(string FullName, string ClassName)> result)
+ {
+ _messageHandlerSymbol = messageHandlerSymbol;
+ _result = result;
+ }
+
+ public override void VisitNamespace(INamespaceSymbol symbol)
+ {
+ foreach (var member in symbol.GetMembers())
+ {
+ member.Accept(this);
+ }
+ }
+
+ public override void VisitNamedType(INamedTypeSymbol symbol)
+ {
+ // Only consider types in a namespace ending with WolverineHandlers
+ var ns = symbol.ContainingNamespace?.ToDisplayString() ?? "";
+ if (!ns.EndsWith(WolverineTypeManifestGenerator.WolverineHandlersNamespaceConst))
+ return;
+
+ // Must not be abstract and must inherit from MessageHandler
+ if (symbol.IsAbstract) return;
+ if (!InheritsFrom(symbol, _messageHandlerSymbol)) return;
+
+ var fullName = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ _result.Add((fullName, symbol.Name));
+ }
+
+ private static bool InheritsFrom(INamedTypeSymbol symbol, INamedTypeSymbol baseType)
+ {
+ var current = symbol.BaseType;
+ while (current != null)
+ {
+ if (SymbolEqualityComparer.Default.Equals(current, baseType))
+ return true;
+ current = current.BaseType;
+ }
+ return false;
+ }
+ }
+
+ ///
+ /// Phase E: Visits all namespaces in the compilation to find concrete types
+ /// implementing IWolverineExtension.
+ ///
+ internal sealed class ExtensionTypeVisitor : SymbolVisitor
+ {
+ private readonly INamedTypeSymbol _extensionInterfaceSymbol;
+ private readonly List _result;
+
+ public ExtensionTypeVisitor(
+ INamedTypeSymbol extensionInterfaceSymbol,
+ List result)
+ {
+ _extensionInterfaceSymbol = extensionInterfaceSymbol;
+ _result = result;
+ }
+
+ public override void VisitNamespace(INamespaceSymbol symbol)
+ {
+ foreach (var member in symbol.GetMembers())
+ {
+ member.Accept(this);
+ }
+ }
+
+ public override void VisitNamedType(INamedTypeSymbol symbol)
+ {
+ // Must be a concrete, non-abstract class
+ if (symbol.IsAbstract) return;
+ if (symbol.TypeKind != TypeKind.Class) return;
+ if (symbol.DeclaredAccessibility != Accessibility.Public &&
+ symbol.DeclaredAccessibility != Accessibility.Internal) return;
+
+ // Check if it implements IWolverineExtension
+ foreach (var iface in symbol.AllInterfaces)
+ {
+ if (SymbolEqualityComparer.Default.Equals(iface, _extensionInterfaceSymbol))
+ {
+ var fullName = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ _result.Add(fullName);
+ return;
+ }
+ }
+ }
+ }
+}
diff --git a/src/Wolverine/Attributes/WolverineTypeManifestAttribute.cs b/src/Wolverine/Attributes/WolverineTypeManifestAttribute.cs
new file mode 100644
index 000000000..23da46a05
--- /dev/null
+++ b/src/Wolverine/Attributes/WolverineTypeManifestAttribute.cs
@@ -0,0 +1,24 @@
+namespace Wolverine.Attributes;
+
+///
+/// Applied by the Wolverine source generator to mark an assembly as containing
+/// a compile-time type manifest. During startup, Wolverine checks for this
+/// attribute on the application assembly. If found, it instantiates the
+/// specified IWolverineTypeLoader to bypass runtime assembly scanning.
+///
+/// This attribute is not intended to be applied manually. It is emitted
+/// by the Wolverine.SourceGeneration package.
+///
+[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
+public sealed class WolverineTypeManifestAttribute : Attribute
+{
+ ///
+ /// The Type that implements IWolverineTypeLoader, generated by the source generator.
+ ///
+ public Type LoaderType { get; }
+
+ public WolverineTypeManifestAttribute(Type loaderType)
+ {
+ LoaderType = loaderType ?? throw new ArgumentNullException(nameof(loaderType));
+ }
+}
diff --git a/src/Wolverine/Configuration/Capabilities/EndpointDescriptor.cs b/src/Wolverine/Configuration/Capabilities/EndpointDescriptor.cs
index b6fd1940a..e5a9f6a62 100644
--- a/src/Wolverine/Configuration/Capabilities/EndpointDescriptor.cs
+++ b/src/Wolverine/Configuration/Capabilities/EndpointDescriptor.cs
@@ -30,6 +30,10 @@ public EndpointDescriptor(Endpoint endpoint) : base(endpoint)
{
Uri = endpoint.Uri;
TransportType = ResolveTransportType(endpoint);
+ SerializerType = endpoint.DefaultSerializer?.GetType().Name;
+ InteropMode = ResolveInteropMode(endpoint);
+ IsSystemEndpoint = endpoint.Uri?.ToString().Contains("wolverine.response", StringComparison.OrdinalIgnoreCase) == true
+ || endpoint.Uri?.Scheme.Equals("local", StringComparison.OrdinalIgnoreCase) == true;
}
public Uri Uri { get; set; } = null!;
@@ -39,9 +43,41 @@ public EndpointDescriptor(Endpoint endpoint) : base(endpoint)
///
public string? TransportType { get; init; }
- internal static string ResolveTransportType(Endpoint endpoint)
+ ///
+ /// The serializer type name (e.g., "SystemTextJsonSerializer", "MessagePackSerializer")
+ ///
+ public string? SerializerType { get; init; }
+
+ ///
+ /// Interop mode if using a pre-canned interop format.
+ /// Values: "CloudEvents", "NServiceBus", "MassTransit", "RawJson", or null for default Wolverine format.
+ ///
+ public string? InteropMode { get; init; }
+
+ ///
+ /// Whether this is a Wolverine system endpoint (reply queue, control queue, etc.)
+ ///
+ public bool IsSystemEndpoint { get; init; }
+
+ internal static string? ResolveInteropMode(Endpoint endpoint)
+ {
+ return ResolveInteropMode(endpoint.DefaultSerializer?.GetType().Name);
+ }
+
+ public static string? ResolveInteropMode(string? serializerTypeName)
+ {
+ if (string.IsNullOrEmpty(serializerTypeName)) return null;
+ if (serializerTypeName.Contains("CloudEvents", StringComparison.OrdinalIgnoreCase)) return "CloudEvents";
+ if (serializerTypeName.Contains("NServiceBus", StringComparison.OrdinalIgnoreCase)) return "NServiceBus";
+ if (serializerTypeName.Contains("MassTransit", StringComparison.OrdinalIgnoreCase)) return "MassTransit";
+ if (serializerTypeName.Contains("RawJson", StringComparison.OrdinalIgnoreCase)) return "RawJson";
+ return null;
+ }
+
+ internal static string ResolveTransportType(Endpoint endpoint) => ResolveTransportType(endpoint.GetType().Name);
+
+ public static string ResolveTransportType(string typeName)
{
- var typeName = endpoint.GetType().Name;
if (TransportTypeMap.TryGetValue(typeName, out var mapped))
{
diff --git a/src/Wolverine/Configuration/Capabilities/ICapabilityDescriptor.cs b/src/Wolverine/Configuration/Capabilities/ICapabilityDescriptor.cs
new file mode 100644
index 000000000..2a7472630
--- /dev/null
+++ b/src/Wolverine/Configuration/Capabilities/ICapabilityDescriptor.cs
@@ -0,0 +1,17 @@
+using JasperFx.Descriptors;
+
+namespace Wolverine.Configuration.Capabilities;
+
+///
+/// Implement this interface to contribute additional capability descriptions
+/// to ServiceCapabilities. Frameworks like Wolverine.HTTP can register
+/// implementations in DI to surface their own configuration and route data.
+///
+public interface ICapabilityDescriptor
+{
+ ///
+ /// Build an OptionsDescription representing this capability.
+ /// The description will be added to ServiceCapabilities.AdditionalCapabilities.
+ ///
+ OptionsDescription Describe();
+}
diff --git a/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs b/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs
index 07e2885b6..ada108cc2 100644
--- a/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs
+++ b/src/Wolverine/Configuration/Capabilities/ServiceCapabilities.cs
@@ -59,6 +59,12 @@ public ServiceCapabilities(WolverineOptions options)
public OptionsDescription? DurabilitySettings { get; set; }
+ ///
+ /// Additional capability descriptions contributed by extension frameworks
+ /// (e.g. Wolverine.HTTP) via ICapabilityDescriptor implementations.
+ ///
+ public List AdditionalCapabilities { get; set; } = [];
+
///
/// Uri for sending command messages to this service
///
@@ -82,6 +88,8 @@ public static async Task ReadFrom(IWolverineRuntime runtime
readEndpoints(runtime, capabilities);
+ readAdditionalCapabilities(runtime, capabilities);
+
return capabilities;
}
@@ -133,6 +141,15 @@ private static async Task readMessageStores(IWolverineRuntime runtime, ServiceCa
capabilities.MessageStoreCardinality = collection.Cardinality();
}
+ private static void readAdditionalCapabilities(IWolverineRuntime runtime, ServiceCapabilities capabilities)
+ {
+ var descriptors = runtime.Services.GetServices();
+ foreach (var descriptor in descriptors)
+ {
+ capabilities.AdditionalCapabilities.Add(descriptor.Describe());
+ }
+ }
+
private static void readTransports(IWolverineRuntime runtime, ServiceCapabilities capabilities)
{
foreach (var transport in runtime.Options.Transports)
diff --git a/src/Wolverine/ExtensionLoader.cs b/src/Wolverine/ExtensionLoader.cs
index 0cf2d553f..a4939693e 100644
--- a/src/Wolverine/ExtensionLoader.cs
+++ b/src/Wolverine/ExtensionLoader.cs
@@ -3,6 +3,7 @@
using JasperFx.Core.Reflection;
using JasperFx.Core.TypeScanning;
using Wolverine.Attributes;
+using Wolverine.Runtime;
namespace Wolverine;
@@ -68,6 +69,16 @@ Assembly[] FindDependencies(Assembly a)
internal static void ApplyExtensions(WolverineOptions options)
{
+ // Phase E: Check if we have a source-generated type loader with pre-discovered
+ // extension types. If so, use those instead of scanning all assemblies.
+ var typeLoader = TryFindTypeLoader(options.ApplicationAssembly);
+ if (typeLoader?.DiscoveredExtensionTypes?.Count > 0)
+ {
+ ApplyExtensionsFromTypeLoader(options, typeLoader);
+ return;
+ }
+
+ // Fallback to runtime assembly scanning
var assemblies = FindExtensionAssemblies();
if (assemblies.Length == 0)
@@ -84,4 +95,53 @@ internal static void ApplyExtensions(WolverineOptions options)
options.ApplyExtensions(extensions);
}
+
+ ///
+ /// Phase E: Apply extensions discovered at compile time by the source generator,
+ /// bypassing the expensive AssemblyFinder scanning.
+ ///
+ private static void ApplyExtensionsFromTypeLoader(WolverineOptions options, IWolverineTypeLoader typeLoader)
+ {
+ var extensions = typeLoader.DiscoveredExtensionTypes
+ .Where(t => t != null && !t.IsAbstract && typeof(IWolverineExtension).IsAssignableFrom(t))
+ .Select(t => (IWolverineExtension)Activator.CreateInstance(t)!)
+ .ToArray();
+
+ if (extensions.Length == 0) return;
+
+ // Include the assemblies that contain extension types for handler discovery
+ var extensionAssemblies = extensions
+ .Select(e => e.GetType().Assembly)
+ .Distinct()
+ .Where(a => a.HasAttribute())
+ .ToArray();
+
+ if (extensionAssemblies.Length > 0)
+ {
+ options.IncludeExtensionAssemblies(extensionAssemblies);
+ }
+
+ options.ApplyExtensions(extensions);
+ }
+
+ ///
+ /// Try to find a source-generated IWolverineTypeLoader from the application assembly
+ /// via the [WolverineTypeManifest] assembly attribute.
+ ///
+ internal static IWolverineTypeLoader? TryFindTypeLoader(Assembly? applicationAssembly)
+ {
+ if (applicationAssembly == null) return null;
+
+ var attr = applicationAssembly.GetCustomAttribute();
+ if (attr?.LoaderType == null) return null;
+
+ try
+ {
+ return (IWolverineTypeLoader)Activator.CreateInstance(attr.LoaderType)!;
+ }
+ catch
+ {
+ return null;
+ }
+ }
}
diff --git a/src/Wolverine/Runtime/Agents/ExclusiveListenerFamily.cs b/src/Wolverine/Runtime/Agents/ExclusiveListenerFamily.cs
index af0f897e1..509e7e8e2 100644
--- a/src/Wolverine/Runtime/Agents/ExclusiveListenerFamily.cs
+++ b/src/Wolverine/Runtime/Agents/ExclusiveListenerFamily.cs
@@ -1,6 +1,8 @@
using JasperFx;
using JasperFx.Core;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
using Wolverine.Configuration;
+using Wolverine.Transports;
namespace Wolverine.Runtime.Agents;
@@ -29,8 +31,30 @@ public async Task StopAsync(CancellationToken cancellationToken)
}
public Uri Uri { get; set; }
-
+
public AgentStatus Status { get; set; } = AgentStatus.Running;
+
+ public Task CheckHealthAsync(HealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ if (Status != AgentStatus.Running)
+ {
+ return Task.FromResult(HealthCheckResult.Unhealthy($"Agent {Uri} is {Status}"));
+ }
+
+ var listeningAgent = _runtime.Endpoints.FindListeningAgent(_endpoint.Uri);
+ if (listeningAgent == null)
+ {
+ return Task.FromResult(HealthCheckResult.Healthy());
+ }
+
+ return Task.FromResult(listeningAgent.Status switch
+ {
+ ListeningStatus.TooBusy => HealthCheckResult.Degraded($"Listener {_endpoint.EndpointName} is too busy"),
+ ListeningStatus.GloballyLatched => HealthCheckResult.Unhealthy($"Listener {_endpoint.EndpointName} is globally latched"),
+ _ => HealthCheckResult.Healthy()
+ });
+ }
}
internal class ExclusiveListenerFamily : IStaticAgentFamily
diff --git a/src/Wolverine/Runtime/Agents/IAgent.cs b/src/Wolverine/Runtime/Agents/IAgent.cs
index 2d51c5526..dac6f233d 100644
--- a/src/Wolverine/Runtime/Agents/IAgent.cs
+++ b/src/Wolverine/Runtime/Agents/IAgent.cs
@@ -1,5 +1,6 @@
using System;
using JasperFx;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
namespace Wolverine.Runtime.Agents;
@@ -12,17 +13,30 @@ namespace Wolverine.Runtime.Agents;
/// Models a constantly running background process within a Wolverine
/// node cluster
///
-public interface IAgent : IHostedService // Standard .NET interface for background services
+public interface IAgent : IHostedService, IHealthCheck
{
///
/// Unique identification for this agent within the Wolverine system
///
Uri Uri { get; }
-
- // Not really used for anything real *yet*, but
- // hopefully becomes something useful for CritterWatch
- // health monitoring
+
+ ///
+ /// Current status of this agent
+ ///
AgentStatus Status { get; }
+
+ ///
+ /// Default health check implementation based on agent status.
+ /// Override in implementations for more specific health reporting.
+ ///
+ Task IHealthCheck.CheckHealthAsync(
+ HealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(Status == AgentStatus.Running
+ ? HealthCheckResult.Healthy()
+ : HealthCheckResult.Unhealthy($"Agent {Uri} is {Status}"));
+ }
}
#endregion
diff --git a/src/Wolverine/Runtime/Agents/IWolverineObserver.cs b/src/Wolverine/Runtime/Agents/IWolverineObserver.cs
index 3a742bcef..f805fdfe4 100644
--- a/src/Wolverine/Runtime/Agents/IWolverineObserver.cs
+++ b/src/Wolverine/Runtime/Agents/IWolverineObserver.cs
@@ -31,6 +31,15 @@ public interface IWolverineObserver
Task CircuitBreakerReset(Endpoint endpoint);
void PersistedCounts(Uri storeUri, PersistedCounts counts);
void MessageHandlingMetricsExported(MessageHandlingMetrics metrics);
+
+ ///
+ /// Called when a new message causation pair is discovered at runtime.
+ /// Latched: only fires once per unique (incomingType, outgoingType) pair.
+ ///
+ void MessageCausedBy(string incomingMessageType, string outgoingMessageType, string handlerType, string? endpointUri)
+ {
+ // Default no-op implementation
+ }
}
internal class PersistenceWolverineObserver : IWolverineObserver
diff --git a/src/Wolverine/Runtime/Agents/LeaderPinnedAgentFamily.cs b/src/Wolverine/Runtime/Agents/LeaderPinnedAgentFamily.cs
index 57c5625f1..da16d7791 100644
--- a/src/Wolverine/Runtime/Agents/LeaderPinnedAgentFamily.cs
+++ b/src/Wolverine/Runtime/Agents/LeaderPinnedAgentFamily.cs
@@ -1,6 +1,8 @@
using JasperFx;
using JasperFx.Core;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
using Wolverine.Configuration;
+using Wolverine.Transports;
namespace Wolverine.Runtime.Agents;
@@ -29,8 +31,30 @@ public async Task StopAsync(CancellationToken cancellationToken)
}
public Uri Uri { get; set; }
-
+
public AgentStatus Status { get; set; } = AgentStatus.Running;
+
+ public Task CheckHealthAsync(HealthCheckContext context,
+ CancellationToken cancellationToken = default)
+ {
+ if (Status != AgentStatus.Running)
+ {
+ return Task.FromResult(HealthCheckResult.Unhealthy($"Agent {Uri} is {Status}"));
+ }
+
+ var listeningAgent = _runtime.Endpoints.FindListeningAgent(_endpoint.Uri);
+ if (listeningAgent == null)
+ {
+ return Task.FromResult(HealthCheckResult.Healthy());
+ }
+
+ return Task.FromResult(listeningAgent.Status switch
+ {
+ ListeningStatus.TooBusy => HealthCheckResult.Degraded($"Listener {_endpoint.EndpointName} is too busy"),
+ ListeningStatus.GloballyLatched => HealthCheckResult.Unhealthy($"Listener {_endpoint.EndpointName} is globally latched"),
+ _ => HealthCheckResult.Healthy()
+ });
+ }
}
public class LeaderPinnedListenerFamily : IStaticAgentFamily
diff --git a/src/Wolverine/Runtime/Handlers/Executor.cs b/src/Wolverine/Runtime/Handlers/Executor.cs
index cb6c8eeac..4dbf13d21 100644
--- a/src/Wolverine/Runtime/Handlers/Executor.cs
+++ b/src/Wolverine/Runtime/Handlers/Executor.cs
@@ -54,11 +54,13 @@ internal class Executor : IExecutor
private readonly FailureRuleCollection _rules;
private readonly TimeSpan _timeout;
private readonly IMessageTracker _tracker;
+ private readonly IWolverineRuntime? _runtime;
public Executor(ObjectPool contextPool, IWolverineRuntime runtime, IMessageHandler handler,
FailureRuleCollection rules, TimeSpan timeout)
: this(contextPool, runtime.LoggerFactory.CreateLogger(handler.MessageType), handler, runtime.MessageTracking, rules, timeout)
{
+ _runtime = runtime;
}
public Executor(ObjectPool contextPool, ILogger logger, IMessageHandler handler,
@@ -108,6 +110,12 @@ public async Task InvokeInlineAsync(Envelope envelope, CancellationToken cancell
envelope.Attempts++;
}
+ // Record message causation before flushing outgoing messages
+ if (_runtime is { Options.EnableMessageCausationTracking: true })
+ {
+ Handler.RecordCauseAndEffect(context, _runtime.Observer);
+ }
+
await context.FlushOutgoingMessagesAsync();
activity?.SetStatus(ActivityStatusCode.Ok);
_tracker.ExecutionFinished(envelope);
@@ -179,11 +187,18 @@ public async Task ExecuteAsync(MessageContext context, Cancellati
try
{
await Handler.HandleAsync(context, combined.Token);
+
+ // Record message causation after handler execution
+ if (_runtime is { Options.EnableMessageCausationTracking: true })
+ {
+ Handler.RecordCauseAndEffect(context, _runtime.Observer);
+ }
+
if (context.Envelope!.ReplyRequested.IsNotEmpty())
{
await context.AssertAnyRequiredResponseWasGenerated();
}
-
+
Activity.Current?.SetStatus(ActivityStatusCode.Ok);
_messageSucceeded(_logger, _messageTypeName, envelope.Id,
diff --git a/src/Wolverine/Runtime/Handlers/HandlerChain.cs b/src/Wolverine/Runtime/Handlers/HandlerChain.cs
index 46651c664..fbb800d68 100644
--- a/src/Wolverine/Runtime/Handlers/HandlerChain.cs
+++ b/src/Wolverine/Runtime/Handlers/HandlerChain.cs
@@ -277,7 +277,24 @@ Task ICodeFile.AttachTypes(GenerationRules rules, Assembly assembly, IServ
bool ICodeFile.AttachTypesSynchronously(GenerationRules rules, Assembly assembly, IServiceProvider? services,
string containingNamespace)
{
- _handlerType = assembly.ExportedTypes.FirstOrDefault(x => x.Name == TypeName);
+ // Use the source-generated type loader for O(1) lookup when available,
+ // falling back to linear scan of assembly.ExportedTypes.
+ // Phase D: First try the PreGeneratedHandlerTypes dictionary for O(1) lookup,
+ // then fall back to TryFindPreGeneratedType for backward compatibility.
+ var typeLoader = _parent?.TypeLoader;
+ if (typeLoader is { HasPreGeneratedHandlers: true })
+ {
+ if (typeLoader.PreGeneratedHandlerTypes?.TryGetValue(TypeName, out var preGenType) == true)
+ {
+ _handlerType = preGenType;
+ }
+ else
+ {
+ _handlerType = typeLoader.TryFindPreGeneratedType(TypeName);
+ }
+ }
+
+ _handlerType ??= assembly.ExportedTypes.FirstOrDefault(x => x.Name == TypeName);
if (_handlerType == null)
{
diff --git a/src/Wolverine/Runtime/Handlers/HandlerGraph.cs b/src/Wolverine/Runtime/Handlers/HandlerGraph.cs
index b36e79fe4..1d42d2f59 100644
--- a/src/Wolverine/Runtime/Handlers/HandlerGraph.cs
+++ b/src/Wolverine/Runtime/Handlers/HandlerGraph.cs
@@ -38,6 +38,8 @@ public partial class HandlerGraph : ICodeFileCollectionWithServices, IWithFailur
internal readonly HandlerDiscovery Discovery = new();
+ private IWolverineTypeLoader? _typeLoader;
+
private ImHashMap _chains = ImHashMap.Empty;
private ImHashMap _handlers = ImHashMap.Empty;
@@ -63,7 +65,22 @@ public HandlerGraph()
RegisterMessageType(typeof(Acknowledgement));
RegisterMessageType(typeof(FailureAcknowledgement));
}
-
+
+ ///
+ /// Set a source-generated type loader to bypass runtime assembly scanning.
+ /// When set, Compile() will use the loader's pre-discovered types instead
+ /// of running HandlerDiscovery.FindCalls().
+ ///
+ internal void UseTypeLoader(IWolverineTypeLoader typeLoader)
+ {
+ _typeLoader = typeLoader;
+ }
+
+ ///
+ /// Returns the currently configured type loader, if any.
+ ///
+ internal IWolverineTypeLoader? TypeLoader => _typeLoader;
+
public Dictionary MappedGenericMessageTypes { get; } = new();
internal IServiceContainer Container { get; set; } = null!;
@@ -324,22 +341,13 @@ internal void Compile(WolverineOptions options, IServiceContainer container)
Rules = options.CodeGeneration;
- foreach (var assembly in Discovery.Assemblies)
- {
- logger.LogInformation("Searching assembly {Assembly} for Wolverine message handlers", assembly.GetName());
- }
-
- var methods = Discovery.FindCalls(options);
-
- var calls = methods.Select(x => new HandlerCall(x.Item1, x.Item2));
-
- if (methods.Length == 0)
+ if (_typeLoader != null)
{
- logger.LogWarning("Wolverine found no handlers. If this is unexpected, check the assemblies that it's scanning. See https://wolverine.netlify.app/guide/handlers/discovery.html for more information");
+ compileWithTypeLoader(options, logger);
}
else
{
- AddRange(calls);
+ compileWithRuntimeScanning(options, logger);
}
Group(options);
@@ -391,6 +399,69 @@ IEnumerable explodeChains(HandlerChain chain)
options.MessagePartitioning.MaybeInferGrouping(this);
}
+ private void compileWithTypeLoader(WolverineOptions options, ILogger logger)
+ {
+ logger.LogInformation(
+ "Using source-generated type loader for handler discovery, bypassing runtime assembly scanning");
+
+ var handlerTypes = _typeLoader!.DiscoveredHandlerTypes;
+
+ // Still use Discovery's method filtering on the pre-discovered types,
+ // but skip the expensive assembly scanning to find those types
+ var methods = new List<(Type, System.Reflection.MethodInfo)>();
+ foreach (var handlerType in handlerTypes)
+ {
+ var typeMethods = handlerType
+ .GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
+ .Where(x => x.DeclaringType != typeof(object))
+ .Where(m => Discovery.MethodIncludes.Matches(m) && !Discovery.MethodExcludes.Matches(m))
+ .Select(m => (handlerType, m));
+
+ methods.AddRange(typeMethods);
+ }
+
+ var calls = methods.Select(x => new HandlerCall(x.Item1, x.Item2));
+
+ if (methods.Count == 0)
+ {
+ logger.LogWarning(
+ "Source-generated type loader found no handler methods. If this is unexpected, verify the source generator is correctly discovering handler types");
+ }
+ else
+ {
+ AddRange(calls);
+ }
+
+ // Also pre-register message types from the loader
+ foreach (var (messageType, alias) in _typeLoader.DiscoveredMessageTypes)
+ {
+ RegisterMessageType(messageType);
+ }
+ }
+
+ private void compileWithRuntimeScanning(WolverineOptions options, ILogger logger)
+ {
+ foreach (var assembly in Discovery.Assemblies)
+ {
+ logger.LogInformation("Searching assembly {Assembly} for Wolverine message handlers",
+ assembly.GetName());
+ }
+
+ var methods = Discovery.FindCalls(options);
+
+ var calls = methods.Select(x => new HandlerCall(x.Item1, x.Item2));
+
+ if (methods.Length == 0)
+ {
+ logger.LogWarning(
+ "Wolverine found no handlers. If this is unexpected, check the assemblies that it's scanning. See https://wolverine.netlify.app/guide/handlers/discovery.html for more information");
+ }
+ else
+ {
+ AddRange(calls);
+ }
+ }
+
private void tryApplyLocalQueueConfiguration(WolverineOptions options)
{
var local = options.Transports.GetOrCreate();
diff --git a/src/Wolverine/Runtime/Handlers/MessageHandler.cs b/src/Wolverine/Runtime/Handlers/MessageHandler.cs
index 7462392ae..9b971238a 100644
--- a/src/Wolverine/Runtime/Handlers/MessageHandler.cs
+++ b/src/Wolverine/Runtime/Handlers/MessageHandler.cs
@@ -1,5 +1,7 @@
+using System.Collections.Concurrent;
using JasperFx.Core.Reflection;
using Microsoft.Extensions.Logging;
+using Wolverine.Runtime.Agents;
namespace Wolverine.Runtime.Handlers;
@@ -19,10 +21,24 @@ public interface IMessageHandler
bool TelemetryEnabled { get; }
Task HandleAsync(MessageContext context, CancellationToken cancellation);
+
+ ///
+ /// Records cause-and-effect relationships between incoming and outgoing messages.
+ /// Called after handler execution but before flushing outgoing messages.
+ /// Default is a no-op; MessageHandler provides the real implementation.
+ ///
+ void RecordCauseAndEffect(MessageContext context, IWolverineObserver observer)
+ {
+ // No-op by default
+ }
}
public abstract class MessageHandler : IMessageHandler
{
+ // Thread-safe set of known causation pairs: "IncomingType->OutgoingType"
+ // Static per concrete handler type via the dictionary keyed by handler type
+ private static readonly ConcurrentDictionary _knownCausation = new();
+
public HandlerChain? Chain { get; set; }
public abstract Task HandleAsync(MessageContext context, CancellationToken cancellation);
@@ -33,6 +49,33 @@ public abstract class MessageHandler : IMessageHandler
public LogLevel ProcessingLogLevel => Chain!.ProcessingLogLevel;
public bool TelemetryEnabled => Chain!.TelemetryEnabled;
+
+ ///
+ /// Records cause-and-effect relationships between the incoming message type
+ /// and any outgoing messages produced during handling. Latched: each unique
+ /// (incoming, outgoing) pair is only reported once to the observer.
+ ///
+ public void RecordCauseAndEffect(MessageContext context, IWolverineObserver observer)
+ {
+ if (!context.Runtime.Options.EnableMessageCausationTracking) return;
+
+ var incomingType = MessageType.FullName ?? MessageType.Name;
+ var handlerType = GetType().FullName ?? GetType().Name;
+ var endpointUri = Chain?.Endpoints?.FirstOrDefault()?.Uri?.ToString();
+
+ foreach (var envelope in context.Outstanding)
+ {
+ var outgoingType = envelope.Message?.GetType().FullName;
+ if (string.IsNullOrEmpty(outgoingType)) continue;
+
+ var key = $"{incomingType}->{outgoingType}@{handlerType}";
+
+ // Latch: only report each unique causation pair once
+ if (!_knownCausation.TryAdd(key, 0)) continue;
+
+ observer.MessageCausedBy(incomingType, outgoingType, handlerType, endpointUri);
+ }
+ }
}
#endregion
diff --git a/src/Wolverine/Runtime/IWolverineTypeLoader.cs b/src/Wolverine/Runtime/IWolverineTypeLoader.cs
new file mode 100644
index 000000000..d684a89f7
--- /dev/null
+++ b/src/Wolverine/Runtime/IWolverineTypeLoader.cs
@@ -0,0 +1,65 @@
+using Wolverine.Runtime.Handlers;
+
+namespace Wolverine.Runtime;
+
+///
+/// Implemented by source generators to provide compile-time discovery
+/// of handlers, message types, and endpoints. When registered in DI,
+/// Wolverine will use this instead of runtime assembly scanning during
+/// startup, dramatically reducing cold start time.
+///
+/// If no IWolverineTypeLoader is registered, Wolverine falls back to
+/// its current runtime assembly scanning behavior with zero regression.
+///
+public interface IWolverineTypeLoader
+{
+ ///
+ /// Handler types discovered at compile time. These are classes matching
+ /// Wolverine handler conventions: *Handler/*Consumer suffix, implementing
+ /// IWolverineHandler, decorated with [WolverineHandler], or Saga types.
+ ///
+ IReadOnlyList DiscoveredHandlerTypes { get; }
+
+ ///
+ /// Message types discovered at compile time, with their serialization aliases.
+ /// Includes types implementing IMessage, decorated with [WolverineMessage],
+ /// and types used as parameters in handler methods.
+ ///
+ IReadOnlyList<(Type MessageType, string Alias)> DiscoveredMessageTypes { get; }
+
+ ///
+ /// HTTP endpoint types discovered at compile time. These are classes matching
+ /// Wolverine.HTTP conventions: *Endpoint/*Endpoints suffix or containing
+ /// methods with [WolverineGet], [WolverinePost], etc. attributes.
+ ///
+ IReadOnlyList DiscoveredHttpEndpointTypes { get; }
+
+ ///
+ /// Extension types discovered at compile time from assemblies marked with
+ /// [WolverineModule]. Returns the IWolverineExtension implementation types
+ /// in dependency order.
+ ///
+ IReadOnlyList DiscoveredExtensionTypes { get; }
+
+ ///
+ /// Whether this loader includes a pre-generated handler type dictionary
+ /// that can replace the linear scan in AttachTypesSynchronously.
+ ///
+ bool HasPreGeneratedHandlers { get; }
+
+ ///
+ /// A dictionary mapping handler chain TypeName to the pre-generated Type,
+ /// enabling O(1) lookup instead of O(N) assembly scanning in AttachTypesSynchronously.
+ /// Returns null if no pre-generated handler types are available.
+ ///
+ IReadOnlyDictionary? PreGeneratedHandlerTypes { get; }
+
+ ///
+ /// Look up a pre-generated handler type by its generated class name.
+ /// Returns null if the type name is not in the pre-generated manifest.
+ /// This replaces the O(N) scan of assembly.ExportedTypes with an O(1) dictionary lookup.
+ ///
+ /// The generated handler type name (e.g., "PlaceOrderHandler")
+ /// The pre-generated Type, or null if not found
+ Type? TryFindPreGeneratedType(string typeName);
+}
diff --git a/src/Wolverine/Runtime/WolverineRuntime.Agents.cs b/src/Wolverine/Runtime/WolverineRuntime.Agents.cs
index 1031e6817..41e5aca81 100644
--- a/src/Wolverine/Runtime/WolverineRuntime.Agents.cs
+++ b/src/Wolverine/Runtime/WolverineRuntime.Agents.cs
@@ -79,7 +79,7 @@ public async Task InvokeAsync(NodeDestination destination, IAgentCommand c
public Uri[] AllRunningAgentUris()
{
- return NodeController!.AllRunningAgentUris();
+ return NodeController?.AllRunningAgentUris() ?? Array.Empty();
}
public bool IsLeader()
diff --git a/src/Wolverine/Runtime/WolverineRuntime.HostService.cs b/src/Wolverine/Runtime/WolverineRuntime.HostService.cs
index 7f6948b62..f6361d9cb 100644
--- a/src/Wolverine/Runtime/WolverineRuntime.HostService.cs
+++ b/src/Wolverine/Runtime/WolverineRuntime.HostService.cs
@@ -4,6 +4,7 @@
using JasperFx.Core.Reflection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
+using Wolverine.Attributes;
using Wolverine.Configuration;
using Wolverine.Persistence.Durability;
using Wolverine.Runtime.Agents;
@@ -43,6 +44,21 @@ public async Task StartAsync(CancellationToken cancellationToken)
}
}
+ // Check for a source-generated type loader to bypass runtime assembly scanning
+ var typeLoader = _container.Services.GetService(typeof(IWolverineTypeLoader)) as IWolverineTypeLoader;
+ if (typeLoader == null)
+ {
+ // Also check for the assembly-level attribute as a discovery mechanism
+ typeLoader = tryDiscoverTypeLoaderFromAttribute();
+ }
+
+ if (typeLoader != null)
+ {
+ Logger.LogInformation(
+ "Source-generated IWolverineTypeLoader detected, using compile-time discovery to reduce startup time");
+ Handlers.UseTypeLoader(typeLoader);
+ }
+
// Build up the message handlers
Handlers.Compile(Options, _container);
@@ -410,6 +426,27 @@ private void discoverListenersFromConventions()
Options.LocalRouting.DiscoverListeners(this, handledMessageTypes);
}
+ private IWolverineTypeLoader? tryDiscoverTypeLoaderFromAttribute()
+ {
+ try
+ {
+ var assembly = Options.ApplicationAssembly;
+ if (assembly == null) return null;
+
+ var attribute = assembly.GetCustomAttributes(typeof(WolverineTypeManifestAttribute), false)
+ .FirstOrDefault() as WolverineTypeManifestAttribute;
+
+ if (attribute?.LoaderType == null) return null;
+
+ return Activator.CreateInstance(attribute.LoaderType) as IWolverineTypeLoader;
+ }
+ catch (Exception e)
+ {
+ Logger.LogWarning(e, "Failed to instantiate source-generated IWolverineTypeLoader from assembly attribute, falling back to runtime scanning");
+ return null;
+ }
+ }
+
internal Task StartLightweightAsync()
{
if (_hasStarted)
diff --git a/src/Wolverine/Wolverine.csproj b/src/Wolverine/Wolverine.csproj
index fa99d0ae6..d115d361b 100644
--- a/src/Wolverine/Wolverine.csproj
+++ b/src/Wolverine/Wolverine.csproj
@@ -9,6 +9,7 @@
+
@@ -18,14 +19,20 @@
-
-
-
+
+
+
+
+
+
+
diff --git a/src/Wolverine/WolverineOptions.cs b/src/Wolverine/WolverineOptions.cs
index ac59a567c..cdc888e5b 100644
--- a/src/Wolverine/WolverineOptions.cs
+++ b/src/Wolverine/WolverineOptions.cs
@@ -388,6 +388,14 @@ public AutoCreate AutoBuildMessageStorageOnStartup
///
public bool EnableAutomaticFailureAcks { get; set; } = false;
+ ///
+ /// When enabled, Wolverine tracks which message types are produced as a result
+ /// of handling other message types (cause and effect). New causation pairs are
+ /// reported to IWolverineObserver.MessageCausedBy for CritterWatch topology
+ /// visualization. Default is false; Wolverine.CritterWatch enables this automatically.
+ ///
+ public bool EnableMessageCausationTracking { get; set; }
+
private void deriveServiceName()
{
if (GetType() == typeof(WolverineOptions))