(feature): support host-owned pg/redis/etcd clients in HAMode registration

This commit is contained in:
Maksym Sadovnychyy 2026-06-20 17:35:04 +02:00
parent 0efd79e567
commit f48e338012
19 changed files with 664 additions and 83 deletions

View File

@ -5,14 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.2] - 2026-06-20
### Changed
- PostgreSQL lease service now uses configurable `Schema` and `Table` from `IRuntimeLeaseConnectionStringProvider` instead of hardcoded `public.app_runtime_leases`.
- Added explicit missing-table error handling for PostgreSQL lease acquire/release operations.
- Added provider-configuration validation for Redis and etcd services (required connection settings and key prefix).
- Added DI overloads to reuse host-managed clients (`NpgsqlDataSource`, `IConnectionMultiplexer`, `EtcdClient`) for lease services.
## [1.0.1] - 2026-06-20 ## [1.0.1] - 2026-06-20
### Added ### Added
- DI registration extensions in `MaksIT.HAMode.Extensions.ServiceCollectionExtensions` for runtime instance id and backend-specific lease service wiring (`PostgreSql`, `Redis`, `Etcd`). - DI registration extensions in `MaksIT.HAMode.Extensions.ServiceCollectionExtensions` for runtime instance id and backend-specific lease service wiring (`PostgreSql`, `Redis`, `Etcd`).
- DI extension overloads that accept concrete configuration instances implementing HAMode configuration interfaces.
- Root connector configuration interface `IRuntimeLeaseConnectionProvider`, with connector-specific interfaces inheriting it.
### Changed ### Changed
- Updated package/release setup to publish `MaksIT.HAMode` as the primary distributable library for version `1.0.1`. - Updated package/release setup to publish `MaksIT.HAMode` as the primary distributable library for version `1.0.1`.
- Updated several dependency versions across HAMode projects. - Updated several dependency versions across HAMode projects.
- Updated README backend examples to use host-defined configuration interfaces and concrete classes, instead of direct `IConfiguration["..."]` access.
## [1.0.0] - 2026-06-20 ## [1.0.0] - 2026-06-20

View File

