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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,6 @@

- name: Inventory Ambassador
href: inventory-ambassador.md

- name: Warehouse Leader Election
href: warehouse-leader-election.md
12 changes: 12 additions & 0 deletions docs/examples/warehouse-leader-election.md
Original file line number Diff line number Diff line change
@@ -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<WarehouseLeaderElectionDemoRunner>();
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.
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 22 additions & 0 deletions docs/generators/leader-election.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Leader Election Generator

`[GenerateLeaderElection]` creates a typed `LeaderElection<TContext>` 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.
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/guides/pattern-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Cloud Architecture | Sidecar | `Sidecar<TRequest,TResponse>` | Sidecar generator |
| Cloud Architecture | Backends for Frontends | `BackendsForFrontends<TRequest,TResponse>` | Backends for Frontends generator |
| Cloud Architecture | Ambassador | `Ambassador<TRequest,TResponse>` | Ambassador generator |
| Cloud Architecture | Leader Election | `LeaderElection<TContext>` | Leader Election generator |
| Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator |
| Application Architecture | Specification | `Specification<T>` and named registries | Specification generator |
| Application Architecture | Repository | `IRepository<TEntity,TKey>` and `InMemoryRepository<TEntity,TKey>` | Repository generator |
Expand Down
22 changes: 22 additions & 0 deletions docs/patterns/cloud/leader-election.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Leader Election

Leader Election coordinates one active worker among several candidates by issuing renewable leases.

```csharp
var election = LeaderElection<WarehouseWorkerContext>
.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()`.
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
230 changes: 230 additions & 0 deletions src/PatternKit.Core/Cloud/LeaderElection/LeaderElection.cs
Original file line number Diff line number Diff line change
@@ -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<TContext>
{
internal LeaderElectionCandidate(string candidateId, TContext context, Action<LeaderLease, TContext>? onAcquired, Action<LeaderLease, TContext>? onRenewed, Action<TContext>? onReleased)
=> (CandidateId, Context, OnAcquired, OnRenewed, OnReleased) = (candidateId, context, onAcquired, onRenewed, onReleased);

public string CandidateId { get; }

public TContext Context { get; }

internal Action<LeaderLease, TContext>? OnAcquired { get; }

internal Action<LeaderLease, TContext>? OnRenewed { get; }

internal Action<TContext>? OnReleased { get; }
}

public sealed class LeaderElection<TContext>
{
private readonly Func<DateTimeOffset> _clock;
private readonly TimeSpan _leaseDuration;
private LeaderLease? _currentLease;

private LeaderElection(string name, TimeSpan leaseDuration, Func<DateTimeOffset>? clock)
Comment on lines +71 to +75
{
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<TContext> 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<TContext> 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<TContext> 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<DateTimeOffset>? _clock;

internal Builder(string name) => _name = name;

public Builder LeaseDuration(TimeSpan leaseDuration)
{
_leaseDuration = leaseDuration;
return this;
}

public Builder Clock(Func<DateTimeOffset> clock)
{
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
return this;
}

public LeaderElection<TContext> Build() => new(_name, _leaseDuration, _clock);
}
}

public static class LeaderElectionCandidate
{
public static Builder<TContext> Create<TContext>(string candidateId, TContext context)
=> new(candidateId, context);

public sealed class Builder<TContext>
{
private readonly string _candidateId;
private readonly TContext _context;
private Action<LeaderLease, TContext>? _onAcquired;
private Action<LeaderLease, TContext>? _onRenewed;
private Action<TContext>? _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<TContext> OnAcquired(Action<LeaderLease, TContext> onAcquired)
{
_onAcquired = onAcquired ?? throw new ArgumentNullException(nameof(onAcquired));
return this;
}

public Builder<TContext> OnRenewed(Action<LeaderLease, TContext> onRenewed)
{
_onRenewed = onRenewed ?? throw new ArgumentNullException(nameof(onRenewed));
return this;
}

public Builder<TContext> OnReleased(Action<TContext> onReleased)
{
_onReleased = onReleased ?? throw new ArgumentNullException(nameof(onReleased));
return this;
}

public LeaderElectionCandidate<TContext> Build()
=> new(_candidateId, _context, _onAcquired, _onRenewed, _onReleased);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

/// <summary>
/// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection.
Expand Down Expand Up @@ -306,7 +308,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddProductGatewayRoutingExample()
.AddOrderTelemetrySidecarExample()
.AddCommerceBackendsForFrontendsExample()
.AddInventoryAmbassadorExample();
.AddInventoryAmbassadorExample()
.AddWarehouseLeaderElectionExample();

public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services)
{
Expand Down Expand Up @@ -1089,6 +1092,15 @@ public static IServiceCollection AddInventoryAmbassadorExample(this IServiceColl
return services.RegisterExample<InventoryAmbassadorExample>("Inventory Ambassador", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore);
}

public static IServiceCollection AddWarehouseLeaderElectionExample(this IServiceCollection services)
{
services.AddWarehouseLeaderElectionDemo();
services.AddSingleton<WarehouseLeaderElectionExample>(sp => new(
sp.GetRequiredService<WarehouseLeaderElectionDemoRunner>(),
sp.GetRequiredService<WarehouseLeaderElectionService>()));
return services.RegisterExample<WarehouseLeaderElectionExample>("Warehouse Leader Election", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

private static IServiceCollection RegisterExample<T>(
this IServiceCollection services,
string name,
Expand Down
Loading
Loading