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))