10 KiB
MaksIT.HAMode
Reusable high-availability runtime coordination library for MaksIT services.
Packages
MaksIT.HAMode(single NuGet package, single assembly)MaksIT.HAMode.Extensions.ServiceCollectionExtensionsMaksIT.HAMode.Abstractionsnamespace:IRuntimeInstanceIdIRuntimeLeaseServiceIRuntimeLeaseConnectionProvider(root marker interface)IRuntimeLeaseConnectionStringProviderIRuntimeLeaseRedisConnectionProviderIRuntimeLeaseEtcdConnectionProviderRuntimeInstanceIdProvider
MaksIT.HAMode.PostgreSql.RuntimeLeaseServiceNpgsqlMaksIT.HAMode.Redis.RuntimeLeaseServiceRedisMaksIT.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.SchemaandIRuntimeLeaseConnectionStringProvider.Table- defaults:
public.app_runtime_leases
- defaults:
- 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
<ItemGroup>
<PackageReference Include="MaksIT.HAMode" />
</ItemGroup>
Shared runtime instance id
using MaksIT.HAMode.Extensions;
builder.Services.AddHAModeRuntimeInstanceId();
PostgreSQL backend
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 = "<your-connection-string>"
};
builder.Services.AddHAModePostgreSql(pgConfiguration);
If you already manage a pooled PostgreSQL client in the host, pass the shared NpgsqlDataSource:
var dataSource = new NpgsqlDataSourceBuilder("<your-connection-string>").Build();
builder.Services.AddHAModePostgreSql(pgConfiguration, dataSource);
Redis backend
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 = "<your-redis-connection-string>"
};
builder.Services.AddHAModeRedis(redisConfiguration);
If you already manage a shared Redis client in the host, pass the same IConnectionMultiplexer:
var multiplexer = await ConnectionMultiplexer.ConnectAsync("<your-redis-connection-string>");
builder.Services.AddHAModeRedis(redisConfiguration, multiplexer);
Redis is schema-less, so there is no table bootstrap requirement; lease keys are isolated by KeyPrefix.
etcd backend
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:
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
using MaksIT.HAMode.Abstractions;
public sealed class BootstrapHostedService(
IRuntimeLeaseService leaseService,
IRuntimeInstanceId runtimeInstance,
ILogger<BootstrapHostedService> 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:
<ItemGroup>
<PackageReference Include="MaksIT.HAMode" />
</ItemGroup>
Switching backend uses a different service registration (no package change needed).
2) Keep Vault interfaces stable with adapters
// Vault Engine contract remains unchanged for consumers.
using SharedRuntimeLeaseService = MaksIT.HAMode.Abstractions.IRuntimeLeaseService;
namespace MaksIT.Vault.Engine.Infrastructure;
public interface IRuntimeLeaseService : SharedRuntimeLeaseService;
// 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
using MaksIT.HAMode.Abstractions;
using SharedRuntimeLeaseServiceNpgsql = MaksIT.HAMode.PostgreSql.RuntimeLeaseServiceNpgsql;
public sealed class RuntimeLeaseServiceNpgsql(
IVaultEngineConfiguration config,
ILogger<SharedRuntimeLeaseServiceNpgsql> 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<MaksIT.Results.Result<bool>> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken ct = default) =>
_inner.TryAcquireAsync(leaseName, holderId, ttl, ct);
public Task<MaksIT.Results.Result> ReleaseAsync(string leaseName, string holderId, CancellationToken ct = default) =>
_inner.ReleaseAsync(leaseName, holderId, ct);
}
4) DI registration in Program.cs
builder.Services.AddSingleton<MaksIT.Vault.Engine.RuntimeCoordination.IRuntimeInstanceId, RuntimeInstanceIdProvider>();
builder.Services.AddSingleton<MaksIT.Vault.Engine.Infrastructure.IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>();
Integration in MaksIT.CertsUI
The same pattern applies to MaksIT.CertsUI.Engine.
1) Add package reference
In MaksIT.CertsUI.Engine.csproj:
<ItemGroup>
<PackageReference Include="MaksIT.HAMode" />
</ItemGroup>
2) Keep CertsUI interfaces stable with adapters
using SharedRuntimeLeaseService = MaksIT.HAMode.Abstractions.IRuntimeLeaseService;
namespace MaksIT.CertsUI.Engine.Infrastructure;
public interface IRuntimeLeaseService : SharedRuntimeLeaseService;
namespace MaksIT.CertsUI.Engine.RuntimeCoordination;
public interface IRuntimeInstanceId : MaksIT.HAMode.Abstractions.IRuntimeInstanceId;
3) Adapter for CertsUI configuration
using MaksIT.HAMode.Abstractions;
using SharedRuntimeLeaseServiceNpgsql = MaksIT.HAMode.PostgreSql.RuntimeLeaseServiceNpgsql;
public sealed class RuntimeLeaseServiceNpgsql(
ICertsEngineConfiguration config,
ILogger<SharedRuntimeLeaseServiceNpgsql> 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<MaksIT.Results.Result<bool>> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken ct = default) =>
_inner.TryAcquireAsync(leaseName, holderId, ttl, ct);
public Task<MaksIT.Results.Result> ReleaseAsync(string leaseName, string holderId, CancellationToken ct = default) =>
_inner.ReleaseAsync(leaseName, holderId, ct);
}
4) DI registration in Program.cs
builder.Services.AddSingleton<MaksIT.CertsUI.Engine.RuntimeCoordination.IRuntimeInstanceId, RuntimeInstanceIdProvider>();
builder.Services.AddSingleton<MaksIT.CertsUI.Engine.Infrastructure.IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>();
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
dotnet pack .\src\MaksIT.HAMode.slnx -c Release
The command emits a single MaksIT.HAMode .nupkg and .snupkg.