diff --git a/docs/examples/index.md b/docs/examples/index.md index 1b5fdb38..2f90e702 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -171,6 +171,7 @@ dotnet test PatternKit.slnx -c Release * **Order Telemetry Sidecar:** `OrderTelemetrySidecarDemo` (+ `OrderTelemetrySidecarDemoTests`) — fluent and generated companion telemetry behavior with DI and ASP.NET Core mapping. * **Commerce Backends for Frontends:** `CommerceBackendsForFrontendsDemo` (+ `CommerceBackendsForFrontendsDemoTests`) — fluent and generated client-specific facade shaping with DI and ASP.NET Core mapping. * **Inventory Ambassador:** `InventoryAmbassadorDemo` (+ `InventoryAmbassadorDemoTests`) — fluent and generated outbound connectivity wrapper with DI and ASP.NET Core mapping. +* **Warehouse Leader Election:** `WarehouseLeaderElectionDemo` (+ `WarehouseLeaderElectionDemoTests`) — fluent and generated active worker lease coordination with DI and Generic Host mapping. * **Production-Ready Example Catalog:** `PatternKitExampleCatalog` (+ `PatternKitExampleCatalogTests`) — DI registration, generic host validation, ASP.NET Core endpoint mapping, and source/test/docs manifest checks. * **Tests:** `PatternKit.Examples.Tests/*` use TinyBDD scenarios that read like specs. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 561a4979..a3ef4b3f 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -255,3 +255,6 @@ - name: Inventory Ambassador href: inventory-ambassador.md + +- name: Warehouse Leader Election + href: warehouse-leader-election.md diff --git a/docs/examples/warehouse-leader-election.md b/docs/examples/warehouse-leader-election.md new file mode 100644 index 00000000..13719c41 --- /dev/null +++ b/docs/examples/warehouse-leader-election.md @@ -0,0 +1,12 @@ +# Warehouse Leader Election + +The warehouse leader election example coordinates a single active replenishment worker in a Generic Host application. + +```csharp +services.AddWarehouseLeaderElectionDemo(); + +var runner = provider.GetRequiredService(); +var log = runner.RunGenerated(); +``` + +The example includes fluent and source-generated construction, an `IServiceCollection` extension, and a hosted service that acquires leadership on start and releases it on stop. diff --git a/docs/generators/index.md b/docs/generators/index.md index 87b00796..53a9e0a0 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -127,6 +127,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Sidecar**](sidecar.md) | Companion behavior pipeline factories | `[GenerateSidecar]` | | [**Backends for Frontends**](backends-for-frontends.md) | Client-specific facade factories | `[GenerateBackendsForFrontends]` | | [**Ambassador**](ambassador.md) | Outbound connectivity wrapper factories | `[GenerateAmbassador]` | +| [**Leader Election**](leader-election.md) | Lease-backed active worker factories | `[GenerateLeaderElection]` | ## Quick Reference diff --git a/docs/generators/leader-election.md b/docs/generators/leader-election.md new file mode 100644 index 00000000..27bde7e7 --- /dev/null +++ b/docs/generators/leader-election.md @@ -0,0 +1,22 @@ +# Leader Election Generator + +`[GenerateLeaderElection]` creates a typed `LeaderElection` factory and a candidate factory from callback methods. + +```csharp +[GenerateLeaderElection(typeof(WarehouseWorkerContext), ElectionName = "warehouse-replenishment-leader")] +public static partial class WarehouseLeader +{ + [LeaderCandidateId] + private static string CandidateId(WarehouseWorkerContext context) => context.NodeId; + + [LeaderAcquired] + private static void Acquired(LeaderLease lease, WarehouseWorkerContext context) { } +} +``` + +Diagnostics: + +- `PKLE001`: host type must be partial. +- `PKLE002`: exactly one candidate id selector is required. +- `PKLE003`: candidate id or callback signature is invalid. +- `PKLE004`: lease duration must be positive. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index e6c09279..66049895 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -178,6 +178,9 @@ - name: Ambassador href: ambassador.md +- name: Leader Election + href: leader-election.md + - name: Queue Load Leveling href: queue-load-leveling.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 5cc63761..57dd8805 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -92,6 +92,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Cloud Architecture | Sidecar | `Sidecar` | Sidecar generator | | Cloud Architecture | Backends for Frontends | `BackendsForFrontends` | Backends for Frontends generator | | Cloud Architecture | Ambassador | `Ambassador` | Ambassador generator | +| Cloud Architecture | Leader Election | `LeaderElection` | Leader Election generator | | Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator | | Application Architecture | Specification | `Specification` and named registries | Specification generator | | Application Architecture | Repository | `IRepository` and `InMemoryRepository` | Repository generator | diff --git a/docs/patterns/cloud/leader-election.md b/docs/patterns/cloud/leader-election.md new file mode 100644 index 00000000..e27d04f6 --- /dev/null +++ b/docs/patterns/cloud/leader-election.md @@ -0,0 +1,22 @@ +# Leader Election + +Leader Election coordinates one active worker among several candidates by issuing renewable leases. + +```csharp +var election = LeaderElection + .Create("warehouse-replenishment-leader") + .LeaseDuration(TimeSpan.FromSeconds(30)) + .Build(); + +var candidate = LeaderElectionCandidate.Create("warehouse-node-a", context) + .OnAcquired((lease, ctx) => ctx.Log.Add($"acquired:{lease.Term}")) + .OnRenewed((lease, ctx) => ctx.Log.Add($"renewed:{lease.Term}")) + .OnReleased(ctx => ctx.Log.Add("released")) + .Build(); + +var result = election.TryAcquire(candidate); +``` + +Use it when a hosted service, scheduler, projection worker, or queue processor must have only one active instance while other nodes remain ready to take over after lease expiry. The runtime path exposes acquisition, renewal, release, contention, and expiry as explicit result states. + +The source-generated path uses `[GenerateLeaderElection]`, `[LeaderCandidateId]`, `[LeaderAcquired]`, `[LeaderRenewed]`, and `[LeaderReleased]`. Import the example through `AddWarehouseLeaderElectionDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 61342f68..3f5e1773 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -383,6 +383,8 @@ href: cloud/backends-for-frontends.md - name: Ambassador href: cloud/ambassador.md + - name: Leader Election + href: cloud/leader-election.md - name: Application Architecture items: - name: Anti-Corruption Layer diff --git a/src/PatternKit.Core/Cloud/LeaderElection/LeaderElection.cs b/src/PatternKit.Core/Cloud/LeaderElection/LeaderElection.cs new file mode 100644 index 00000000..05608497 --- /dev/null +++ b/src/PatternKit.Core/Cloud/LeaderElection/LeaderElection.cs @@ -0,0 +1,230 @@ +namespace PatternKit.Cloud.LeaderElection; + +public sealed class LeaderLease +{ + public LeaderLease(string candidateId, long term, DateTimeOffset expiresAt) + => (CandidateId, Term, ExpiresAt) = (candidateId, term, expiresAt); + + public string CandidateId { get; } + + public long Term { get; } + + public DateTimeOffset ExpiresAt { get; } + + public LeaderLease Renew(DateTimeOffset expiresAt) => new(CandidateId, Term, expiresAt); +} + +public sealed class LeaderElectionResult +{ + private LeaderElectionResult(string electionName, string candidateId, LeaderLease? lease, Exception? exception, bool acquired, bool renewed, bool released) + => (ElectionName, CandidateId, Lease, Exception, Acquired, Renewed, Released) = (electionName, candidateId, lease, exception, acquired, renewed, released); + + public string ElectionName { get; } + + public string CandidateId { get; } + + public LeaderLease? Lease { get; } + + public Exception? Exception { get; } + + public bool Acquired { get; } + + public bool Renewed { get; } + + public bool Released { get; } + + public bool Succeeded => Exception is null; + + public bool Failed => !Succeeded; + + public static LeaderElectionResult Acquisition(string electionName, string candidateId, LeaderLease lease) + => new(electionName, candidateId, lease ?? throw new ArgumentNullException(nameof(lease)), null, acquired: true, renewed: false, released: false); + + public static LeaderElectionResult Renewal(string electionName, string candidateId, LeaderLease lease) + => new(electionName, candidateId, lease ?? throw new ArgumentNullException(nameof(lease)), null, acquired: false, renewed: true, released: false); + + public static LeaderElectionResult Release(string electionName, string candidateId) + => new(electionName, candidateId, null, null, acquired: false, renewed: false, released: true); + + public static LeaderElectionResult Failure(string electionName, string candidateId, Exception exception, LeaderLease? lease = null) + => new(electionName, candidateId, lease, exception ?? throw new ArgumentNullException(nameof(exception)), acquired: false, renewed: false, released: false); +} + +public sealed class LeaderElectionCandidate +{ + internal LeaderElectionCandidate(string candidateId, TContext context, Action? onAcquired, Action? onRenewed, Action? onReleased) + => (CandidateId, Context, OnAcquired, OnRenewed, OnReleased) = (candidateId, context, onAcquired, onRenewed, onReleased); + + public string CandidateId { get; } + + public TContext Context { get; } + + internal Action? OnAcquired { get; } + + internal Action? OnRenewed { get; } + + internal Action? OnReleased { get; } +} + +public sealed class LeaderElection +{ + private readonly Func _clock; + private readonly TimeSpan _leaseDuration; + private LeaderLease? _currentLease; + + private LeaderElection(string name, TimeSpan leaseDuration, Func? clock) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Leader election name is required.", nameof(name)); + if (leaseDuration <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive."); + + Name = name; + _leaseDuration = leaseDuration; + _clock = clock ?? (() => DateTimeOffset.UtcNow); + } + + public string Name { get; } + + public LeaderLease? CurrentLease + { + get + { + ExpireIfNeeded(); + return _currentLease; + } + } + + public LeaderElectionResult TryAcquire(LeaderElectionCandidate candidate) + { + if (candidate is null) + throw new ArgumentNullException(nameof(candidate)); + + ExpireIfNeeded(); + if (_currentLease is not null && !IsLeader(candidate.CandidateId)) + return LeaderElectionResult.Failure(Name, candidate.CandidateId, new InvalidOperationException($"Leadership is held by '{_currentLease.CandidateId}'."), _currentLease); + + var lease = new LeaderLease(candidate.CandidateId, (_currentLease?.Term ?? 0) + 1, _clock().Add(_leaseDuration)); + _currentLease = lease; + candidate.OnAcquired?.Invoke(lease, candidate.Context); + return LeaderElectionResult.Acquisition(Name, candidate.CandidateId, lease); + } + + public LeaderElectionResult Renew(LeaderElectionCandidate candidate) + { + if (candidate is null) + throw new ArgumentNullException(nameof(candidate)); + + ExpireIfNeeded(); + if (_currentLease is null) + return LeaderElectionResult.Failure(Name, candidate.CandidateId, new InvalidOperationException("No active leadership lease exists.")); + if (!IsLeader(candidate.CandidateId)) + return LeaderElectionResult.Failure(Name, candidate.CandidateId, new InvalidOperationException($"Candidate '{candidate.CandidateId}' is not the current leader."), _currentLease); + + var lease = _currentLease.Renew(_clock().Add(_leaseDuration)); + _currentLease = lease; + candidate.OnRenewed?.Invoke(lease, candidate.Context); + return LeaderElectionResult.Renewal(Name, candidate.CandidateId, lease); + } + + public LeaderElectionResult Release(LeaderElectionCandidate candidate) + { + if (candidate is null) + throw new ArgumentNullException(nameof(candidate)); + + ExpireIfNeeded(); + if (_currentLease is null) + return LeaderElectionResult.Failure(Name, candidate.CandidateId, new InvalidOperationException("No active leadership lease exists.")); + if (!IsLeader(candidate.CandidateId)) + return LeaderElectionResult.Failure(Name, candidate.CandidateId, new InvalidOperationException($"Candidate '{candidate.CandidateId}' is not the current leader."), _currentLease); + + _currentLease = null; + candidate.OnReleased?.Invoke(candidate.Context); + return LeaderElectionResult.Release(Name, candidate.CandidateId); + } + + public bool IsLeader(string candidateId) + { + if (string.IsNullOrWhiteSpace(candidateId)) + throw new ArgumentException("Candidate id is required.", nameof(candidateId)); + + ExpireIfNeeded(); + return _currentLease is not null && string.Equals(_currentLease.CandidateId, candidateId, StringComparison.Ordinal); + } + + public static Builder Create(string name = "leader-election") => new(name); + + private void ExpireIfNeeded() + { + if (_currentLease is not null && _currentLease.ExpiresAt <= _clock()) + _currentLease = null; + } + + public sealed class Builder + { + private readonly string _name; + private TimeSpan _leaseDuration = TimeSpan.FromSeconds(30); + private Func? _clock; + + internal Builder(string name) => _name = name; + + public Builder LeaseDuration(TimeSpan leaseDuration) + { + _leaseDuration = leaseDuration; + return this; + } + + public Builder Clock(Func clock) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + return this; + } + + public LeaderElection Build() => new(_name, _leaseDuration, _clock); + } +} + +public static class LeaderElectionCandidate +{ + public static Builder Create(string candidateId, TContext context) + => new(candidateId, context); + + public sealed class Builder + { + private readonly string _candidateId; + private readonly TContext _context; + private Action? _onAcquired; + private Action? _onRenewed; + private Action? _onReleased; + + internal Builder(string candidateId, TContext context) + { + if (string.IsNullOrWhiteSpace(candidateId)) + throw new ArgumentException("Candidate id is required.", nameof(candidateId)); + + _candidateId = candidateId; + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public Builder OnAcquired(Action onAcquired) + { + _onAcquired = onAcquired ?? throw new ArgumentNullException(nameof(onAcquired)); + return this; + } + + public Builder OnRenewed(Action onRenewed) + { + _onRenewed = onRenewed ?? throw new ArgumentNullException(nameof(onRenewed)); + return this; + } + + public Builder OnReleased(Action onReleased) + { + _onReleased = onReleased ?? throw new ArgumentNullException(nameof(onReleased)); + return this; + } + + public LeaderElectionCandidate Build() + => new(_candidateId, _context, _onAcquired, _onRenewed, _onReleased); + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 9a534373..3bd5b5ca 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -45,6 +45,7 @@ using PatternKit.Examples.Generators.Visitors; using PatternKit.Examples.HealthEndpointMonitoringDemo; using PatternKit.Examples.IdentityMapDemo; +using PatternKit.Examples.LeaderElectionDemo; using PatternKit.Examples.MaterializedViewDemo; using PatternKit.Examples.MementoDemo; using PatternKit.Examples.Messaging; @@ -212,6 +213,7 @@ public sealed record ProductGatewayRoutingExample(ProductGatewayRoutingDemoRunne public sealed record OrderTelemetrySidecarExample(OrderTelemetrySidecarDemoRunner Runner, OrderTelemetrySidecarService Service); public sealed record CommerceBackendsForFrontendsExample(CommerceBackendsForFrontendsDemoRunner Runner, CommerceBackendsForFrontendsService Service); public sealed record InventoryAmbassadorExample(InventoryAmbassadorDemoRunner Runner, InventoryAmbassadorService Service); +public sealed record WarehouseLeaderElectionExample(WarehouseLeaderElectionDemoRunner Runner, WarehouseLeaderElectionService Service); /// /// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection. @@ -306,7 +308,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddProductGatewayRoutingExample() .AddOrderTelemetrySidecarExample() .AddCommerceBackendsForFrontendsExample() - .AddInventoryAmbassadorExample(); + .AddInventoryAmbassadorExample() + .AddWarehouseLeaderElectionExample(); public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) { @@ -1089,6 +1092,15 @@ public static IServiceCollection AddInventoryAmbassadorExample(this IServiceColl return services.RegisterExample("Inventory Ambassador", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore); } + public static IServiceCollection AddWarehouseLeaderElectionExample(this IServiceCollection services) + { + services.AddWarehouseLeaderElectionDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Warehouse Leader Election", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + private static IServiceCollection RegisterExample( this IServiceCollection services, string name, diff --git a/src/PatternKit.Examples/LeaderElectionDemo/WarehouseLeaderElectionDemo.cs b/src/PatternKit.Examples/LeaderElectionDemo/WarehouseLeaderElectionDemo.cs new file mode 100644 index 00000000..c1ee04db --- /dev/null +++ b/src/PatternKit.Examples/LeaderElectionDemo/WarehouseLeaderElectionDemo.cs @@ -0,0 +1,101 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using PatternKit.Cloud.LeaderElection; +using PatternKit.Generators.LeaderElection; + +namespace PatternKit.Examples.LeaderElectionDemo; + +public sealed record WarehouseWorkerContext(string NodeId, List Log); + +public sealed class WarehouseLeaderElectionService(LeaderElection election) +{ + public LeaderElectionResult TryLead(WarehouseWorkerContext context) + { + var candidate = GeneratedWarehouseLeaderElection.Create(context); + return election.TryAcquire(candidate); + } + + public LeaderElectionResult Renew(WarehouseWorkerContext context) + => election.Renew(GeneratedWarehouseLeaderElection.Create(context)); + + public LeaderElectionResult Release(WarehouseWorkerContext context) + => election.Release(GeneratedWarehouseLeaderElection.Create(context)); +} + +public sealed class WarehouseLeadershipHostedService(WarehouseLeaderElectionService service) : IHostedService +{ + public WarehouseWorkerContext Context { get; } = new("warehouse-node-a", []); + + public Task StartAsync(CancellationToken cancellationToken) + { + service.TryLead(Context); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + service.Release(Context); + return Task.CompletedTask; + } +} + +public static class WarehouseLeaderElections +{ + public static LeaderElection CreateFluent(Func? clock = null) + => LeaderElection.Create("warehouse-replenishment-leader") + .LeaseDuration(TimeSpan.FromSeconds(30)) + .Clock(clock ?? (() => DateTimeOffset.UtcNow)) + .Build(); +} + +[GenerateLeaderElection(typeof(WarehouseWorkerContext), FactoryMethodName = "Create", ElectionName = "warehouse-replenishment-leader", LeaseDurationMilliseconds = 30000)] +public static partial class GeneratedWarehouseLeaderElection +{ + [LeaderCandidateId] + private static string CandidateId(WarehouseWorkerContext context) => context.NodeId; + + [LeaderAcquired] + private static void Acquired(LeaderLease lease, WarehouseWorkerContext context) + => context.Log.Add($"acquired:{lease.Term}"); + + [LeaderRenewed] + private static void Renewed(LeaderLease lease, WarehouseWorkerContext context) + => context.Log.Add($"renewed:{lease.Term}"); + + [LeaderReleased] + private static void Released(WarehouseWorkerContext context) + => context.Log.Add("released"); +} + +public sealed class WarehouseLeaderElectionDemoRunner(WarehouseLeaderElectionService service) +{ + public IReadOnlyList RunGenerated() + { + var context = new WarehouseWorkerContext("warehouse-node-a", []); + service.TryLead(context); + service.Renew(context); + service.Release(context); + return context.Log; + } + + public static LeaderElectionResult RunFluent() + { + var now = DateTimeOffset.UtcNow; + var election = WarehouseLeaderElections.CreateFluent(() => now); + var context = new WarehouseWorkerContext("warehouse-node-a", []); + var candidate = LeaderElectionCandidate.Create(context.NodeId, context).Build(); + return election.TryAcquire(candidate); + } +} + +public static class WarehouseLeaderElectionServiceCollectionExtensions +{ + public static IServiceCollection AddWarehouseLeaderElectionDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => GeneratedWarehouseLeaderElection.CreateElection()); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 0229fe03..bfe3e33f 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -775,7 +775,15 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog "docs/examples/inventory-ambassador.md", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore, ["Ambassador"], - ["outbound call wrapping", "source-generated ambassador factory", "ASP.NET Core endpoint mapping"]) + ["outbound call wrapping", "source-generated ambassador factory", "ASP.NET Core endpoint mapping"]), + Descriptor( + "Warehouse Leader Election", + "src/PatternKit.Examples/LeaderElectionDemo/WarehouseLeaderElectionDemo.cs", + "test/PatternKit.Examples.Tests/LeaderElectionDemo/WarehouseLeaderElectionDemoTests.cs", + "docs/examples/warehouse-leader-election.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["Leader Election"], + ["single active worker lease", "source-generated candidate factory", "Generic Host hosted service"]) ]; public IReadOnlyList Entries => Items; diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 077280b9..e35704b5 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -987,6 +987,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/AmbassadorDemo/InventoryAmbassadorDemoTests.cs", ["fluent outbound call wrapper", "generated ambassador factory", "DI-importable ASP.NET Core inventory example"]), + Pattern("Leader Election", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/leader-election.md", + "src/PatternKit.Core/Cloud/LeaderElection/LeaderElection.cs", + "test/PatternKit.Tests/Cloud/LeaderElection/LeaderElectionTests.cs", + "docs/generators/leader-election.md", + "src/PatternKit.Generators/LeaderElection/LeaderElectionGenerator.cs", + "test/PatternKit.Generators.Tests/LeaderElectionGeneratorTests.cs", + null, + "docs/examples/warehouse-leader-election.md", + "src/PatternKit.Examples/LeaderElectionDemo/WarehouseLeaderElectionDemo.cs", + "test/PatternKit.Examples.Tests/LeaderElectionDemo/WarehouseLeaderElectionDemoTests.cs", + ["fluent lease election", "generated candidate factory", "DI-importable Generic Host worker example"]), + Pattern("CQRS", PatternFamily.ApplicationArchitecture, "docs/generators/dispatcher.md", "src/PatternKit.Core/Behavioral/Mediator/Mediator.cs", diff --git a/src/PatternKit.Generators.Abstractions/Cloud/LeaderElectionAttributes.cs b/src/PatternKit.Generators.Abstractions/Cloud/LeaderElectionAttributes.cs new file mode 100644 index 00000000..9f60f4e5 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Cloud/LeaderElectionAttributes.cs @@ -0,0 +1,33 @@ +namespace PatternKit.Generators.LeaderElection; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateLeaderElectionAttribute(Type contextType) : Attribute +{ + public Type ContextType { get; } = contextType ?? throw new ArgumentNullException(nameof(contextType)); + + public string FactoryMethodName { get; set; } = "Create"; + + public string ElectionName { get; set; } = "leader-election"; + + public int LeaseDurationMilliseconds { get; set; } = 30000; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class LeaderCandidateIdAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class LeaderAcquiredAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class LeaderRenewedAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class LeaderReleasedAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index ed09f8f4..4d9ac12b 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -314,6 +314,10 @@ PKAMB001 | PatternKit.Generators.Ambassador | Error | Ambassador host must be pa PKAMB002 | PatternKit.Generators.Ambassador | Error | Ambassador members are missing. PKAMB003 | PatternKit.Generators.Ambassador | Error | Ambassador method signature is invalid. PKAMB004 | PatternKit.Generators.Ambassador | Error | Ambassador telemetry is duplicated. +PKLE001 | PatternKit.Generators.LeaderElection | Error | Leader Election host must be partial. +PKLE002 | PatternKit.Generators.LeaderElection | Error | Leader Election members are missing. +PKLE003 | PatternKit.Generators.LeaderElection | Error | Leader Election method signature is invalid. +PKLE004 | PatternKit.Generators.LeaderElection | Error | Leader Election lease duration is invalid. PKGR001 | PatternKit.Generators.GatewayRouting | Error | Gateway Routing host must be partial. PKGR002 | PatternKit.Generators.GatewayRouting | Error | Gateway Routing members are missing. PKGR003 | PatternKit.Generators.GatewayRouting | Error | Gateway Routing method signature is invalid. diff --git a/src/PatternKit.Generators/LeaderElection/LeaderElectionGenerator.cs b/src/PatternKit.Generators/LeaderElection/LeaderElectionGenerator.cs new file mode 100644 index 00000000..c70b8bc2 --- /dev/null +++ b/src/PatternKit.Generators/LeaderElection/LeaderElectionGenerator.cs @@ -0,0 +1,217 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.LeaderElection; + +[Generator] +public sealed class LeaderElectionGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.LeaderElection.GenerateLeaderElectionAttribute"; + private const string CandidateIdAttributeName = "PatternKit.Generators.LeaderElection.LeaderCandidateIdAttribute"; + private const string AcquiredAttributeName = "PatternKit.Generators.LeaderElection.LeaderAcquiredAttribute"; + private const string RenewedAttributeName = "PatternKit.Generators.LeaderElection.LeaderRenewedAttribute"; + private const string ReleasedAttributeName = "PatternKit.Generators.LeaderElection.LeaderReleasedAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKLE001", "Leader Election host must be partial", + "Type '{0}' is marked with [GenerateLeaderElection] but is not declared as partial", + "PatternKit.Generators.LeaderElection", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingMembers = new( + "PKLE002", "Leader Election members are missing", + "Leader Election type '{0}' must declare exactly one candidate id selector", + "PatternKit.Generators.LeaderElection", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidMember = new( + "PKLE003", "Leader Election method signature is invalid", + "Leader Election method '{0}' has an invalid static signature for the configured context type", + "PatternKit.Generators.LeaderElection", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidLease = new( + "PKLE004", "Leader Election lease duration is invalid", + "Leader Election '{0}' must have LeaseDurationMilliseconds > 0", + "PatternKit.Generators.LeaderElection", DiagnosticSeverity.Error, true); + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => a.AttributeClass?.ToDisplayString() == AttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var contextType = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + if (contextType is null) + return; + + var leaseMs = GetNamedInt(attribute, "LeaseDurationMilliseconds") ?? 30000; + if (leaseMs <= 0) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidLease, node.Identifier.GetLocation(), type.Name)); + return; + } + + var ids = MembersWith(type, CandidateIdAttributeName); + var acquired = MembersWith(type, AcquiredAttributeName); + var renewed = MembersWith(type, RenewedAttributeName); + var released = MembersWith(type, ReleasedAttributeName); + if (ids.Length != 1 || acquired.Length > 1 || renewed.Length > 1 || released.Length > 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingMembers, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (!IsCandidateId(ids[0], contextType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, ids[0].Locations.FirstOrDefault(), ids[0].Name)); + return; + } + + var invalidLeaseCallback = acquired.Concat(renewed).FirstOrDefault(method => !IsLeaseCallback(method, contextType)); + if (invalidLeaseCallback is not null) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, invalidLeaseCallback.Locations.FirstOrDefault(), invalidLeaseCallback.Name)); + return; + } + + var invalidRelease = released.FirstOrDefault(method => !IsReleaseCallback(method, contextType)); + if (invalidRelease is not null) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, invalidRelease.Locations.FirstOrDefault(), invalidRelease.Name)); + return; + } + + context.AddSource($"{type.Name}.LeaderElection.g.cs", SourceText.From(GenerateSource( + type, + contextType, + ids[0].Name, + acquired.FirstOrDefault()?.Name, + renewed.FirstOrDefault()?.Name, + released.FirstOrDefault()?.Name, + GetNamedString(attribute, "FactoryMethodName") ?? "Create", + GetNamedString(attribute, "ElectionName") ?? "leader-election", + leaseMs), Encoding.UTF8)); + } + + private static IMethodSymbol[] MembersWith(INamedTypeSymbol type, string attributeName) + => type.GetMembers().OfType() + .Where(method => method.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == attributeName)) + .ToArray(); + + private static bool IsCandidateId(IMethodSymbol method, INamedTypeSymbol contextType) + => method.IsStatic && + method.ReturnType.SpecialType == SpecialType.System_String && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, contextType); + + private static bool IsLeaseCallback(IMethodSymbol method, INamedTypeSymbol contextType) + => method.IsStatic && + method.ReturnsVoid && + method.Parameters.Length == 2 && + method.Parameters[0].Type.ToDisplayString() == "PatternKit.Cloud.LeaderElection.LeaderLease" && + SymbolEqualityComparer.Default.Equals(method.Parameters[1].Type, contextType); + + private static bool IsReleaseCallback(IMethodSymbol method, INamedTypeSymbol contextType) + => method.IsStatic && + method.ReturnsVoid && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, contextType); + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol contextType, + string candidateIdName, + string? acquiredName, + string? renewedName, + string? releasedName, + string factoryMethodName, + string electionName, + int leaseDurationMilliseconds) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var contextTypeName = contextType.ToDisplayString(TypeFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Cloud.LeaderElection.LeaderElection<").Append(contextTypeName).Append("> ").Append(factoryMethodName).AppendLine("Election()"); + sb.AppendLine(" {"); + sb.Append(" return global::PatternKit.Cloud.LeaderElection.LeaderElection<").Append(contextTypeName).Append(">.Create(\"").Append(Escape(electionName)).AppendLine("\")"); + sb.Append(" .LeaseDuration(global::System.TimeSpan.FromMilliseconds(").Append(leaseDurationMilliseconds).AppendLine("))"); + sb.AppendLine(" .Build();"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.Append(" public static global::PatternKit.Cloud.LeaderElection.LeaderElectionCandidate<").Append(contextTypeName).Append("> ").Append(factoryMethodName).Append('(').Append(contextTypeName).AppendLine(" context)"); + sb.AppendLine(" {"); + sb.Append(" return global::PatternKit.Cloud.LeaderElection.LeaderElectionCandidate.Create(").Append(candidateIdName).AppendLine("(context), context)"); + if (acquiredName is not null) + sb.Append(" .OnAcquired(").Append(acquiredName).AppendLine(")"); + if (renewedName is not null) + sb.Append(" .OnRenewed(").Append(renewedName).AppendLine(")"); + if (releasedName is not null) + sb.Append(" .OnReleased(").Append(releasedName).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static int? GetNamedInt(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as int?; + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; +} diff --git a/test/PatternKit.Examples.Tests/LeaderElectionDemo/WarehouseLeaderElectionDemoTests.cs b/test/PatternKit.Examples.Tests/LeaderElectionDemo/WarehouseLeaderElectionDemoTests.cs new file mode 100644 index 00000000..a5f61202 --- /dev/null +++ b/test/PatternKit.Examples.Tests/LeaderElectionDemo/WarehouseLeaderElectionDemoTests.cs @@ -0,0 +1,102 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.LeaderElectionDemo; +using PatternKit.Examples.ProductionReadiness; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.LeaderElectionDemo; + +[Feature("Warehouse Leader Election demo")] +public sealed class WarehouseLeaderElectionDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent leader election acquires warehouse leadership")] + [Fact] + public Task Fluent_Leader_Election_Acquires_Warehouse_Leadership() + => Given("a fluent warehouse leader election", WarehouseLeaderElectionDemoRunner.RunFluent) + .Then("the candidate becomes leader", result => + { + ScenarioExpect.True(result.Acquired); + ScenarioExpect.Equal("warehouse-node-a", result.CandidateId); + }) + .AssertPassed(); + + [Scenario("Generated leader election is importable through IServiceCollection")] + [Fact] + public Task Generated_Leader_Election_Is_Importable_Through_IServiceCollection() + => Given("a service provider configured with the warehouse leader election demo", () => + { + var services = new ServiceCollection(); + services.AddWarehouseLeaderElectionDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("the demo runner resolves and runs", provider => + { + using (provider) + return provider.GetRequiredService().RunGenerated(); + }) + .Then("leadership callbacks are executed", log => + ScenarioExpect.Equal(["acquired:1", "renewed:1", "released"], log)) + .AssertPassed(); + + [Scenario("Hosted service participates in leadership lifecycle")] + [Fact] + public Task Hosted_Service_Participates_In_Leadership_Lifecycle() + => Given("a hosted warehouse leader service", () => + { + var services = new ServiceCollection(); + services.AddWarehouseLeaderElectionDemo(); + var provider = services.BuildServiceProvider(validateScopes: true); + return new { Provider = provider, Hosted = provider.GetServices().OfType().Single() }; + }) + .When("the host starts and stops the service", ctx => RunHostedLifecycle(ctx.Provider, ctx.Hosted)) + .Then("leadership is acquired and released", log => + ScenarioExpect.Equal(["acquired:1", "released"], log)) + .AssertPassed(); + + [Scenario("Warehouse leader election appears in production catalogs")] + [Fact] + public Task Warehouse_Leader_Election_Appears_In_Production_Catalogs() + => Given("the production catalogs", () => new + { + Examples = new PatternKitExampleCatalog(), + Patterns = new PatternKitPatternCatalog() + }) + .Then("the example catalog includes the leader election demo", ctx => + ScenarioExpect.Contains(ctx.Examples.Entries, entry => entry.Name == "Warehouse Leader Election")) + .And("the pattern catalog includes Leader Election", ctx => + ScenarioExpect.Contains(ctx.Patterns.Patterns, pattern => pattern.Name == "Leader Election")) + .AssertPassed(); + + [Scenario("Aggregate example registration includes warehouse leader election")] + [Fact] + public Task Aggregate_Example_Registration_Includes_Warehouse_Leader_Election() + => Given("all PatternKit examples registered in a service collection", () => + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("the leader election example is resolved", provider => + { + using (provider) + return provider.GetRequiredService().Runner.RunGenerated(); + }) + .Then("the registered example executes leadership callbacks", log => + ScenarioExpect.Equal(["acquired:1", "renewed:1", "released"], log)) + .AssertPassed(); + + private static async Task RunHostedLifecycle( + ServiceProvider provider, + WarehouseLeadershipHostedService hosted) + { + using (provider) + { + await hosted.StartAsync(CancellationToken.None); + await hosted.StopAsync(CancellationToken.None); + return hosted.Context.Log.ToArray(); + } + } +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 2618cc49..8b8cfc39 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -86,6 +86,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Sidecar", "Backends for Frontends", "Ambassador", + "Leader Election", "CQRS", "Specification", "Repository", @@ -143,7 +144,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() { ScenarioExpect.Equal(30, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); - ScenarioExpect.Equal(15, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); + ScenarioExpect.Equal(16, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(15, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 8255090e..00c1cd51 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -25,6 +25,7 @@ using PatternKit.Generators.Interpreter; using PatternKit.Generators.Iterator; using PatternKit.Generators.HealthEndpointMonitoring; +using PatternKit.Generators.LeaderElection; using PatternKit.Generators.MaterializedViews; using PatternKit.Generators.Messaging; using PatternKit.Generators.Observer; @@ -239,6 +240,11 @@ private enum TestTrigger { typeof(AmbassadorTelemetryAttribute), AttributeTargets.Method, false, false }, { typeof(AmbassadorCallAttribute), AttributeTargets.Method, false, false }, { typeof(AmbassadorFallbackAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateLeaderElectionAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(LeaderCandidateIdAttribute), AttributeTargets.Method, false, false }, + { typeof(LeaderAcquiredAttribute), AttributeTargets.Method, false, false }, + { typeof(LeaderRenewedAttribute), AttributeTargets.Method, false, false }, + { typeof(LeaderReleasedAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateGatewayRoutingAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GatewayRouteAttribute), AttributeTargets.Method, false, false }, { typeof(GatewayRouteHandlerAttribute), AttributeTargets.Method, false, false }, @@ -629,6 +635,28 @@ public void Ambassador_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.IsType(new AmbassadorFallbackAttribute()); } + [Scenario("Leader Election Attributes Expose Defaults And Configuration")] + [Fact] + public void Leader_Election_Attributes_Expose_Defaults_And_Configuration() + { + var leader = new GenerateLeaderElectionAttribute(typeof(string)) + { + FactoryMethodName = "BuildLeader", + ElectionName = "orders-leader", + LeaseDurationMilliseconds = 5000 + }; + + ScenarioExpect.Equal(typeof(string), leader.ContextType); + ScenarioExpect.Equal("BuildLeader", leader.FactoryMethodName); + ScenarioExpect.Equal("orders-leader", leader.ElectionName); + ScenarioExpect.Equal(5000, leader.LeaseDurationMilliseconds); + ScenarioExpect.Throws(() => new GenerateLeaderElectionAttribute(null!)); + ScenarioExpect.IsType(new LeaderCandidateIdAttribute()); + ScenarioExpect.IsType(new LeaderAcquiredAttribute()); + ScenarioExpect.IsType(new LeaderRenewedAttribute()); + ScenarioExpect.IsType(new LeaderReleasedAttribute()); + } + [Scenario("Strangler Fig Attributes Expose Defaults And Configuration")] [Fact] public void StranglerFig_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/LeaderElectionGeneratorTests.cs b/test/PatternKit.Generators.Tests/LeaderElectionGeneratorTests.cs new file mode 100644 index 00000000..6b00c0f1 --- /dev/null +++ b/test/PatternKit.Generators.Tests/LeaderElectionGeneratorTests.cs @@ -0,0 +1,106 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Cloud.LeaderElection; +using PatternKit.Generators.LeaderElection; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Leader Election generator")] +public sealed partial class LeaderElectionGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates leader election factories")] + [Fact] + public Task Generates_Leader_Election_Factories() + => Given("a leader election declaration", () => Compile(""" + using PatternKit.Cloud.LeaderElection; + using PatternKit.Generators.LeaderElection; + namespace Demo; + public sealed record WorkerContext(string NodeId); + [GenerateLeaderElection(typeof(WorkerContext), FactoryMethodName = "Build", ElectionName = "orders-leader", LeaseDurationMilliseconds = 5000)] + public static partial class OrdersLeader + { + [LeaderCandidateId] + private static string CandidateId(WorkerContext context) => context.NodeId; + [LeaderAcquired] + private static void Acquired(LeaderLease lease, WorkerContext context) { } + [LeaderRenewed] + private static void Renewed(LeaderLease lease, WorkerContext context) { } + [LeaderReleased] + private static void Released(WorkerContext context) { } + } + """)) + .Then("the generated source creates election and candidate factories", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("BuildElection()", source); + ScenarioExpect.Contains("LeaderElection.Create(\"orders-leader\")", source); + ScenarioExpect.Contains(".LeaseDuration(global::System.TimeSpan.FromMilliseconds(5000))", source); + ScenarioExpect.Contains("Build(global::Demo.WorkerContext context)", source); + ScenarioExpect.Contains("LeaderElectionCandidate.Create(CandidateId(context), context)", source); + ScenarioExpect.Contains(".OnAcquired(Acquired)", source); + ScenarioExpect.Contains(".OnRenewed(Renewed)", source); + ScenarioExpect.Contains(".OnReleased(Released)", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid leader election declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Leader_Election_Declarations() + => Given("invalid leader election declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.LeaderElection; + [GenerateLeaderElection(typeof(string))] + public static class LeaderHost; + """), + Compile(""" + using PatternKit.Generators.LeaderElection; + [GenerateLeaderElection(typeof(string))] + public static partial class LeaderHost; + """), + Compile(""" + using PatternKit.Generators.LeaderElection; + [GenerateLeaderElection(typeof(string))] + public static partial class LeaderHost + { + [LeaderCandidateId] + private static int CandidateId(string value) => 1; + } + """), + Compile(""" + using PatternKit.Generators.LeaderElection; + [GenerateLeaderElection(typeof(string), LeaseDurationMilliseconds = 0)] + public static partial class LeaderHost + { + [LeaderCandidateId] + private static string CandidateId(string value) => value; + } + """) + }) + .Then("diagnostics identify invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKLE001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKLE002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKLE003"); + ScenarioExpect.Contains(results[3].Diagnostics, diagnostic => diagnostic.Id == "PKLE004"); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "LeaderElectionGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(LeaderElection<>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new LeaderElectionGenerator(), out var run, out var updated); + var result = run.Results.Single(); + var emit = updated.Emit(Stream.Null); + return new(result.Diagnostics.ToArray(), result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray(), emit.Success, emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray()); + } + + private sealed record GeneratorResult(IReadOnlyList Diagnostics, IReadOnlyList GeneratedSources, bool EmitSuccess, IReadOnlyList EmitDiagnostics); +} diff --git a/test/PatternKit.Tests/Cloud/LeaderElection/LeaderElectionTests.cs b/test/PatternKit.Tests/Cloud/LeaderElection/LeaderElectionTests.cs new file mode 100644 index 00000000..e4e7499b --- /dev/null +++ b/test/PatternKit.Tests/Cloud/LeaderElection/LeaderElectionTests.cs @@ -0,0 +1,133 @@ +using PatternKit.Cloud.LeaderElection; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Cloud.LeaderElection; + +[Feature("Leader Election")] +public sealed class LeaderElectionTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Leader election acquires renews and releases leases")] + [Fact] + public Task Leader_Election_Acquires_Renews_And_Releases_Leases() + => Given("a leader election with a candidate", () => + { + var now = DateTimeOffset.Parse("2026-05-22T00:00:00Z"); + var log = new List(); + var election = LeaderElection.Create("orders-leader") + .LeaseDuration(TimeSpan.FromSeconds(5)) + .Clock(() => now) + .Build(); + var candidate = Candidate("node-a", log); + return new { Election = election, Candidate = candidate, Log = log }; + }) + .When("the candidate acquires renews and releases leadership", ctx => new + { + Acquired = ctx.Election.TryAcquire(ctx.Candidate), + Renewed = ctx.Election.Renew(ctx.Candidate), + Released = ctx.Election.Release(ctx.Candidate), + ctx.Log, + ctx.Election + }) + .Then("each transition succeeds and callbacks run", result => + { + ScenarioExpect.True(result.Acquired.Acquired); + ScenarioExpect.Equal(1L, result.Acquired.Lease!.Term); + ScenarioExpect.True(result.Renewed.Renewed); + ScenarioExpect.True(result.Released.Released); + ScenarioExpect.Null(result.Election.CurrentLease); + ScenarioExpect.Equal(["acquired:1", "renewed:1", "released"], result.Log); + }) + .AssertPassed(); + + [Scenario("Leader election handles contention and expiry")] + [Fact] + public Task Leader_Election_Handles_Contention_And_Expiry() + => Given("a leader election with two candidates", () => + { + var now = DateTimeOffset.Parse("2026-05-22T00:00:00Z"); + var election = LeaderElection.Create("orders-leader") + .LeaseDuration(TimeSpan.FromSeconds(1)) + .Clock(() => now) + .Build(); + return new + { + Election = election, + Advance = new Action(delta => now = now.Add(delta)), + Leader = Candidate("node-a"), + Follower = Candidate("node-b") + }; + }) + .When("a second candidate contends before and after expiry", ctx => + { + var first = ctx.Election.TryAcquire(ctx.Leader); + var blocked = ctx.Election.TryAcquire(ctx.Follower); + ctx.Advance(TimeSpan.FromSeconds(2)); + var afterExpiry = ctx.Election.TryAcquire(ctx.Follower); + return new { first, blocked, afterExpiry, ctx.Election }; + }) + .Then("contention fails until the lease expires", result => + { + ScenarioExpect.True(result.first.Acquired); + ScenarioExpect.True(result.blocked.Failed); + ScenarioExpect.Contains("node-a", result.blocked.Exception!.Message); + ScenarioExpect.True(result.afterExpiry.Acquired); + ScenarioExpect.Equal("node-b", result.Election.CurrentLease!.CandidateId); + }) + .AssertPassed(); + + [Scenario("Leader election validates configuration and failures")] + [Fact] + public Task Leader_Election_Validates_Configuration_And_Failures() + => Given("invalid leader election inputs", () => true) + .Then("invalid election configuration is rejected", _ => + { + ScenarioExpect.Throws(() => LeaderElection.Create("").Build()); + ScenarioExpect.Throws(() => LeaderElection.Create().LeaseDuration(TimeSpan.Zero).Build()); + ScenarioExpect.Throws(() => LeaderElection.Create().Clock(null!)); + }) + .And("invalid candidate configuration is rejected", _ => + { + ScenarioExpect.Throws(() => LeaderElectionCandidate.Create("", new CandidateState([]))); + ScenarioExpect.Throws(() => LeaderElectionCandidate.Create("node-a", (CandidateState)null!)); + ScenarioExpect.Throws(() => LeaderElectionCandidate.Create("node-a", new CandidateState([])).OnAcquired(null!)); + ScenarioExpect.Throws(() => LeaderElectionCandidate.Create("node-a", new CandidateState([])).OnRenewed(null!)); + ScenarioExpect.Throws(() => LeaderElectionCandidate.Create("node-a", new CandidateState([])).OnReleased(null!)); + }) + .And("renew and release fail when candidate is not leader", _ => + { + var election = LeaderElection.Create("orders-leader").Build(); + var leader = Candidate("node-a"); + var follower = Candidate("node-b"); + ScenarioExpect.True(election.Renew(leader).Failed); + ScenarioExpect.True(election.Release(leader).Failed); + ScenarioExpect.True(election.TryAcquire(leader).Acquired); + ScenarioExpect.True(election.Renew(follower).Failed); + ScenarioExpect.True(election.Release(follower).Failed); + }) + .And("null candidates and blank ids are guarded", _ => + { + var election = LeaderElection.Create().Build(); + ScenarioExpect.Throws(() => election.TryAcquire(null!)); + ScenarioExpect.Throws(() => election.Renew(null!)); + ScenarioExpect.Throws(() => election.Release(null!)); + ScenarioExpect.Throws(() => election.IsLeader("")); + }) + .And("result factories guard required values", _ => + { + ScenarioExpect.Throws(() => LeaderElectionResult.Acquisition("orders", "node", null!)); + ScenarioExpect.Throws(() => LeaderElectionResult.Renewal("orders", "node", null!)); + ScenarioExpect.Throws(() => LeaderElectionResult.Failure("orders", "node", null!)); + }) + .AssertPassed(); + + private static LeaderElectionCandidate Candidate(string id, List? log = null) + => LeaderElectionCandidate.Create(id, new CandidateState(log ?? [])) + .OnAcquired(static (lease, state) => state.Log.Add($"acquired:{lease.Term}")) + .OnRenewed(static (lease, state) => state.Log.Add($"renewed:{lease.Term}")) + .OnReleased(static state => state.Log.Add("released")) + .Build(); + + private sealed record CandidateState(List Log); +}