diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c927f..3b68001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added - 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 - 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 README backend examples to use host-defined configuration interfaces and concrete classes, instead of direct `IConfiguration["..."]` access. ## [1.0.0] - 2026-06-20 diff --git a/README.md b/README.md index a56e6bc..1a3e19a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Reusable high-availability runtime coordination library for MaksIT services. - `MaksIT.HAMode.Abstractions` namespace: - `IRuntimeInstanceId` - `IRuntimeLeaseService` + - `IRuntimeLeaseConnectionProvider` (root marker interface) - `IRuntimeLeaseConnectionStringProvider` - `IRuntimeLeaseRedisConnectionProvider` - `IRuntimeLeaseEtcdConnectionProvider` @@ -33,7 +34,8 @@ Reusable high-availability runtime coordination library for MaksIT services. `RuntimeLeaseServiceNpgsql` expects: -- table: `public.app_runtime_leases` +- table: configurable via `IRuntimeLeaseConnectionStringProvider.Schema` and `IRuntimeLeaseConnectionStringProvider.Table` + - defaults: `public.app_runtime_leases` - columns: - `lease_name` (text, PK) - `holder_id` (text) @@ -41,13 +43,15 @@ Reusable high-availability runtime coordination library for MaksIT services. - `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 - + ``` @@ -65,11 +69,28 @@ builder.Services.AddHAModeRuntimeInstanceId(); using MaksIT.HAMode.Abstractions; using MaksIT.HAMode.Extensions; -public sealed class MyPgLeaseConnectionProvider(IConfiguration cfg) : IRuntimeLeaseConnectionStringProvider { - public string ConnectionString => cfg["Configuration:Engine:ConnectionString"]!; +// 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"; } -builder.Services.AddHAModePostgreSql(); +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 @@ -78,30 +99,66 @@ builder.Services.AddHAModePostgreSql(); using MaksIT.HAMode.Abstractions; using MaksIT.HAMode.Extensions; -public sealed class MyRedisLeaseConnectionProvider(IConfiguration cfg) : IRuntimeLeaseRedisConnectionProvider { - public string Configuration => cfg["Configuration:Redis:ConnectionString"]!; - public string KeyPrefix => "my-app/runtime-leases:"; +// 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:"; } -builder.Services.AddHAModeRedis(); +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; -public sealed class MyEtcdLeaseConnectionProvider(IConfiguration cfg) : IRuntimeLeaseEtcdConnectionProvider { - public string Endpoints => cfg["Configuration:Etcd:Endpoints"]!; // ex: http://etcd:2379 - public string? Username => cfg["Configuration:Etcd:Username"]; - public string? Password => cfg["Configuration:Etcd:Password"]; - public string KeyPrefix => "my-app/runtime-leases/"; +// 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/"; } -builder.Services.AddHAModeEtcd(); +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 @@ -145,7 +202,7 @@ In `MaksIT.Vault.Engine.csproj`: ```xml - + ``` @@ -207,7 +264,7 @@ In `MaksIT.CertsUI.Engine.csproj`: ```xml - + ``` diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg index fa5039f..27013c9 100644 --- a/assets/badges/coverage-branches.svg +++ b/assets/badges/coverage-branches.svg @@ -1,5 +1,5 @@ - - Branch Coverage: 28.3% + + Branch Coverage: 37.3% @@ -15,7 +15,7 @@ Branch Coverage - - 28.3% + + 37.3% diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg index 0c19b4b..32742a9 100644 --- a/assets/badges/coverage-lines.svg +++ b/assets/badges/coverage-lines.svg @@ -1,5 +1,5 @@ - - Line Coverage: 27.4% + + Line Coverage: 41.1% @@ -9,13 +9,13 @@ - + Line Coverage - - 27.4% + + 41.1% diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg index 3333227..5ff67e9 100644 --- a/assets/badges/coverage-methods.svg +++ b/assets/badges/coverage-methods.svg @@ -1,5 +1,5 @@ - - Method Coverage: 81.8% + + Method Coverage: 60.9% @@ -9,13 +9,13 @@ - + Method Coverage - - 81.8% + + 60.9% diff --git a/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseConnectionProvider.cs b/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseConnectionProvider.cs new file mode 100644 index 0000000..cb79100 --- /dev/null +++ b/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseConnectionProvider.cs @@ -0,0 +1,7 @@ +namespace MaksIT.HAMode.Abstractions; + +/// +/// Root marker interface for runtime lease connector configuration contracts. +/// Host projects should implement one of the connector-specific interfaces. +/// +public interface IRuntimeLeaseConnectionProvider; diff --git a/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseConnectionStringProvider.cs b/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseConnectionStringProvider.cs index a3218b8..9d73dec 100644 --- a/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseConnectionStringProvider.cs +++ b/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseConnectionStringProvider.cs @@ -4,6 +4,13 @@ namespace MaksIT.HAMode.Abstractions; /// Supplies a PostgreSQL connection string for runtime lease persistence. /// Kept as abstraction so host projects own configuration sources. /// -public interface IRuntimeLeaseConnectionStringProvider { +public interface IRuntimeLeaseConnectionStringProvider : IRuntimeLeaseConnectionProvider { + /// PostgreSQL connection string. string ConnectionString { get; } + + /// Optional schema name for lease table. Defaults to public. + string Schema => "public"; + + /// Optional lease table name. Defaults to app_runtime_leases. + string Table => "app_runtime_leases"; } diff --git a/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseEtcdConnectionProvider.cs b/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseEtcdConnectionProvider.cs index 15d4386..a58e57c 100644 --- a/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseEtcdConnectionProvider.cs +++ b/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseEtcdConnectionProvider.cs @@ -3,7 +3,7 @@ namespace MaksIT.HAMode.Abstractions; /// /// Supplies etcd connection settings for runtime lease persistence. /// -public interface IRuntimeLeaseEtcdConnectionProvider { +public interface IRuntimeLeaseEtcdConnectionProvider : IRuntimeLeaseConnectionProvider { /// Comma-separated etcd endpoint list (for example "http://localhost:2379"). string Endpoints { get; } diff --git a/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseRedisConnectionProvider.cs b/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseRedisConnectionProvider.cs index c1f6b72..70fa2e2 100644 --- a/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseRedisConnectionProvider.cs +++ b/src/MaksIT.HAMode.Abstractions/IRuntimeLeaseRedisConnectionProvider.cs @@ -3,7 +3,7 @@ namespace MaksIT.HAMode.Abstractions; /// /// Supplies Redis connection settings for runtime lease persistence. /// -public interface IRuntimeLeaseRedisConnectionProvider { +public interface IRuntimeLeaseRedisConnectionProvider : IRuntimeLeaseConnectionProvider { /// StackExchange.Redis configuration string. string Configuration { get; } diff --git a/src/MaksIT.HAMode.Etcd/RuntimeLeaseServiceEtcd.cs b/src/MaksIT.HAMode.Etcd/RuntimeLeaseServiceEtcd.cs index 5723f93..3910d5d 100644 --- a/src/MaksIT.HAMode.Etcd/RuntimeLeaseServiceEtcd.cs +++ b/src/MaksIT.HAMode.Etcd/RuntimeLeaseServiceEtcd.cs @@ -12,21 +12,31 @@ namespace MaksIT.HAMode.Etcd; /// public sealed class RuntimeLeaseServiceEtcd( IRuntimeLeaseEtcdConnectionProvider connectionProvider, - ILogger logger + ILogger logger, + EtcdClient? sharedClient = null ) : IRuntimeLeaseService { private readonly Lazy _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)); + : new EtcdClient(connectionProvider.Endpoints))); public async Task> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(leaseName)) + return Result.BadRequest(false, "leaseName is required."); if (string.IsNullOrWhiteSpace(holderId)) return Result.BadRequest(false, "holderId is required."); + if (ttl <= TimeSpan.Zero) return Result.BadRequest(false, "ttl must be positive."); + if (sharedClient is null && string.IsNullOrWhiteSpace(connectionProvider.Endpoints)) + return Result.BadRequest(false, "etcd endpoints are required."); + + if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix)) + return Result.BadRequest(false, "etcd key prefix is required."); + try { var key = BuildKey(leaseName); var keyBytes = ByteString.CopyFromUtf8(key); @@ -87,9 +97,16 @@ public sealed class RuntimeLeaseServiceEtcd( public async Task ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(leaseName)) return Result.BadRequest("leaseName is required."); + if (string.IsNullOrWhiteSpace(holderId)) 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 { var keyBytes = ByteString.CopyFromUtf8(BuildKey(leaseName)); var holderBytes = ByteString.CopyFromUtf8(holderId); diff --git a/src/MaksIT.HAMode.PostgreSql/RuntimeLeaseServiceNpgsql.cs b/src/MaksIT.HAMode.PostgreSql/RuntimeLeaseServiceNpgsql.cs index 2668f80..9ca6226 100644 --- a/src/MaksIT.HAMode.PostgreSql/RuntimeLeaseServiceNpgsql.cs +++ b/src/MaksIT.HAMode.PostgreSql/RuntimeLeaseServiceNpgsql.cs @@ -10,34 +10,45 @@ namespace MaksIT.HAMode.PostgreSql; /// public sealed class RuntimeLeaseServiceNpgsql( IRuntimeLeaseConnectionStringProvider connectionStringProvider, - ILogger logger + ILogger logger, + NpgsqlDataSource? dataSource = null ) : IRuntimeLeaseService { public async Task> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(leaseName)) return Result.BadRequest(false, "leaseName is required."); + if (string.IsNullOrWhiteSpace(holderId)) return Result.BadRequest(false, "holderId is required."); + if (ttl <= TimeSpan.Zero) return Result.BadRequest(false, "ttl must be positive."); + if (dataSource is null && string.IsNullOrWhiteSpace(connectionStringProvider.ConnectionString)) + return Result.BadRequest(false, "connection string is required."); + + if (string.IsNullOrWhiteSpace(connectionStringProvider.Schema)) + return Result.BadRequest(false, "schema is required."); + + if (string.IsNullOrWhiteSpace(connectionStringProvider.Table)) + return Result.BadRequest(false, "table is required."); try { - await using var conn = new NpgsqlConnection(connectionStringProvider.ConnectionString); - await conn.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var conn = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false); var acquiredAt = DateTimeOffset.UtcNow; var expiresAt = acquiredAt.Add(ttl); + var tableReference = GetQualifiedTableReference(); 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) ON CONFLICT (lease_name) DO UPDATE SET holder_id = EXCLUDED.holder_id, - version = public.app_runtime_leases.version + 1, + version = {tableReference}.version + 1, acquired_at_utc = EXCLUDED.acquired_at_utc, expires_at_utc = EXCLUDED.expires_at_utc - WHERE public.app_runtime_leases.expires_at_utc < EXCLUDED.acquired_at_utc - OR public.app_runtime_leases.holder_id = EXCLUDED.holder_id + WHERE {tableReference}.expires_at_utc < EXCLUDED.acquired_at_utc + OR {tableReference}.holder_id = EXCLUDED.holder_id RETURNING holder_id; """, conn); @@ -54,6 +65,14 @@ public sealed class RuntimeLeaseServiceNpgsql( var winner = reader.GetString(0); return Result.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.InternalServerError(false, [ + $"Lease table '{qualifiedTableName}' was not found.", + "Create the table or set Schema/Table in the PostgreSQL connection provider." + ]); + } catch (Exception ex) { logger.LogError(ex, "TryAcquire lease failed for {LeaseName}", leaseName); return Result.InternalServerError(false, ["Lease acquire failed.", ex.Message]); @@ -63,16 +82,26 @@ public sealed class RuntimeLeaseServiceNpgsql( public async Task ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(leaseName)) return Result.BadRequest("leaseName is required."); + if (string.IsNullOrWhiteSpace(holderId)) 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 { - await using var conn = new NpgsqlConnection(connectionStringProvider.ConnectionString); - await conn.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var conn = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + var tableReference = GetQualifiedTableReference(); await using var cmd = new NpgsqlCommand( - """ - DELETE FROM public.app_runtime_leases + $""" + DELETE FROM {tableReference} WHERE lease_name = @name AND holder_id = @holder; """, conn); @@ -82,9 +111,32 @@ public sealed class RuntimeLeaseServiceNpgsql( await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); 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) { logger.LogWarning(ex, "Release lease failed for {LeaseName} (ignored).", leaseName); 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 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; + } } diff --git a/src/MaksIT.HAMode.Redis/RuntimeLeaseServiceRedis.cs b/src/MaksIT.HAMode.Redis/RuntimeLeaseServiceRedis.cs index 2af0ba2..3a4e417 100644 --- a/src/MaksIT.HAMode.Redis/RuntimeLeaseServiceRedis.cs +++ b/src/MaksIT.HAMode.Redis/RuntimeLeaseServiceRedis.cs @@ -10,7 +10,8 @@ namespace MaksIT.HAMode.Redis; /// public sealed class RuntimeLeaseServiceRedis( IRuntimeLeaseRedisConnectionProvider connectionProvider, - ILogger logger + ILogger logger, + IConnectionMultiplexer? sharedMultiplexer = null ) : IRuntimeLeaseService, IAsyncDisposable { private static readonly LuaScript AcquireScript = LuaScript.Prepare( """ @@ -33,16 +34,24 @@ public sealed class RuntimeLeaseServiceRedis( """); private readonly SemaphoreSlim _connectionLock = new(1, 1); - private ConnectionMultiplexer? _multiplexer; + private ConnectionMultiplexer? _ownedMultiplexer; public async Task> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(leaseName)) return Result.BadRequest(false, "leaseName is required."); + if (string.IsNullOrWhiteSpace(holderId)) return Result.BadRequest(false, "holderId is required."); + if (ttl <= TimeSpan.Zero) return Result.BadRequest(false, "ttl must be positive."); + if (sharedMultiplexer is null && string.IsNullOrWhiteSpace(connectionProvider.Configuration)) + return Result.BadRequest(false, "redis configuration is required."); + + if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix)) + return Result.BadRequest(false, "redis key prefix is required."); + try { var db = (await GetDatabaseAsync(cancellationToken).ConfigureAwait(false)).Database; var key = BuildKey(leaseName); @@ -62,9 +71,16 @@ public sealed class RuntimeLeaseServiceRedis( public async Task ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(leaseName)) return Result.BadRequest("leaseName is required."); + if (string.IsNullOrWhiteSpace(holderId)) 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 { var db = (await GetDatabaseAsync(cancellationToken).ConfigureAwait(false)).Database; var key = BuildKey(leaseName); @@ -80,17 +96,20 @@ public sealed class RuntimeLeaseServiceRedis( private string BuildKey(string leaseName) => $"{connectionProvider.KeyPrefix}{leaseName}"; 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()); await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (_multiplexer is not { IsConnected: true }) { - _multiplexer?.Dispose(); - _multiplexer = await ConnectionMultiplexer.ConnectAsync(connectionProvider.Configuration).ConfigureAwait(false); + if (_ownedMultiplexer is not { IsConnected: true }) { + _ownedMultiplexer?.Dispose(); + _ownedMultiplexer = await ConnectionMultiplexer.ConnectAsync(connectionProvider.Configuration).ConfigureAwait(false); } - return (_multiplexer, _multiplexer.GetDatabase()); + return (_ownedMultiplexer, _ownedMultiplexer.GetDatabase()); } finally { _connectionLock.Release(); @@ -98,12 +117,17 @@ public sealed class RuntimeLeaseServiceRedis( } public async ValueTask DisposeAsync() { + if (sharedMultiplexer is not null) { + _connectionLock.Dispose(); + return; + } + await _connectionLock.WaitAsync().ConfigureAwait(false); try { - if (_multiplexer is not null) { - await _multiplexer.CloseAsync(allowCommandsToComplete: false).ConfigureAwait(false); - _multiplexer.Dispose(); - _multiplexer = null; + if (_ownedMultiplexer is not null) { + await _ownedMultiplexer.CloseAsync(allowCommandsToComplete: false).ConfigureAwait(false); + _ownedMultiplexer.Dispose(); + _ownedMultiplexer = null; } } finally { diff --git a/src/MaksIT.HAMode.Tests/ConnectionProviderDefaultsTests.cs b/src/MaksIT.HAMode.Tests/ConnectionProviderDefaultsTests.cs index 43acb8c..a624e62 100644 --- a/src/MaksIT.HAMode.Tests/ConnectionProviderDefaultsTests.cs +++ b/src/MaksIT.HAMode.Tests/ConnectionProviderDefaultsTests.cs @@ -3,12 +3,31 @@ using MaksIT.HAMode.Abstractions; namespace MaksIT.HAMode.Tests; 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(provider); + } + [Fact] public void RedisProvider_UsesDefaultKeyPrefix() { IRuntimeLeaseRedisConnectionProvider provider = new TestRedisProvider(); Assert.Equal("app_runtime_leases:", provider.KeyPrefix); } + [Fact] + public void RedisProvider_ImplementsRootConnectorInterface() { + IRuntimeLeaseConnectionProvider provider = new TestRedisProvider(); + Assert.IsAssignableFrom(provider); + } + [Fact] public void EtcdProvider_UsesDefaultKeyPrefixAndNullCredentials() { IRuntimeLeaseEtcdConnectionProvider provider = new TestEtcdProvider(); @@ -17,11 +36,9 @@ public sealed class ConnectionProviderDefaultsTests { Assert.Null(provider.Password); } - private sealed class TestRedisProvider : IRuntimeLeaseRedisConnectionProvider { - public string Configuration => "localhost:6379"; - } - - private sealed class TestEtcdProvider : IRuntimeLeaseEtcdConnectionProvider { - public string Endpoints => "http://localhost:2379"; + [Fact] + public void EtcdProvider_ImplementsRootConnectorInterface() { + IRuntimeLeaseConnectionProvider provider = new TestEtcdProvider(); + Assert.IsAssignableFrom(provider); } } diff --git a/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj b/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj index 6f06e32..6516ca4 100644 --- a/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj +++ b/src/MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj @@ -13,12 +13,14 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + @@ -26,6 +28,7 @@ + diff --git a/src/MaksIT.HAMode.Tests/RuntimeLeaseServiceValidationTests.cs b/src/MaksIT.HAMode.Tests/RuntimeLeaseServiceValidationTests.cs index 820c450..9434d1a 100644 --- a/src/MaksIT.HAMode.Tests/RuntimeLeaseServiceValidationTests.cs +++ b/src/MaksIT.HAMode.Tests/RuntimeLeaseServiceValidationTests.cs @@ -1,8 +1,8 @@ -using MaksIT.HAMode.Abstractions; using MaksIT.HAMode.Etcd; using MaksIT.HAMode.PostgreSql; using MaksIT.HAMode.Redis; using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; using System.Net; namespace MaksIT.HAMode.Tests; @@ -14,7 +14,7 @@ public sealed class RuntimeLeaseServiceValidationTests { [InlineData("")] [InlineData(" ")] public async Task PostgreSql_TryAcquire_InvalidLeaseName_ReturnsBadRequest(string leaseName) { - var service = new RuntimeLeaseServiceNpgsql(new PgProvider(), NullLogger.Instance); + var service = new RuntimeLeaseServiceNpgsql(new TestPgProvider(), NullLogger.Instance); var result = await service.TryAcquireAsync(leaseName, "holder", PositiveTtl, TestContext.Current.CancellationToken); Assert.False(result.IsSuccess); Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); @@ -22,15 +22,68 @@ public sealed class RuntimeLeaseServiceValidationTests { [Fact] public async Task PostgreSql_Release_InvalidHolder_ReturnsBadRequest() { - var service = new RuntimeLeaseServiceNpgsql(new PgProvider(), NullLogger.Instance); + var service = new RuntimeLeaseServiceNpgsql(new TestPgProvider(), NullLogger.Instance); var result = await service.ReleaseAsync("lease", "", TestContext.Current.CancellationToken); Assert.False(result.IsSuccess); Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); } + [Fact] + public async Task PostgreSql_TryAcquire_MissingConnectionString_ReturnsBadRequest() { + var service = new RuntimeLeaseServiceNpgsql( + new TestPgProvider { ConnectionString = "" }, + NullLogger.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.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.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.Instance, + dataSource); + + var result = await service.TryAcquireAsync("lease", "holder", PositiveTtl, TestContext.Current.CancellationToken); + + Assert.NotEqual(HttpStatusCode.BadRequest, result.StatusCode); + } + [Fact] public async Task Redis_TryAcquire_InvalidTtl_ReturnsBadRequest() { - var service = new RuntimeLeaseServiceRedis(new RedisProvider(), NullLogger.Instance); + var service = new RuntimeLeaseServiceRedis(new TestRedisProvider(), NullLogger.Instance); var result = await service.TryAcquireAsync("lease", "holder", TimeSpan.Zero, TestContext.Current.CancellationToken); Assert.False(result.IsSuccess); Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); @@ -39,16 +92,42 @@ public sealed class RuntimeLeaseServiceValidationTests { [Fact] public async Task Redis_Release_InvalidLeaseName_ReturnsBadRequest() { - var service = new RuntimeLeaseServiceRedis(new RedisProvider(), NullLogger.Instance); + var service = new RuntimeLeaseServiceRedis(new TestRedisProvider(), NullLogger.Instance); var result = await service.ReleaseAsync("", "holder", TestContext.Current.CancellationToken); Assert.False(result.IsSuccess); Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); await service.DisposeAsync(); } + [Fact] + public async Task Redis_TryAcquire_MissingConfiguration_ReturnsBadRequest() { + var service = new RuntimeLeaseServiceRedis( + new TestRedisProvider { Configuration = "" }, + NullLogger.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.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 Etcd_TryAcquire_InvalidHolder_ReturnsBadRequest() { - var service = new RuntimeLeaseServiceEtcd(new EtcdProvider(), NullLogger.Instance); + var service = new RuntimeLeaseServiceEtcd(new TestEtcdProvider(), NullLogger.Instance); var result = await service.TryAcquireAsync("lease", " ", PositiveTtl, TestContext.Current.CancellationToken); Assert.False(result.IsSuccess); Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); @@ -56,21 +135,33 @@ public sealed class RuntimeLeaseServiceValidationTests { [Fact] public async Task Etcd_Release_InvalidLeaseName_ReturnsBadRequest() { - var service = new RuntimeLeaseServiceEtcd(new EtcdProvider(), NullLogger.Instance); + var service = new RuntimeLeaseServiceEtcd(new TestEtcdProvider(), NullLogger.Instance); var result = await service.ReleaseAsync(" ", "holder", TestContext.Current.CancellationToken); Assert.False(result.IsSuccess); Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); } - private sealed class PgProvider : IRuntimeLeaseConnectionStringProvider { - public string ConnectionString => "Host=localhost;Port=5432;Database=hamode;Username=hamode;Password=hamode"; + [Fact] + public async Task Etcd_TryAcquire_MissingEndpoints_ReturnsBadRequest() { + var service = new RuntimeLeaseServiceEtcd( + new TestEtcdProvider { Endpoints = "" }, + NullLogger.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 { - public string Configuration => "localhost:6379"; - } + [Fact] + public async Task Etcd_TryAcquire_MissingKeyPrefix_ReturnsBadRequest() { + var service = new RuntimeLeaseServiceEtcd( + new TestEtcdProvider { KeyPrefix = "" }, + NullLogger.Instance); - private sealed class EtcdProvider : IRuntimeLeaseEtcdConnectionProvider { - public string Endpoints => "http://localhost:2379"; + var result = await service.TryAcquireAsync("lease", "holder", PositiveTtl, TestContext.Current.CancellationToken); + + Assert.False(result.IsSuccess); + Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); } } diff --git a/src/MaksIT.HAMode.Tests/ServiceCollectionExtensionsTests.cs b/src/MaksIT.HAMode.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..156d897 --- /dev/null +++ b/src/MaksIT.HAMode.Tests/ServiceCollectionExtensionsTests.cs @@ -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(); + + Assert.IsType(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()); + Assert.IsType(provider.GetRequiredService()); + Assert.IsType(provider.GetRequiredService()); + } + + [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()); + Assert.Same(dataSource, provider.GetRequiredService()); + Assert.IsType(provider.GetRequiredService()); + } + + [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()); + Assert.IsType(provider.GetRequiredService()); + } + + [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()); + Assert.Same(multiplexer, provider.GetRequiredService()); + Assert.IsType(provider.GetRequiredService()); + } + + [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()); + Assert.IsType(provider.GetRequiredService()); + } + + [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()); + Assert.Same(client, provider.GetRequiredService()); + Assert.IsType(provider.GetRequiredService()); + } +} diff --git a/src/MaksIT.HAMode.Tests/TestProviders.cs b/src/MaksIT.HAMode.Tests/TestProviders.cs new file mode 100644 index 0000000..8738ce9 --- /dev/null +++ b/src/MaksIT.HAMode.Tests/TestProviders.cs @@ -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/"; +} diff --git a/src/MaksIT.HAMode/Extensions/ServiceCollectionExtensions.cs b/src/MaksIT.HAMode/Extensions/ServiceCollectionExtensions.cs index 32b6241..e53ebea 100644 --- a/src/MaksIT.HAMode/Extensions/ServiceCollectionExtensions.cs +++ b/src/MaksIT.HAMode/Extensions/ServiceCollectionExtensions.cs @@ -1,8 +1,11 @@ +using dotnet_etcd; using MaksIT.HAMode.Abstractions; using MaksIT.HAMode.Etcd; using MaksIT.HAMode.PostgreSql; using MaksIT.HAMode.Redis; using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using StackExchange.Redis; namespace MaksIT.HAMode.Extensions; @@ -23,11 +26,43 @@ public static class ServiceCollectionExtensions { /// public static IServiceCollection AddHAModePostgreSqlLease(this IServiceCollection services) where TConnectionProvider : class, IRuntimeLeaseConnectionStringProvider { + ArgumentNullException.ThrowIfNull(services); + services.AddSingleton(); services.AddSingleton(); return services; } + /// + /// Registers only PostgreSQL-backed runtime lease service using a provided configuration instance. + /// + public static IServiceCollection AddHAModePostgreSqlLease(this IServiceCollection services, IRuntimeLeaseConnectionStringProvider configuration) { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddSingleton(configuration); + services.AddSingleton(); + return services; + } + + /// + /// Registers only PostgreSQL-backed runtime lease service using provided configuration and shared data source. + /// + 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(); + return services; + } + /// /// Registers full PostgreSQL HA mode scenario. /// @@ -38,16 +73,70 @@ public static class ServiceCollectionExtensions { .AddHAModePostgreSqlLease(); } + /// + /// Registers full PostgreSQL HA mode scenario using a provided configuration instance. + /// + public static IServiceCollection AddHAModePostgreSql(this IServiceCollection services, IRuntimeLeaseConnectionStringProvider configuration) { + return services + .AddHAModeRuntimeInstanceId() + .AddHAModePostgreSqlLease(configuration); + } + + /// + /// Registers full PostgreSQL HA mode scenario using provided configuration and shared data source. + /// + public static IServiceCollection AddHAModePostgreSql( + this IServiceCollection services, + IRuntimeLeaseConnectionStringProvider configuration, + NpgsqlDataSource dataSource + ) { + return services + .AddHAModeRuntimeInstanceId() + .AddHAModePostgreSqlLease(configuration, dataSource); + } + /// /// Registers only Redis-backed runtime lease service. /// public static IServiceCollection AddHAModeRedisLease(this IServiceCollection services) where TConnectionProvider : class, IRuntimeLeaseRedisConnectionProvider { + ArgumentNullException.ThrowIfNull(services); + services.AddSingleton(); services.AddSingleton(); return services; } + /// + /// Registers only Redis-backed runtime lease service using a provided configuration instance. + /// + public static IServiceCollection AddHAModeRedisLease(this IServiceCollection services, IRuntimeLeaseRedisConnectionProvider configuration) { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddSingleton(configuration); + services.AddSingleton(); + return services; + } + + /// + /// Registers only Redis-backed runtime lease service using provided configuration and shared multiplexer. + /// + 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(); + return services; + } + /// /// Registers full Redis HA mode scenario. /// @@ -58,16 +147,70 @@ public static class ServiceCollectionExtensions { .AddHAModeRedisLease(); } + /// + /// Registers full Redis HA mode scenario using a provided configuration instance. + /// + public static IServiceCollection AddHAModeRedis(this IServiceCollection services, IRuntimeLeaseRedisConnectionProvider configuration) { + return services + .AddHAModeRuntimeInstanceId() + .AddHAModeRedisLease(configuration); + } + + /// + /// Registers full Redis HA mode scenario using provided configuration and shared multiplexer. + /// + public static IServiceCollection AddHAModeRedis( + this IServiceCollection services, + IRuntimeLeaseRedisConnectionProvider configuration, + IConnectionMultiplexer multiplexer + ) { + return services + .AddHAModeRuntimeInstanceId() + .AddHAModeRedisLease(configuration, multiplexer); + } + /// /// Registers only etcd-backed runtime lease service. /// public static IServiceCollection AddHAModeEtcdLease(this IServiceCollection services) where TConnectionProvider : class, IRuntimeLeaseEtcdConnectionProvider { + ArgumentNullException.ThrowIfNull(services); + services.AddSingleton(); services.AddSingleton(); return services; } + /// + /// Registers only etcd-backed runtime lease service using a provided configuration instance. + /// + public static IServiceCollection AddHAModeEtcdLease(this IServiceCollection services, IRuntimeLeaseEtcdConnectionProvider configuration) { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddSingleton(configuration); + services.AddSingleton(); + return services; + } + + /// + /// Registers only etcd-backed runtime lease service using provided configuration and shared client. + /// + 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(); + return services; + } + /// /// Registers full etcd HA mode scenario. /// @@ -77,4 +220,26 @@ public static class ServiceCollectionExtensions { .AddHAModeRuntimeInstanceId() .AddHAModeEtcdLease(); } + + /// + /// Registers full etcd HA mode scenario using a provided configuration instance. + /// + public static IServiceCollection AddHAModeEtcd(this IServiceCollection services, IRuntimeLeaseEtcdConnectionProvider configuration) { + return services + .AddHAModeRuntimeInstanceId() + .AddHAModeEtcdLease(configuration); + } + + /// + /// Registers full etcd HA mode scenario using provided configuration and shared client. + /// + public static IServiceCollection AddHAModeEtcd( + this IServiceCollection services, + IRuntimeLeaseEtcdConnectionProvider configuration, + EtcdClient client + ) { + return services + .AddHAModeRuntimeInstanceId() + .AddHAModeEtcdLease(configuration, client); + } } diff --git a/src/MaksIT.HAMode/MaksIT.HAMode.csproj b/src/MaksIT.HAMode/MaksIT.HAMode.csproj index 56ff5f4..768e760 100644 --- a/src/MaksIT.HAMode/MaksIT.HAMode.csproj +++ b/src/MaksIT.HAMode/MaksIT.HAMode.csproj @@ -9,7 +9,7 @@ true MaksIT.HAMode - 1.0.1 + 1.0.2 Maksym Sadovnychyy MAKS-IT MaksIT.HAMode