# MaksIT.HAMode
Reusable high-availability runtime coordination library for MaksIT services.
  
## Packages
- `MaksIT.HAMode` (single NuGet package)
- `MaksIT.HAMode.Extensions.ServiceCollectionExtensions`
- `MaksIT.HAMode.Abstractions` namespace:
- `IRuntimeInstanceId`
- `IRuntimeLeaseService`
- `IRuntimeLeaseConnectionProvider` (root marker interface)
- `IRuntimeLeaseConnectionStringProvider`
- `IRuntimeLeaseRedisConnectionProvider`
- `IRuntimeLeaseEtcdConnectionProvider`
- `RuntimeInstanceIdProvider`
- `MaksIT.HAMode.PostgreSql.RuntimeLeaseServiceNpgsql`
- `MaksIT.HAMode.Redis.RuntimeLeaseServiceRedis`
- `MaksIT.HAMode.Etcd.RuntimeLeaseServiceEtcd`
## Target framework
- `net10.0`
## Design goals
- Keep runtime coordination reusable across products.
- Keep application configuration ownership in host projects.
- Keep lease persistence implementation swappable and SOLID-compliant.
## PostgreSQL lease table contract
`RuntimeLeaseServiceNpgsql` expects:
- table: configurable via `IRuntimeLeaseConnectionStringProvider.Schema` and `IRuntimeLeaseConnectionStringProvider.Table`
- defaults: `public.app_runtime_leases`
- columns:
- `lease_name` (text, PK)
- `holder_id` (text)
- `version` (bigint)
- `acquired_at_utc` (timestamptz)
- `expires_at_utc` (timestamptz)
If the configured table does not exist, PostgreSQL lease operations return an explicit internal error explaining which table is missing.
## Usage examples
### Install package
```xml
```
### Shared runtime instance id
```csharp
using MaksIT.HAMode.Extensions;
builder.Services.AddHAModeRuntimeInstanceId();
```
### PostgreSQL backend
```csharp
using MaksIT.HAMode.Abstractions;
using MaksIT.HAMode.Extensions;
// Host project contract.
public interface IMyPgLeaseConfiguration : IRuntimeLeaseConnectionStringProvider;
// Host project concrete configuration.
public sealed class MyPgLeaseConfiguration : IMyPgLeaseConfiguration {
public required string ConnectionString { get; init; }
public string Schema { get; init; } = "ha";
public string Table { get; init; } = "runtime_leases";
}
IMyPgLeaseConfiguration pgConfiguration = new MyPgLeaseConfiguration {
ConnectionString = ""
};
builder.Services.AddHAModePostgreSql(pgConfiguration);
```
If you already manage a pooled PostgreSQL client in the host, pass the shared `NpgsqlDataSource`:
```csharp
var dataSource = new NpgsqlDataSourceBuilder("").Build();
builder.Services.AddHAModePostgreSql(pgConfiguration, dataSource);
```
### Redis backend
```csharp
using MaksIT.HAMode.Abstractions;
using MaksIT.HAMode.Extensions;
// Host project contract.
public interface IMyRedisLeaseConfiguration : IRuntimeLeaseRedisConnectionProvider;
// Host project concrete configuration.
public sealed class MyRedisLeaseConfiguration : IMyRedisLeaseConfiguration {
public required string Configuration { get; init; }
public string KeyPrefix { get; init; } = "my-app/runtime-leases:";
}
IMyRedisLeaseConfiguration redisConfiguration = new MyRedisLeaseConfiguration {
Configuration = ""
};
builder.Services.AddHAModeRedis(redisConfiguration);
```
If you already manage a shared Redis client in the host, pass the same `IConnectionMultiplexer`:
```csharp
var multiplexer = await ConnectionMultiplexer.ConnectAsync("");
builder.Services.AddHAModeRedis(redisConfiguration, multiplexer);
```
Redis is schema-less, so there is no table bootstrap requirement; lease keys are isolated by `KeyPrefix`.
### etcd backend
```csharp
using MaksIT.HAMode.Abstractions;
using MaksIT.HAMode.Extensions;
// Host project contract.
public interface IMyEtcdLeaseConfiguration : IRuntimeLeaseEtcdConnectionProvider;
// Host project concrete configuration.
public sealed class MyEtcdLeaseConfiguration : IMyEtcdLeaseConfiguration {
public required string Endpoints { get; init; } // ex: http://etcd:2379
public string? Username { get; init; }
public string? Password { get; init; }
public string KeyPrefix { get; init; } = "my-app/runtime-leases/";
}
IMyEtcdLeaseConfiguration etcdConfiguration = new MyEtcdLeaseConfiguration {
Endpoints = "http://etcd:2379",
Username = null,
Password = null
};
builder.Services.AddHAModeEtcd(etcdConfiguration);
```
If you already manage a shared etcd client in the host, pass the same `EtcdClient`:
```csharp
var etcdClient = new EtcdClient("http://etcd:2379");
builder.Services.AddHAModeEtcd(etcdConfiguration, etcdClient);
```
etcd is key-space based, so there is no table bootstrap requirement; lease keys are isolated by `KeyPrefix`.
### Runtime acquire/release flow
```csharp
using MaksIT.HAMode.Abstractions;
public sealed class BootstrapHostedService(
IRuntimeLeaseService leaseService,
IRuntimeInstanceId runtimeInstance,
ILogger logger
) : IHostedService {
public async Task StartAsync(CancellationToken cancellationToken) {
var holder = runtimeInstance.InstanceId;
var acquired = await leaseService.TryAcquireAsync(
leaseName: "my-app-bootstrap",
holderId: holder,
ttl: TimeSpan.FromSeconds(30),
cancellationToken: cancellationToken);
if (!acquired.IsSuccess || !acquired.Value) {
logger.LogInformation("Another replica owns bootstrap lease.");
return;
}
try {
// Run single-replica bootstrap logic here.
}
finally {
await leaseService.ReleaseAsync("my-app-bootstrap", holder, CancellationToken.None);
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
```
## Integration in MaksIT.Vault
### 1) Add package reference
In `MaksIT.Vault.Engine.csproj`:
```xml
```
Switching backend uses a different service registration (no package change needed).
### 2) Keep Vault interfaces stable with adapters
```csharp
// Vault Engine contract remains unchanged for consumers.
using SharedRuntimeLeaseService = MaksIT.HAMode.Abstractions.IRuntimeLeaseService;
namespace MaksIT.Vault.Engine.Infrastructure;
public interface IRuntimeLeaseService : SharedRuntimeLeaseService;
```
```csharp
// Vault host contract remains unchanged for controllers/hosted services.
namespace MaksIT.Vault.Engine.RuntimeCoordination;
public interface IRuntimeInstanceId : MaksIT.HAMode.Abstractions.IRuntimeInstanceId;
```
### 3) Adapter for connection/config ownership
```csharp
using MaksIT.HAMode.Abstractions;
using SharedRuntimeLeaseServiceNpgsql = MaksIT.HAMode.PostgreSql.RuntimeLeaseServiceNpgsql;
public sealed class RuntimeLeaseServiceNpgsql(
IVaultEngineConfiguration config,
ILogger logger
) : IRuntimeLeaseService {
private sealed class VaultLeaseConnection(IVaultEngineConfiguration cfg) : IRuntimeLeaseConnectionStringProvider {
public string ConnectionString => cfg.ConnectionString;
}
private readonly SharedRuntimeLeaseServiceNpgsql _inner = new(new VaultLeaseConnection(config), logger);
public Task> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken ct = default) =>
_inner.TryAcquireAsync(leaseName, holderId, ttl, ct);
public Task ReleaseAsync(string leaseName, string holderId, CancellationToken ct = default) =>
_inner.ReleaseAsync(leaseName, holderId, ct);
}
```
### 4) DI registration in `Program.cs`
```csharp
builder.Services.AddSingleton();
builder.Services.AddSingleton();
```
## Integration in MaksIT.CertsUI
The same pattern applies to `MaksIT.CertsUI.Engine`.
### 1) Add package reference
In `MaksIT.CertsUI.Engine.csproj`:
```xml
```
### 2) Keep CertsUI interfaces stable with adapters
```csharp
using SharedRuntimeLeaseService = MaksIT.HAMode.Abstractions.IRuntimeLeaseService;
namespace MaksIT.CertsUI.Engine.Infrastructure;
public interface IRuntimeLeaseService : SharedRuntimeLeaseService;
```
```csharp
namespace MaksIT.CertsUI.Engine.RuntimeCoordination;
public interface IRuntimeInstanceId : MaksIT.HAMode.Abstractions.IRuntimeInstanceId;
```
### 3) Adapter for CertsUI configuration
```csharp
using MaksIT.HAMode.Abstractions;
using SharedRuntimeLeaseServiceNpgsql = MaksIT.HAMode.PostgreSql.RuntimeLeaseServiceNpgsql;
public sealed class RuntimeLeaseServiceNpgsql(
ICertsEngineConfiguration config,
ILogger logger
) : IRuntimeLeaseService {
private sealed class CertsLeaseConnection(ICertsEngineConfiguration cfg) : IRuntimeLeaseConnectionStringProvider {
public string ConnectionString => cfg.ConnectionString;
}
private readonly SharedRuntimeLeaseServiceNpgsql _inner = new(new CertsLeaseConnection(config), logger);
public Task> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken ct = default) =>
_inner.TryAcquireAsync(leaseName, holderId, ttl, ct);
public Task ReleaseAsync(string leaseName, string holderId, CancellationToken ct = default) =>
_inner.ReleaseAsync(leaseName, holderId, ct);
}
```
### 4) DI registration in `Program.cs`
```csharp
builder.Services.AddSingleton();
builder.Services.AddSingleton();
```
## Backend switching strategy
Use the same app-level interface (`IRuntimeLeaseService`) and swap only adapter implementation:
- PostgreSQL: `MaksIT.HAMode.PostgreSql.RuntimeLeaseServiceNpgsql`
- Redis: `MaksIT.HAMode.Redis.RuntimeLeaseServiceRedis`
- etcd: `MaksIT.HAMode.Etcd.RuntimeLeaseServiceEtcd`
This keeps hosted services and domain workflows unchanged in both `maksit-vault` and `maksit-certs-ui`.
## Local pack
```powershell
dotnet pack .\src\MaksIT.HAMode.slnx -c Release
```
The command emits a single `MaksIT.HAMode` `.nupkg` and `.snupkg`.