@ -11,6 +11,7 @@ Reusable high-availability runtime coordination library for MaksIT services.
- `MaksIT.HAMode.Abstractions` namespace: - `MaksIT.HAMode.Abstractions` namespace:
- `IRuntimeInstanceId` - `IRuntimeInstanceId`
- `IRuntimeLeaseService` - `IRuntimeLeaseService`
- `IRuntimeLeaseConnectionProvider` (root marker interface)
- `IRuntimeLeaseConnectionStringProvider` - `IRuntimeLeaseConnectionStringProvider`
- `IRuntimeLeaseRedisConnectionProvider` - `IRuntimeLeaseRedisConnectionProvider`
- `IRuntimeLeaseEtcdConnectionProvider` - `IRuntimeLeaseEtcdConnectionProvider`
@ -33,7 +34,8 @@ Reusable high-availability runtime coordination library for MaksIT services.
`RuntimeLeaseServiceNpgsql` expects: `RuntimeLeaseServiceNpgsql` expects:
- table: `public.app_runtime_leases` - table: configurable via `IRuntimeLeaseConnectionStringProvider.Schema` and `IRuntimeLeaseConnectionStringProvider.Table`
- defaults: `public.app_runtime_leases`
- columns: - columns:
- `lease_name` (text, PK) - `lease_name` (text, PK)
- `holder_id` (text) - `holder_id` (text)
@ -41,13 +43,15 @@ Reusable high-availability runtime coordination library for MaksIT services.
- `acquired_at_utc` (timestamptz) - `acquired_at_utc` (timestamptz)
- `expires_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 ## Usage examples
### Install package ### Install package
```xml ```xml
<ItemGroup> <ItemGroup>
<PackageReference Include="MaksIT.HAMode" Version="1.0.1" /> <PackageReference Include="MaksIT.HAMode" />
</ItemGroup> </ItemGroup>
``` ```
@ -65,11 +69,28 @@ builder.Services.AddHAModeRuntimeInstanceId();
using MaksIT.HAMode.Abstractions; using MaksIT.HAMode.Abstractions;
using MaksIT.HAMode.Extensions; using MaksIT.HAMode.Extensions;
public sealed class MyPgLeaseConnectionProvider(IConfiguration cfg) : IRuntimeLeaseConnectionStringProvider { // Host project contract.
public string ConnectionString => cfg["Configuration:Engine:ConnectionString"]!; 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";
} }
builder.Services.AddHAModePostgreSql<MyPgLeaseConnectionProvider>(); 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`:
```csharp
var dataSource = new NpgsqlDataSourceBuilder("<your-connection-string>").Build();
builder.Services.AddHAModePostgreSql(pgConfiguration, dataSource);
``` ```
### Redis backend ### Redis backend
@ -78,30 +99,66 @@ builder.Services.AddHAModePostgreSql<MyPgLeaseConnectionProvider>();
using MaksIT.HAMode.Abstractions; using MaksIT.HAMode.Abstractions;
using MaksIT.HAMode.Extensions; using MaksIT.HAMode.Extensions;
public sealed class MyRedisLeaseConnectionProvider(IConfiguration cfg) : IRuntimeLeaseRedisConnectionProvider { // Host project contract.
public string Configuration => cfg["Configuration:Redis:ConnectionString"]!; public interface IMyRedisLeaseConfiguration : IRuntimeLeaseRedisConnectionProvider;
public string KeyPrefix => "my-app/runtime-leases:";
// Host project concrete configuration.
public sealed class MyRedisLeaseConfiguration : IMyRedisLeaseConfiguration {
public required string Configuration { get; init; }
public string KeyPrefix { get; init; } = "my-app/runtime-leases:";
} }
builder.Services.AddHAModeRedis<MyRedisLeaseConnectionProvider>(); 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`:
```csharp
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 ### etcd backend
```csharp ```csharp
using MaksIT.HAMode.Abstractions; using MaksIT.HAMode.Abstractions;
using MaksIT.HAMode.Extensions; using MaksIT.HAMode.Extensions;
public sealed class MyEtcdLeaseConnectionProvider(IConfiguration cfg) : IRuntimeLeaseEtcdConnectionProvider { // Host project contract.
public string Endpoints => cfg["Configuration:Etcd:Endpoints"]!; // ex: http://etcd:2379 public interface IMyEtcdLeaseConfiguration : IRuntimeLeaseEtcdConnectionProvider;
public string? Username => cfg["Configuration:Etcd:Username"];
public string? Password => cfg["Configuration:Etcd:Password"]; // Host project concrete configuration.
public string KeyPrefix => "my-app/runtime-leases/"; 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/";
} }
builder.Services.AddHAModeEtcd<MyEtcdLeaseConnectionProvider>(); 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 ### Runtime acquire/release flow
```csharp ```csharp
@ -145,7 +202,7 @@ In `MaksIT.Vault.Engine.csproj`:
```xml ```xml
<ItemGroup> <ItemGroup>
<PackageReference Include="MaksIT.HAMode" Version="1.0.1" /> <PackageReference Include="MaksIT.HAMode" />
</ItemGroup> </ItemGroup>
``` ```
@ -207,7 +264,7 @@ In `MaksIT.CertsUI.Engine.csproj`:
```xml ```xml
<ItemGroup> <ItemGroup>
<PackageReference Include="MaksIT.HAMode" Version="1.0.1" /> <PackageReference Include="MaksIT.HAMode" />
</ItemGroup> </ItemGroup>
``` ```

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 28.3%"> <svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 37.3%">
<title>Branch Coverage: 28.3%</title> <title>Branch Coverage: 37.3%</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/> <stop offset="1" stop-opacity=".1"/>
@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text> <text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
<text x="53.75" y="14" fill="#fff">Branch Coverage</text> <text x="53.75" y="14" fill="#fff">Branch Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">28.3%</text> <text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">37.3%</text>
<text x="128.75" y="14" fill="#fff">28.3%</text> <text x="128.75" y="14" fill="#fff">37.3%</text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 27.4%"> <svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 41.1%">
<title>Line Coverage: 27.4%</title> <title>Line Coverage: 41.1%</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/> <stop offset="1" stop-opacity=".1"/>
@ -9,13 +9,13 @@
</clipPath> </clipPath>
<g clip-path="url(#r)"> <g clip-path="url(#r)">
<rect width="94.5" height="20" fill="#555"/> <rect width="94.5" height="20" fill="#555"/>
<rect x="94.5" width="42.5" height="20" fill="#dfb317"/> <rect x="94.5" width="42.5" height="20" fill="#a4a61d"/>
<rect width="137" height="20" fill="url(#s)"/> <rect width="137" height="20" fill="url(#s)"/>
</g> </g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text> <text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
<text x="47.25" y="14" fill="#fff">Line Coverage</text> <text x="47.25" y="14" fill="#fff">Line Coverage</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">27.4%</text> <text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">41.1%</text>
<text x="115.75" y="14" fill="#fff">27.4%</text> <text x="115.75" y="14" fill="#fff">41.1%</text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 81.8%"> <svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 60.9%">
<title>Method Coverage: 81.8%</title> <title>Method Coverage: 60.9%</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/> <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/> <stop offset="1" stop-opacity=".1"/>
@ -9,13 +9,13 @@
</clipPath> </clipPath>
<g clip-path="url(#r)"> <g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/> <rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="42.5" height="20" fill="#4c1"/> <rect x="107.5" width="42.5" height="20" fill="#97ca00"/>
<rect width="150" height="20" fill="url(#s)"/> <rect width="150" height="20" fill="url(#s)"/>
</g> </g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11"> <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text> <text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
<text x="53.75" y="14" fill="#fff">Method Coverage</text> <text x="53.75" y="14" fill="#fff">Method Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">81.8%</text> <text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">60.9%</text>
<text x="128.75" y="14" fill="#fff">81.8%</text> <text x="128.75" y="14" fill="#fff">60.9%</text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,7 @@
namespace MaksIT.HAMode.Abstractions;
/// <summary>
/// Root marker interface for runtime lease connector configuration contracts.
/// Host projects should implement one of the connector-specific interfaces.
/// </summary>
public interface IRuntimeLeaseConnectionProvider;

View File

@ -4,6 +4,13 @@ namespace MaksIT.HAMode.Abstractions;
/// Supplies a PostgreSQL connection string for runtime lease persistence. /// Supplies a PostgreSQL connection string for runtime lease persistence.
/// Kept as abstraction so host projects own configuration sources. /// Kept as abstraction so host projects own configuration sources.
/// </summary> /// </summary>
public interface IRuntimeLeaseConnectionStringProvider { public interface IRuntimeLeaseConnectionStringProvider : IRuntimeLeaseConnectionProvider {
/// <summary>PostgreSQL connection string.</summary>
string ConnectionString { get; } string ConnectionString { get; }
/// <summary>Optional schema name for lease table. Defaults to <c>public</c>.</summary>
string Schema => "public";
/// <summary>Optional lease table name. Defaults to <c>app_runtime_leases</c>.</summary>
string Table => "app_runtime_leases";
} }

View File

@ -3,7 +3,7 @@ namespace MaksIT.HAMode.Abstractions;
/// <summary> /// <summary>
/// Supplies etcd connection settings for runtime lease persistence. /// Supplies etcd connection settings for runtime lease persistence.
/// </summary> /// </summary>
public interface IRuntimeLeaseEtcdConnectionProvider { public interface IRuntimeLeaseEtcdConnectionProvider : IRuntimeLeaseConnectionProvider {
/// <summary>Comma-separated etcd endpoint list (for example "http://localhost:2379").</summary> /// <summary>Comma-separated etcd endpoint list (for example "http://localhost:2379").</summary>
string Endpoints { get; } string Endpoints { get; }

View File

@ -3,7 +3,7 @@ namespace MaksIT.HAMode.Abstractions;
/// <summary> /// <summary>
/// Supplies Redis connection settings for runtime lease persistence. /// Supplies Redis connection settings for runtime lease persistence.
/// </summary> /// </summary>
public interface IRuntimeLeaseRedisConnectionProvider { public interface IRuntimeLeaseRedisConnectionProvider : IRuntimeLeaseConnectionProvider {
/// <summary>StackExchange.Redis configuration string.</summary> /// <summary>StackExchange.Redis configuration string.</summary>
string Configuration { get; } string Configuration { get; }

View File

@ -12,21 +12,31 @@ namespace MaksIT.HAMode.Etcd;
/// </summary> /// </summary>
public sealed class RuntimeLeaseServiceEtcd( public sealed class RuntimeLeaseServiceEtcd(
IRuntimeLeaseEtcdConnectionProvider connectionProvider, IRuntimeLeaseEtcdConnectionProvider connectionProvider,
ILogger<RuntimeLeaseServiceEtcd> logger ILogger<RuntimeLeaseServiceEtcd> logger,
EtcdClient? sharedClient = null
) : IRuntimeLeaseService { ) : IRuntimeLeaseService {
private readonly Lazy<EtcdClient> _client = new(() => private readonly Lazy<EtcdClient> _client = new(() =>
!string.IsNullOrWhiteSpace(connectionProvider.Username) && connectionProvider.Password is not null sharedClient ??
(!string.IsNullOrWhiteSpace(connectionProvider.Username) && connectionProvider.Password is not null
? new EtcdClient(connectionProvider.Endpoints, connectionProvider.Username, connectionProvider.Password) ? new EtcdClient(connectionProvider.Endpoints, connectionProvider.Username, connectionProvider.Password)
: new EtcdClient(connectionProvider.Endpoints)); : new EtcdClient(connectionProvider.Endpoints)));
public async Task<Result<bool>> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) { public async Task<Result<bool>> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(leaseName)) if (string.IsNullOrWhiteSpace(leaseName))
return Result<bool>.BadRequest(false, "leaseName is required."); return Result<bool>.BadRequest(false, "leaseName is required.");
if (string.IsNullOrWhiteSpace(holderId)) if (string.IsNullOrWhiteSpace(holderId))
return Result<bool>.BadRequest(false, "holderId is required."); return Result<bool>.BadRequest(false, "holderId is required.");
if (ttl <= TimeSpan.Zero) if (ttl <= TimeSpan.Zero)
return Result<bool>.BadRequest(false, "ttl must be positive."); return Result<bool>.BadRequest(false, "ttl must be positive.");
if (sharedClient is null && string.IsNullOrWhiteSpace(connectionProvider.Endpoints))
return Result<bool>.BadRequest(false, "etcd endpoints are required.");
if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix))
return Result<bool>.BadRequest(false, "etcd key prefix is required.");
try { try {
var key = BuildKey(leaseName); var key = BuildKey(leaseName);
var keyBytes = ByteString.CopyFromUtf8(key); var keyBytes = ByteString.CopyFromUtf8(key);
@ -87,9 +97,16 @@ public sealed class RuntimeLeaseServiceEtcd(
public async Task<Result> ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) { public async Task<Result> ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(leaseName)) if (string.IsNullOrWhiteSpace(leaseName))
return Result.BadRequest("leaseName is required."); return Result.BadRequest("leaseName is required.");
if (string.IsNullOrWhiteSpace(holderId)) if (string.IsNullOrWhiteSpace(holderId))
return Result.BadRequest("holderId is required."); return Result.BadRequest("holderId is required.");
if (sharedClient is null && string.IsNullOrWhiteSpace(connectionProvider.Endpoints))
return Result.BadRequest("etcd endpoints are required.");
if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix))
return Result.BadRequest("etcd key prefix is required.");
try { try {
var keyBytes = ByteString.CopyFromUtf8(BuildKey(leaseName)); var keyBytes = ByteString.CopyFromUtf8(BuildKey(leaseName));
var holderBytes = ByteString.CopyFromUtf8(holderId); var holderBytes = ByteString.CopyFromUtf8(holderId);

View File

@ -10,34 +10,45 @@ namespace MaksIT.HAMode.PostgreSql;
/// </summary> /// </summary>
public sealed class RuntimeLeaseServiceNpgsql( public sealed class RuntimeLeaseServiceNpgsql(
IRuntimeLeaseConnectionStringProvider connectionStringProvider, IRuntimeLeaseConnectionStringProvider connectionStringProvider,
ILogger<RuntimeLeaseServiceNpgsql> logger ILogger<RuntimeLeaseServiceNpgsql> logger,
NpgsqlDataSource? dataSource = null
) : IRuntimeLeaseService { ) : IRuntimeLeaseService {
public async Task<Result<bool>> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) { public async Task<Result<bool>> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(leaseName)) if (string.IsNullOrWhiteSpace(leaseName))
return Result<bool>.BadRequest(false, "leaseName is required."); return Result<bool>.BadRequest(false, "leaseName is required.");
if (string.IsNullOrWhiteSpace(holderId)) if (string.IsNullOrWhiteSpace(holderId))
return Result<bool>.BadRequest(false, "holderId is required."); return Result<bool>.BadRequest(false, "holderId is required.");
if (ttl <= TimeSpan.Zero) if (ttl <= TimeSpan.Zero)
return Result<bool>.BadRequest(false, "ttl must be positive."); return Result<bool>.BadRequest(false, "ttl must be positive.");
if (dataSource is null && string.IsNullOrWhiteSpace(connectionStringProvider.ConnectionString))
return Result<bool>.BadRequest(false, "connection string is required.");
if (string.IsNullOrWhiteSpace(connectionStringProvider.Schema))
return Result<bool>.BadRequest(false, "schema is required.");
if (string.IsNullOrWhiteSpace(connectionStringProvider.Table))
return Result<bool>.BadRequest(false, "table is required.");
try { try {
await using var conn = new NpgsqlConnection(connectionStringProvider.ConnectionString); await using var conn = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
var acquiredAt = DateTimeOffset.UtcNow; var acquiredAt = DateTimeOffset.UtcNow;
var expiresAt = acquiredAt.Add(ttl); var expiresAt = acquiredAt.Add(ttl);
var tableReference = GetQualifiedTableReference();
await using var cmd = new NpgsqlCommand( await using var cmd = new NpgsqlCommand(
""" $"""
INSERT INTO public.app_runtime_leases (lease_name, holder_id, version, acquired_at_utc, expires_at_utc) INSERT INTO {tableReference} (lease_name, holder_id, version, acquired_at_utc, expires_at_utc)
VALUES (@name, @holder, 1, @acquired, @expires) VALUES (@name, @holder, 1, @acquired, @expires)
ON CONFLICT (lease_name) DO UPDATE ON CONFLICT (lease_name) DO UPDATE
SET holder_id = EXCLUDED.holder_id, SET holder_id = EXCLUDED.holder_id,
version = public.app_runtime_leases.version + 1, version = {tableReference}.version + 1,
acquired_at_utc = EXCLUDED.acquired_at_utc, acquired_at_utc = EXCLUDED.acquired_at_utc,
expires_at_utc = EXCLUDED.expires_at_utc expires_at_utc = EXCLUDED.expires_at_utc
WHERE public.app_runtime_leases.expires_at_utc < EXCLUDED.acquired_at_utc WHERE {tableReference}.expires_at_utc < EXCLUDED.acquired_at_utc
OR public.app_runtime_leases.holder_id = EXCLUDED.holder_id OR {tableReference}.holder_id = EXCLUDED.holder_id
RETURNING holder_id; RETURNING holder_id;
""", """,
conn); conn);
@ -54,6 +65,14 @@ public sealed class RuntimeLeaseServiceNpgsql(
var winner = reader.GetString(0); var winner = reader.GetString(0);
return Result<bool>.Ok(string.Equals(winner, holderId, StringComparison.Ordinal)); return Result<bool>.Ok(string.Equals(winner, holderId, StringComparison.Ordinal));
} }
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UndefinedTable) {
var qualifiedTableName = $"{connectionStringProvider.Schema}.{connectionStringProvider.Table}";
logger.LogError(ex, "Lease table {TableName} was not found while acquiring lease {LeaseName}", qualifiedTableName, leaseName);
return Result<bool>.InternalServerError(false, [
$"Lease table '{qualifiedTableName}' was not found.",
"Create the table or set Schema/Table in the PostgreSQL connection provider."
]);
}
catch (Exception ex) { catch (Exception ex) {
logger.LogError(ex, "TryAcquire lease failed for {LeaseName}", leaseName); logger.LogError(ex, "TryAcquire lease failed for {LeaseName}", leaseName);
return Result<bool>.InternalServerError(false, ["Lease acquire failed.", ex.Message]); return Result<bool>.InternalServerError(false, ["Lease acquire failed.", ex.Message]);
@ -63,16 +82,26 @@ public sealed class RuntimeLeaseServiceNpgsql(
public async Task<Result> ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) { public async Task<Result> ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(leaseName)) if (string.IsNullOrWhiteSpace(leaseName))
return Result.BadRequest("leaseName is required."); return Result.BadRequest("leaseName is required.");
if (string.IsNullOrWhiteSpace(holderId)) if (string.IsNullOrWhiteSpace(holderId))
return Result.BadRequest("holderId is required."); return Result.BadRequest("holderId is required.");
if (dataSource is null && string.IsNullOrWhiteSpace(connectionStringProvider.ConnectionString))
return Result.BadRequest("connection string is required.");
if (string.IsNullOrWhiteSpace(connectionStringProvider.Schema))
return Result.BadRequest("schema is required.");
if (string.IsNullOrWhiteSpace(connectionStringProvider.Table))
return Result.BadRequest("table is required.");
try { try {
await using var conn = new NpgsqlConnection(connectionStringProvider.ConnectionString); await using var conn = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await conn.OpenAsync(cancellationToken).ConfigureAwait(false); var tableReference = GetQualifiedTableReference();
await using var cmd = new NpgsqlCommand( await using var cmd = new NpgsqlCommand(
""" $"""
DELETE FROM public.app_runtime_leases DELETE FROM {tableReference}
WHERE lease_name = @name AND holder_id = @holder; WHERE lease_name = @name AND holder_id = @holder;
""", """,
conn); conn);
@ -82,9 +111,32 @@ public sealed class RuntimeLeaseServiceNpgsql(
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return Result.Ok(); return Result.Ok();
} }
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UndefinedTable) {
var qualifiedTableName = $"{connectionStringProvider.Schema}.{connectionStringProvider.Table}";
logger.LogWarning(ex, "Lease table {TableName} was not found while releasing lease {LeaseName}", qualifiedTableName, leaseName);
return Result.InternalServerError([
$"Lease table '{qualifiedTableName}' was not found.",
"Create the table or set Schema/Table in the PostgreSQL connection provider."
]);
}
catch (Exception ex) { catch (Exception ex) {
logger.LogWarning(ex, "Release lease failed for {LeaseName} (ignored).", leaseName); logger.LogWarning(ex, "Release lease failed for {LeaseName} (ignored).", leaseName);
return Result.InternalServerError(["Lease release failed.", ex.Message]); return Result.InternalServerError(["Lease release failed.", ex.Message]);
} }
} }
private string GetQualifiedTableReference() =>
$"{QuoteIdentifier(connectionStringProvider.Schema)}.{QuoteIdentifier(connectionStringProvider.Table)}";
private static string QuoteIdentifier(string identifier) =>
$"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
private async Task<NpgsqlConnection> OpenConnectionAsync(CancellationToken cancellationToken) {
if (dataSource is not null)
return await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
var connection = new NpgsqlConnection(connectionStringProvider.ConnectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
return connection;
}
} }

View File

@ -10,7 +10,8 @@ namespace MaksIT.HAMode.Redis;
/// </summary> /// </summary>
public sealed class RuntimeLeaseServiceRedis( public sealed class RuntimeLeaseServiceRedis(
IRuntimeLeaseRedisConnectionProvider connectionProvider, IRuntimeLeaseRedisConnectionProvider connectionProvider,
ILogger<RuntimeLeaseServiceRedis> logger ILogger<RuntimeLeaseServiceRedis> logger,
IConnectionMultiplexer? sharedMultiplexer = null
) : IRuntimeLeaseService, IAsyncDisposable { ) : IRuntimeLeaseService, IAsyncDisposable {
private static readonly LuaScript AcquireScript = LuaScript.Prepare( private static readonly LuaScript AcquireScript = LuaScript.Prepare(
""" """
@ -33,16 +34,24 @@ public sealed class RuntimeLeaseServiceRedis(
"""); """);
private readonly SemaphoreSlim _connectionLock = new(1, 1); private readonly SemaphoreSlim _connectionLock = new(1, 1);
private ConnectionMultiplexer? _multiplexer; private ConnectionMultiplexer? _ownedMultiplexer;
public async Task<Result<bool>> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) { public async Task<Result<bool>> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(leaseName)) if (string.IsNullOrWhiteSpace(leaseName))
return Result<bool>.BadRequest(false, "leaseName is required."); return Result<bool>.BadRequest(false, "leaseName is required.");
if (string.IsNullOrWhiteSpace(holderId)) if (string.IsNullOrWhiteSpace(holderId))
return Result<bool>.BadRequest(false, "holderId is required."); return Result<bool>.BadRequest(false, "holderId is required.");
if (ttl <= TimeSpan.Zero) if (ttl <= TimeSpan.Zero)
return Result<bool>.BadRequest(false, "ttl must be positive."); return Result<bool>.BadRequest(false, "ttl must be positive.");
if (sharedMultiplexer is null && string.IsNullOrWhiteSpace(connectionProvider.Configuration))
return Result<bool>.BadRequest(false, "redis configuration is required.");
if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix))
return Result<bool>.BadRequest(false, "redis key prefix is required.");
try { try {
var db = (await GetDatabaseAsync(cancellationToken).ConfigureAwait(false)).Database; var db = (await GetDatabaseAsync(cancellationToken).ConfigureAwait(false)).Database;
var key = BuildKey(leaseName); var key = BuildKey(leaseName);
@ -62,9 +71,16 @@ public sealed class RuntimeLeaseServiceRedis(
public async Task<Result> ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) { public async Task<Result> ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(leaseName)) if (string.IsNullOrWhiteSpace(leaseName))
return Result.BadRequest("leaseName is required."); return Result.BadRequest("leaseName is required.");
if (string.IsNullOrWhiteSpace(holderId)) if (string.IsNullOrWhiteSpace(holderId))
return Result.BadRequest("holderId is required."); return Result.BadRequest("holderId is required.");
if (sharedMultiplexer is null && string.IsNullOrWhiteSpace(connectionProvider.Configuration))
return Result.BadRequest("redis configuration is required.");
if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix))
return Result.BadRequest("redis key prefix is required.");
try { try {
var db = (await GetDatabaseAsync(cancellationToken).ConfigureAwait(false)).Database; var db = (await GetDatabaseAsync(cancellationToken).ConfigureAwait(false)).Database;
var key = BuildKey(leaseName); var key = BuildKey(leaseName);
@ -80,17 +96,20 @@ public sealed class RuntimeLeaseServiceRedis(
private string BuildKey(string leaseName) => $"{connectionProvider.KeyPrefix}{leaseName}"; private string BuildKey(string leaseName) => $"{connectionProvider.KeyPrefix}{leaseName}";
private async Task<(IConnectionMultiplexer Multiplexer, IDatabase Database)> GetDatabaseAsync(CancellationToken cancellationToken) { private async Task<(IConnectionMultiplexer Multiplexer, IDatabase Database)> GetDatabaseAsync(CancellationToken cancellationToken) {
if (_multiplexer is { IsConnected: true } connected) if (sharedMultiplexer is not null)
return (sharedMultiplexer, sharedMultiplexer.GetDatabase());
if (_ownedMultiplexer is { IsConnected: true } connected)
return (connected, connected.GetDatabase()); return (connected, connected.GetDatabase());
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false); await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try { try {
if (_multiplexer is not { IsConnected: true }) { if (_ownedMultiplexer is not { IsConnected: true }) {
_multiplexer?.Dispose(); _ownedMultiplexer?.Dispose();
_multiplexer = await ConnectionMultiplexer.ConnectAsync(connectionProvider.Configuration).ConfigureAwait(false); _ownedMultiplexer = await ConnectionMultiplexer.ConnectAsync(connectionProvider.Configuration).ConfigureAwait(false);
} }
return (_multiplexer, _multiplexer.GetDatabase()); return (_ownedMultiplexer, _ownedMultiplexer.GetDatabase());
} }
finally { finally {
_connectionLock.Release(); _connectionLock.Release();
@ -98,12 +117,17 @@ public sealed class RuntimeLeaseServiceRedis(
} }
public async ValueTask DisposeAsync() { public async ValueTask DisposeAsync() {
if (sharedMultiplexer is not null) {
_connectionLock.Dispose();
return;
}
await _connectionLock.WaitAsync().ConfigureAwait(false); await _connectionLock.WaitAsync().ConfigureAwait(false);
try { try {
if (_multiplexer is not null) { if (_ownedMultiplexer is not null) {
await _multiplexer.CloseAsync(allowCommandsToComplete: false).ConfigureAwait(false); await _ownedMultiplexer.CloseAsync(allowCommandsToComplete: false).ConfigureAwait(false);
_multiplexer.Dispose(); _ownedMultiplexer.Dispose();
_multiplexer = null; _ownedMultiplexer = null;
} }
} }
finally { finally {

View File

@ -3,12 +3,31 @@ using MaksIT.HAMode.Abstractions;
namespace MaksIT.HAMode.Tests; namespace MaksIT.HAMode.Tests;
public sealed class ConnectionProviderDefaultsTests { public sealed class ConnectionProviderDefaultsTests {
[Fact]
public void PostgreSqlProvider_UsesDefaultSchemaAndTable() {
IRuntimeLeaseConnectionStringProvider provider = new TestPgProvider();
Assert.Equal("public", provider.Schema);
Assert.Equal("app_runtime_leases", provider.Table);
}
[Fact]
public void PostgreSqlProvider_ImplementsRootConnectorInterface() {
IRuntimeLeaseConnectionProvider provider = new TestPgProvider();
Assert.IsAssignableFrom<IRuntimeLeaseConnectionStringProvider>(provider);
}
[Fact] [Fact]
public void RedisProvider_UsesDefaultKeyPrefix() { public void RedisProvider_UsesDefaultKeyPrefix() {
IRuntimeLeaseRedisConnectionProvider provider = new TestRedisProvider(); IRuntimeLeaseRedisConnectionProvider provider = new TestRedisProvider();
Assert.Equal("app_runtime_leases:", provider.KeyPrefix); Assert.Equal("app_runtime_leases:", provider.KeyPrefix);
} }
[Fact]
public void RedisProvider_ImplementsRootConnectorInterface() {
IRuntimeLeaseConnectionProvider provider = new TestRedisProvider();
Assert.IsAssignableFrom<IRuntimeLeaseRedisConnectionProvider>(provider);
}
[Fact] [Fact]
public void EtcdProvider_UsesDefaultKeyPrefixAndNullCredentials() { public void EtcdProvider_UsesDefaultKeyPrefixAndNullCredentials() {
IRuntimeLeaseEtcdConnectionProvider provider = new TestEtcdProvider(); IRuntimeLeaseEtcdConnectionProvider provider = new TestEtcdProvider();
@ -17,11 +36,9 @@ public sealed class ConnectionProviderDefaultsTests {
Assert.Null(provider.Password); Assert.Null(provider.Password);
} }
private sealed class TestRedisProvider : IRuntimeLeaseRedisConnectionProvider { [Fact]
public string Configuration => "localhost:6379"; public void EtcdProvider_ImplementsRootConnectorInterface() {
} IRuntimeLeaseConnectionProvider provider = new TestEtcdProvider();
Assert.IsAssignableFrom<IRuntimeLeaseEtcdConnectionProvider>(provider);
private sealed class TestEtcdProvider : IRuntimeLeaseEtcdConnectionProvider {
public string Endpoints => "http://localhost:2379";
} }
} }

View File

@ -13,12 +13,14 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0-pre.4"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="xunit.v3" Version="4.0.0-pre.128" /> <PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -26,6 +28,7 @@
<ProjectReference Include="..\MaksIT.HAMode.PostgreSql\MaksIT.HAMode.PostgreSql.csproj" /> <ProjectReference Include="..\MaksIT.HAMode.PostgreSql\MaksIT.HAMode.PostgreSql.csproj" />
<ProjectReference Include="..\MaksIT.HAMode.Redis\MaksIT.HAMode.Redis.csproj" /> <ProjectReference Include="..\MaksIT.HAMode.Redis\MaksIT.HAMode.Redis.csproj" />
<ProjectReference Include="..\MaksIT.HAMode.Etcd\MaksIT.HAMode.Etcd.csproj" /> <ProjectReference Include="..\MaksIT.HAMode.Etcd\MaksIT.HAMode.Etcd.csproj" />
<ProjectReference Include="..\MaksIT.HAMode\MaksIT.HAMode.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -1,8 +1,8 @@
using MaksIT.HAMode.Abstractions;
using MaksIT.HAMode.Etcd; using MaksIT.HAMode.Etcd;
using MaksIT.HAMode.PostgreSql; using MaksIT.HAMode.PostgreSql;
using MaksIT.HAMode.Redis; using MaksIT.HAMode.Redis;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using System.Net; using System.Net;
namespace MaksIT.HAMode.Tests; namespace MaksIT.HAMode.Tests;
@ -14,7 +14,7 @@ public sealed class RuntimeLeaseServiceValidationTests {
[InlineData("")] [InlineData("")]
[InlineData(" ")] [InlineData(" ")]
public async Task PostgreSql_TryAcquire_InvalidLeaseName_ReturnsBadRequest(string leaseName) { public async Task PostgreSql_TryAcquire_InvalidLeaseName_ReturnsBadRequest(string leaseName) {
var service = new RuntimeLeaseServiceNpgsql(new PgProvider(), NullLogger<RuntimeLeaseServiceNpgsql>.Instance); var service = new RuntimeLeaseServiceNpgsql(new TestPgProvider(), NullLogger<RuntimeLeaseServiceNpgsql>.Instance);
var result = await service.TryAcquireAsync(leaseName, "holder", PositiveTtl, TestContext.Current.CancellationToken); var result = await service.TryAcquireAsync(leaseName, "holder", PositiveTtl, TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess); Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
@ -22,15 +22,68 @@ public sealed class RuntimeLeaseServiceValidationTests {
[Fact] [Fact]
public async Task PostgreSql_Release_InvalidHolder_ReturnsBadRequest() { public async Task PostgreSql_Release_InvalidHolder_ReturnsBadRequest() {
var service = new RuntimeLeaseServiceNpgsql(new PgProvider(), NullLogger<RuntimeLeaseServiceNpgsql>.Instance); var service = new RuntimeLeaseServiceNpgsql(new TestPgProvider(), NullLogger<RuntimeLeaseServiceNpgsql>.Instance);
var result = await service.ReleaseAsync("lease", "", TestContext.Current.CancellationToken); var result = await service.ReleaseAsync("lease", "", TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess); Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
} }
[Fact]
public async Task PostgreSql_TryAcquire_MissingConnectionString_ReturnsBadRequest() {
var service = new RuntimeLeaseServiceNpgsql(
new TestPgProvider { ConnectionString = "" },
NullLogger<RuntimeLeaseServiceNpgsql>.Instance);
var result = await service.TryAcquireAsync("lease", "holder", PositiveTtl, TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task PostgreSql_TryAcquire_MissingSchema_ReturnsBadRequest(string schema) {
var service = new RuntimeLeaseServiceNpgsql(
new TestPgProvider { Schema = schema },
NullLogger<RuntimeLeaseServiceNpgsql>.Instance);
var result = await service.TryAcquireAsync("lease", "holder", PositiveTtl, TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task PostgreSql_TryAcquire_MissingTable_ReturnsBadRequest(string table) {
var service = new RuntimeLeaseServiceNpgsql(
new TestPgProvider { Table = table },
NullLogger<RuntimeLeaseServiceNpgsql>.Instance);
var result = await service.TryAcquireAsync("lease", "holder", PositiveTtl, TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
}
[Fact]
public async Task PostgreSql_TryAcquire_WithSharedDataSource_AllowsEmptyConnectionString() {
await using var dataSource = NpgsqlDataSource.Create("Host=127.0.0.1;Port=5432;Database=hamode;Username=hamode;Password=hamode;Timeout=1");
var service = new RuntimeLeaseServiceNpgsql(
new TestPgProvider { ConnectionString = "" },
NullLogger<RuntimeLeaseServiceNpgsql>.Instance,
dataSource);
var result = await service.TryAcquireAsync("lease", "holder", PositiveTtl, TestContext.Current.CancellationToken);
Assert.NotEqual(HttpStatusCode.BadRequest, result.StatusCode);
}
[Fact] [Fact]
public async Task Redis_TryAcquire_InvalidTtl_ReturnsBadRequest() { public async Task Redis_TryAcquire_InvalidTtl_ReturnsBadRequest() {
var service = new RuntimeLeaseServiceRedis(new RedisProvider(), NullLogger<RuntimeLeaseServiceRedis>.Instance); var service = new RuntimeLeaseServiceRedis(new TestRedisProvider(), NullLogger<RuntimeLeaseServiceRedis>.Instance);
var result = await service.TryAcquireAsync("lease", "holder", TimeSpan.Zero, TestContext.Current.CancellationToken); var result = await service.TryAcquireAsync("lease", "holder", TimeSpan.Zero, TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess); Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
@ -39,16 +92,42 @@ public sealed class RuntimeLeaseServiceValidationTests {
[Fact] [Fact]
public async Task Redis_Release_InvalidLeaseName_ReturnsBadRequest() { public async Task Redis_Release_InvalidLeaseName_ReturnsBadRequest() {
var service = new RuntimeLeaseServiceRedis(new RedisProvider(), NullLogger<RuntimeLeaseServiceRedis>.Instance); var service = new RuntimeLeaseServiceRedis(new TestRedisProvider(), NullLogger<RuntimeLeaseServiceRedis>.Instance);
var result = await service.ReleaseAsync("", "holder", TestContext.Current.CancellationToken); var result = await service.ReleaseAsync("", "holder", TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess); Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
await service.DisposeAsync(); await service.DisposeAsync();
} }
[Fact]
public async Task Redis_TryAcquire_MissingConfiguration_ReturnsBadRequest() {
var service = new RuntimeLeaseServiceRedis(
new TestRedisProvider { Configuration = "" },
NullLogger<RuntimeLeaseServiceRedis>.Instance);
var result = await service.TryAcquireAsync("lease", "holder", PositiveTtl, TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
await service.DisposeAsync();
}
[Fact]
public async Task Redis_TryAcquire_MissingKeyPrefix_ReturnsBadRequest() {
var service = new RuntimeLeaseServiceRedis(
new TestRedisProvider { KeyPrefix = "" },
NullLogger<RuntimeLeaseServiceRedis>.Instance);
var result = await service.TryAcquireAsync("lease", "holder", PositiveTtl, TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
await service.DisposeAsync();
}
[Fact] [Fact]
public async Task Etcd_TryAcquire_InvalidHolder_ReturnsBadRequest() { public async Task Etcd_TryAcquire_InvalidHolder_ReturnsBadRequest() {
var service = new RuntimeLeaseServiceEtcd(new EtcdProvider(), NullLogger<RuntimeLeaseServiceEtcd>.Instance); var service = new RuntimeLeaseServiceEtcd(new TestEtcdProvider(), NullLogger<RuntimeLeaseServiceEtcd>.Instance);
var result = await service.TryAcquireAsync("lease", " ", PositiveTtl, TestContext.Current.CancellationToken); var result = await service.TryAcquireAsync("lease", " ", PositiveTtl, TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess); Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
@ -56,21 +135,33 @@ public sealed class RuntimeLeaseServiceValidationTests {
[Fact] [Fact]
public async Task Etcd_Release_InvalidLeaseName_ReturnsBadRequest() { public async Task Etcd_Release_InvalidLeaseName_ReturnsBadRequest() {
var service = new RuntimeLeaseServiceEtcd(new EtcdProvider(), NullLogger<RuntimeLeaseServiceEtcd>.Instance); var service = new RuntimeLeaseServiceEtcd(new TestEtcdProvider(), NullLogger<RuntimeLeaseServiceEtcd>.Instance);
var result = await service.ReleaseAsync(" ", "holder", TestContext.Current.CancellationToken); var result = await service.ReleaseAsync(" ", "holder", TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess); Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
} }
private sealed class PgProvider : IRuntimeLeaseConnectionStringProvider { [Fact]
public string ConnectionString => "Host=localhost;Port=5432;Database=hamode;Username=hamode;Password=hamode"; public async Task Etcd_TryAcquire_MissingEndpoints_ReturnsBadRequest() {
var service = new RuntimeLeaseServiceEtcd(
new TestEtcdProvider { Endpoints = "" },
NullLogger<RuntimeLeaseServiceEtcd>.Instance);
var result = await service.TryAcquireAsync("lease", "holder", PositiveTtl, TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
} }
private sealed class RedisProvider : IRuntimeLeaseRedisConnectionProvider { [Fact]
public string Configuration => "localhost:6379"; public async Task Etcd_TryAcquire_MissingKeyPrefix_ReturnsBadRequest() {
} var service = new RuntimeLeaseServiceEtcd(
new TestEtcdProvider { KeyPrefix = "" },
NullLogger<RuntimeLeaseServiceEtcd>.Instance);
private sealed class EtcdProvider : IRuntimeLeaseEtcdConnectionProvider { var result = await service.TryAcquireAsync("lease", "holder", PositiveTtl, TestContext.Current.CancellationToken);
public string Endpoints => "http://localhost:2379";
Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
} }
} }

View File

@ -0,0 +1,109 @@
using dotnet_etcd;
using MaksIT.HAMode.Abstractions;
using MaksIT.HAMode.Etcd;
using MaksIT.HAMode.Extensions;
using MaksIT.HAMode.PostgreSql;
using MaksIT.HAMode.Redis;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using StackExchange.Redis;
namespace MaksIT.HAMode.Tests;
public sealed class ServiceCollectionExtensionsTests {
[Fact]
public void AddHAModeRuntimeInstanceId_RegistersProvider() {
var services = new ServiceCollection();
services.AddHAModeRuntimeInstanceId();
var provider = services.BuildServiceProvider();
var instanceId = provider.GetRequiredService<IRuntimeInstanceId>();
Assert.IsType<RuntimeInstanceIdProvider>(instanceId);
}
[Fact]
public void AddHAModePostgreSql_WithConfigurationInstance_RegistersLeaseService() {
var configuration = new TestPgProvider();
var services = new ServiceCollection()
.AddLogging()
.AddHAModePostgreSql(configuration);
var provider = services.BuildServiceProvider();
Assert.Same(configuration, provider.GetRequiredService<IRuntimeLeaseConnectionStringProvider>());
Assert.IsType<RuntimeLeaseServiceNpgsql>(provider.GetRequiredService<IRuntimeLeaseService>());
Assert.IsType<RuntimeInstanceIdProvider>(provider.GetRequiredService<IRuntimeInstanceId>());
}
[Fact]
public void AddHAModePostgreSql_WithSharedDataSource_RegistersLeaseService() {
var configuration = new TestPgProvider();
var dataSource = NpgsqlDataSource.Create("Host=127.0.0.1;Port=5432;Database=hamode;Username=hamode;Password=hamode");
var services = new ServiceCollection()
.AddLogging()
.AddHAModePostgreSql(configuration, dataSource);
var provider = services.BuildServiceProvider();
Assert.Same(configuration, provider.GetRequiredService<IRuntimeLeaseConnectionStringProvider>());
Assert.Same(dataSource, provider.GetRequiredService<NpgsqlDataSource>());
Assert.IsType<RuntimeLeaseServiceNpgsql>(provider.GetRequiredService<IRuntimeLeaseService>());
}
[Fact]
public void AddHAModeRedis_WithConfigurationInstance_RegistersLeaseService() {
var configuration = new TestRedisProvider();
var services = new ServiceCollection()
.AddLogging()
.AddHAModeRedis(configuration);
var provider = services.BuildServiceProvider();
Assert.Same(configuration, provider.GetRequiredService<IRuntimeLeaseRedisConnectionProvider>());
Assert.IsType<RuntimeLeaseServiceRedis>(provider.GetRequiredService<IRuntimeLeaseService>());
}
[Fact]
public void AddHAModeRedis_WithSharedMultiplexer_RegistersLeaseService() {
var configuration = new TestRedisProvider();
var multiplexer = ConnectionMultiplexer.Connect("127.0.0.1:63999,abortConnect=false,connectTimeout=1");
var services = new ServiceCollection()
.AddLogging()
.AddHAModeRedis(configuration, multiplexer);
var provider = services.BuildServiceProvider();
Assert.Same(configuration, provider.GetRequiredService<IRuntimeLeaseRedisConnectionProvider>());
Assert.Same(multiplexer, provider.GetRequiredService<IConnectionMultiplexer>());
Assert.IsType<RuntimeLeaseServiceRedis>(provider.GetRequiredService<IRuntimeLeaseService>());
}
[Fact]
public void AddHAModeEtcd_WithConfigurationInstance_RegistersLeaseService() {
var configuration = new TestEtcdProvider();
var services = new ServiceCollection()
.AddLogging()
.AddHAModeEtcd(configuration);
var provider = services.BuildServiceProvider();
Assert.Same(configuration, provider.GetRequiredService<IRuntimeLeaseEtcdConnectionProvider>());
Assert.IsType<RuntimeLeaseServiceEtcd>(provider.GetRequiredService<IRuntimeLeaseService>());
}
[Fact]
public void AddHAModeEtcd_WithSharedClient_RegistersLeaseService() {
var configuration = new TestEtcdProvider();
var client = new EtcdClient("http://127.0.0.1:2379");
var services = new ServiceCollection()
.AddLogging()
.AddHAModeEtcd(configuration, client);
var provider = services.BuildServiceProvider();
Assert.Same(configuration, provider.GetRequiredService<IRuntimeLeaseEtcdConnectionProvider>());
Assert.Same(client, provider.GetRequiredService<EtcdClient>());
Assert.IsType<RuntimeLeaseServiceEtcd>(provider.GetRequiredService<IRuntimeLeaseService>());
}
}

View File

@ -0,0 +1,21 @@
using MaksIT.HAMode.Abstractions;
namespace MaksIT.HAMode.Tests;
internal sealed class TestPgProvider : IRuntimeLeaseConnectionStringProvider {
public string ConnectionString { get; init; } = "Host=localhost;Port=5432;Database=hamode;Username=hamode;Password=hamode";
public string Schema { get; init; } = "public";
public string Table { get; init; } = "app_runtime_leases";
}
internal sealed class TestRedisProvider : IRuntimeLeaseRedisConnectionProvider {
public string Configuration { get; init; } = "localhost:6379";
public string KeyPrefix { get; init; } = "app_runtime_leases:";
}
internal sealed class TestEtcdProvider : IRuntimeLeaseEtcdConnectionProvider {
public string Endpoints { get; init; } = "http://localhost:2379";
public string? Username { get; init; }
public string? Password { get; init; }
public string KeyPrefix { get; init; } = "app_runtime_leases/";
}

View File

@ -1,8 +1,11 @@
using dotnet_etcd;
using MaksIT.HAMode.Abstractions; using MaksIT.HAMode.Abstractions;
using MaksIT.HAMode.Etcd; using MaksIT.HAMode.Etcd;
using MaksIT.HAMode.PostgreSql; using MaksIT.HAMode.PostgreSql;
using MaksIT.HAMode.Redis; using MaksIT.HAMode.Redis;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using StackExchange.Redis;
namespace MaksIT.HAMode.Extensions; namespace MaksIT.HAMode.Extensions;
@ -23,11 +26,43 @@ public static class ServiceCollectionExtensions {
/// </summary> /// </summary>
public static IServiceCollection AddHAModePostgreSqlLease<TConnectionProvider>(this IServiceCollection services) public static IServiceCollection AddHAModePostgreSqlLease<TConnectionProvider>(this IServiceCollection services)
where TConnectionProvider : class, IRuntimeLeaseConnectionStringProvider { where TConnectionProvider : class, IRuntimeLeaseConnectionStringProvider {
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<IRuntimeLeaseConnectionStringProvider, TConnectionProvider>(); services.AddSingleton<IRuntimeLeaseConnectionStringProvider, TConnectionProvider>();
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>(); services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>();
return services; return services;
} }
/// <summary>
/// Registers only PostgreSQL-backed runtime lease service using a provided configuration instance.
/// </summary>
public static IServiceCollection AddHAModePostgreSqlLease(this IServiceCollection services, IRuntimeLeaseConnectionStringProvider configuration) {
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.AddSingleton(configuration);
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>();
return services;
}
/// <summary>
/// Registers only PostgreSQL-backed runtime lease service using provided configuration and shared data source.
/// </summary>
public static IServiceCollection AddHAModePostgreSqlLease(
this IServiceCollection services,
IRuntimeLeaseConnectionStringProvider configuration,
NpgsqlDataSource dataSource
) {
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(dataSource);
services.AddSingleton(configuration);
services.AddSingleton(dataSource);
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>();
return services;
}
/// <summary> /// <summary>
/// Registers full PostgreSQL HA mode scenario. /// Registers full PostgreSQL HA mode scenario.
/// </summary> /// </summary>
@ -38,16 +73,70 @@ public static class ServiceCollectionExtensions {
.AddHAModePostgreSqlLease<TConnectionProvider>(); .AddHAModePostgreSqlLease<TConnectionProvider>();
} }
/// <summary>
/// Registers full PostgreSQL HA mode scenario using a provided configuration instance.
/// </summary>
public static IServiceCollection AddHAModePostgreSql(this IServiceCollection services, IRuntimeLeaseConnectionStringProvider configuration) {
return services
.AddHAModeRuntimeInstanceId()
.AddHAModePostgreSqlLease(configuration);
}
/// <summary>
/// Registers full PostgreSQL HA mode scenario using provided configuration and shared data source.
/// </summary>
public static IServiceCollection AddHAModePostgreSql(
this IServiceCollection services,
IRuntimeLeaseConnectionStringProvider configuration,
NpgsqlDataSource dataSource
) {
return services
.AddHAModeRuntimeInstanceId()
.AddHAModePostgreSqlLease(configuration, dataSource);
}
/// <summary> /// <summary>
/// Registers only Redis-backed runtime lease service. /// Registers only Redis-backed runtime lease service.
/// </summary> /// </summary>
public static IServiceCollection AddHAModeRedisLease<TConnectionProvider>(this IServiceCollection services) public static IServiceCollection AddHAModeRedisLease<TConnectionProvider>(this IServiceCollection services)
where TConnectionProvider : class, IRuntimeLeaseRedisConnectionProvider { where TConnectionProvider : class, IRuntimeLeaseRedisConnectionProvider {
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<IRuntimeLeaseRedisConnectionProvider, TConnectionProvider>(); services.AddSingleton<IRuntimeLeaseRedisConnectionProvider, TConnectionProvider>();
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceRedis>(); services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceRedis>();
return services; return services;
} }
/// <summary>
/// Registers only Redis-backed runtime lease service using a provided configuration instance.
/// </summary>
public static IServiceCollection AddHAModeRedisLease(this IServiceCollection services, IRuntimeLeaseRedisConnectionProvider configuration) {
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.AddSingleton(configuration);
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceRedis>();
return services;
}
/// <summary>
/// Registers only Redis-backed runtime lease service using provided configuration and shared multiplexer.
/// </summary>
public static IServiceCollection AddHAModeRedisLease(
this IServiceCollection services,
IRuntimeLeaseRedisConnectionProvider configuration,
IConnectionMultiplexer multiplexer
) {
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(multiplexer);
services.AddSingleton(configuration);
services.AddSingleton(multiplexer);
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceRedis>();
return services;
}
/// <summary> /// <summary>
/// Registers full Redis HA mode scenario. /// Registers full Redis HA mode scenario.
/// </summary> /// </summary>
@ -58,16 +147,70 @@ public static class ServiceCollectionExtensions {
.AddHAModeRedisLease<TConnectionProvider>(); .AddHAModeRedisLease<TConnectionProvider>();
} }
/// <summary>
/// Registers full Redis HA mode scenario using a provided configuration instance.
/// </summary>
public static IServiceCollection AddHAModeRedis(this IServiceCollection services, IRuntimeLeaseRedisConnectionProvider configuration) {
return services
.AddHAModeRuntimeInstanceId()
.AddHAModeRedisLease(configuration);
}
/// <summary>
/// Registers full Redis HA mode scenario using provided configuration and shared multiplexer.
/// </summary>
public static IServiceCollection AddHAModeRedis(
this IServiceCollection services,
IRuntimeLeaseRedisConnectionProvider configuration,
IConnectionMultiplexer multiplexer
) {
return services
.AddHAModeRuntimeInstanceId()
.AddHAModeRedisLease(configuration, multiplexer);
}
/// <summary> /// <summary>
/// Registers only etcd-backed runtime lease service. /// Registers only etcd-backed runtime lease service.
/// </summary> /// </summary>
public static IServiceCollection AddHAModeEtcdLease<TConnectionProvider>(this IServiceCollection services) public static IServiceCollection AddHAModeEtcdLease<TConnectionProvider>(this IServiceCollection services)
where TConnectionProvider : class, IRuntimeLeaseEtcdConnectionProvider { where TConnectionProvider : class, IRuntimeLeaseEtcdConnectionProvider {
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<IRuntimeLeaseEtcdConnectionProvider, TConnectionProvider>(); services.AddSingleton<IRuntimeLeaseEtcdConnectionProvider, TConnectionProvider>();
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceEtcd>(); services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceEtcd>();
return services; return services;
} }
/// <summary>
/// Registers only etcd-backed runtime lease service using a provided configuration instance.
/// </summary>
public static IServiceCollection AddHAModeEtcdLease(this IServiceCollection services, IRuntimeLeaseEtcdConnectionProvider configuration) {
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.AddSingleton(configuration);
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceEtcd>();
return services;
}
/// <summary>
/// Registers only etcd-backed runtime lease service using provided configuration and shared client.
/// </summary>
public static IServiceCollection AddHAModeEtcdLease(
this IServiceCollection services,
IRuntimeLeaseEtcdConnectionProvider configuration,
EtcdClient client
) {
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(client);
services.AddSingleton(configuration);
services.AddSingleton(client);
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceEtcd>();
return services;
}
/// <summary> /// <summary>
/// Registers full etcd HA mode scenario. /// Registers full etcd HA mode scenario.
/// </summary> /// </summary>
@ -77,4 +220,26 @@ public static class ServiceCollectionExtensions {
.AddHAModeRuntimeInstanceId() .AddHAModeRuntimeInstanceId()
.AddHAModeEtcdLease<TConnectionProvider>(); .AddHAModeEtcdLease<TConnectionProvider>();
} }
/// <summary>
/// Registers full etcd HA mode scenario using a provided configuration instance.
/// </summary>
public static IServiceCollection AddHAModeEtcd(this IServiceCollection services, IRuntimeLeaseEtcdConnectionProvider configuration) {
return services
.AddHAModeRuntimeInstanceId()
.AddHAModeEtcdLease(configuration);
}
/// <summary>
/// Registers full etcd HA mode scenario using provided configuration and shared client.
/// </summary>
public static IServiceCollection AddHAModeEtcd(
this IServiceCollection services,
IRuntimeLeaseEtcdConnectionProvider configuration,
EtcdClient client
) {
return services
.AddHAModeRuntimeInstanceId()
.AddHAModeEtcdLease(configuration, client);
}
} }

View File

@ -9,7 +9,7 @@
<IncludeBuildOutput>true</IncludeBuildOutput> <IncludeBuildOutput>true</IncludeBuildOutput>
<PackageId>MaksIT.HAMode</PackageId> <PackageId>MaksIT.HAMode</PackageId>
<Version>1.0.1</Version> <Version>1.0.2</Version>
<Authors>Maksym Sadovnychyy</Authors> <Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company> <Company>MAKS-IT</Company>
<Product>MaksIT.HAMode</Product> <Product>MaksIT.HAMode</Product>