Compare commits

...

4 Commits
v1.0.2 ... main

26 changed files with 332 additions and 326 deletions

View File

@ -5,6 +5,29 @@ 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.6] - 2026-06-28
### Fixed
- Replaced `Microsoft.Extensions.DependencyInjection.Abstractions` and `Microsoft.Extensions.Logging.Abstractions` preview packages (`11.0.0-preview.5.26302.115`) that were incorrectly introduced in `1.0.5` with stable releases (`10.0.9`).
## [1.0.5] - 2026-06-28
### Changed
- Centralized lease input validation and provider-configuration checks in shared `LeaseInputValidation` helpers used by PostgreSQL, Redis, and etcd lease services.
- Centralized lease failure result mapping in `LeaseResultErrors`, using `MaksIT.Core` exception message extraction for acquire/release errors.
- Updated dependencies: `MaksIT.Core` (`1.6.8`), `MaksIT.Results` (`2.0.3`), `Microsoft.Extensions.DependencyInjection.Abstractions` and `Microsoft.Extensions.Logging.Abstractions` (`11.0.0-preview.5.26302.115`), and `StackExchange.Redis` (`3.0.7`).
## [1.0.4] - 2026-06-20
### Changed
- Replaced preview dependencies with latest stable releases: `Microsoft.Extensions.DependencyInjection.Abstractions` and `Microsoft.Extensions.Logging.Abstractions` (`10.0.8`), and `StackExchange.Redis` (`2.8.58`).
## [1.0.3] - 2026-06-20
### Changed
- Consolidated source into a single `MaksIT.HAMode` project and assembly; removed internal multi-project packaging shell and manual DLL bundling.
- Namespaces are unchanged (`MaksIT.HAMode.Abstractions`, `MaksIT.HAMode.PostgreSql`, etc.) so consumers can upgrade without code changes.
## [1.0.2] - 2026-06-20
### Changed

View File

@ -6,7 +6,7 @@ Reusable high-availability runtime coordination library for MaksIT services.
## Packages
- `MaksIT.HAMode` (single NuGet package)
- `MaksIT.HAMode` (single NuGet package, single assembly)
- `MaksIT.HAMode.Extensions.ServiceCollectionExtensions`
- `MaksIT.HAMode.Abstractions` namespace:
- `IRuntimeInstanceId`

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 37.3%">
<title>Branch Coverage: 37.3%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 49.1%">
<title>Branch Coverage: 49.1%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@ -9,13 +9,13 @@
</clipPath>
<g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="42.5" height="20" fill="#dfb317"/>
<rect x="107.5" width="42.5" height="20" fill="#a4a61d"/>
<rect width="150" height="20" fill="url(#s)"/>
</g>
<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 x="53.75" y="14" fill="#fff">Branch Coverage</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">37.3%</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">49.1%</text>
<text x="128.75" y="14" fill="#fff">49.1%</text>
</g>
</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: 41.1%">
<title>Line Coverage: 41.1%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 57.1%">
<title>Line Coverage: 57.1%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" 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">
<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 aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">41.1%</text>
<text x="115.75" y="14" fill="#fff">41.1%</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">57.1%</text>
<text x="115.75" y="14" fill="#fff">57.1%</text>
</g>
</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: 60.9%">
<title>Method Coverage: 60.9%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 70.7%">
<title>Method Coverage: 70.7%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" 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">
<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 aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">60.9%</text>
<text x="128.75" y="14" fill="#fff">60.9%</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">70.7%</text>
<text x="128.75" y="14" fill="#fff">70.7%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,46 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<PackageId>MaksIT.HAMode.Abstractions</PackageId>
<Version>0.1.0</Version>
<Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company>
<Product>MaksIT.HAMode</Product>
<Copyright>Copyright © Maksym Sadovnychyy (MAKS-IT)</Copyright>
<Description>Shared high-availability abstractions for MaksIT services, including runtime instance identity and lease coordination contracts.</Description>
<PackageTags>dotnet;ha;high-availability;runtime;lease;coordination;postgresql;kubernetes</PackageTags>
<PackageProjectUrl>https://github.com/MAKS-IT-COM/maksit-hamode</PackageProjectUrl>
<RepositoryUrl>https://github.com/MAKS-IT-COM/maksit-hamode</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
<PackageReleaseNotes>See CHANGELOG.md in the package and repository releases.</PackageReleaseNotes>
<RequireLicenseAcceptance>false</RequireLicenseAcceptance>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Results" Version="2.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="11.0.0-preview.5.26302.115" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Link="LICENSE.md" />
<None Include="..\..\README.md" Pack="true" PackagePath="\" Link="README.md" />
<None Include="..\..\CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
</ItemGroup>
</Project>

View File

@ -1,52 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<PackageId>MaksIT.HAMode.Etcd</PackageId>
<Version>0.1.0</Version>
<Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company>
<Product>MaksIT.HAMode</Product>
<Copyright>Copyright © Maksym Sadovnychyy (MAKS-IT)</Copyright>
<Description>etcd runtime lease implementation for MaksIT HA mode, using transactions and lease-backed keys.</Description>
<PackageTags>dotnet;ha;high-availability;runtime;lease;coordination;etcd;grpc;kubernetes</PackageTags>
<PackageProjectUrl>https://github.com/MAKS-IT-COM/maksit-hamode</PackageProjectUrl>
<RepositoryUrl>https://github.com/MAKS-IT-COM/maksit-hamode</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
<PackageReleaseNotes>See CHANGELOG.md in the package and repository releases.</PackageReleaseNotes>
<RequireLicenseAcceptance>false</RequireLicenseAcceptance>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="dotnet-etcd" Version="8.1.0" />
<PackageReference Include="Google.Protobuf" Version="3.35.1" />
<PackageReference Include="MaksIT.Results" Version="2.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="11.0.0-preview.5.26302.115" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MaksIT.HAMode.Abstractions\MaksIT.HAMode.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Link="LICENSE.md" />
<None Include="..\..\README.md" Pack="true" PackagePath="\" Link="README.md" />
<None Include="..\..\CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
</ItemGroup>
</Project>

View File

@ -1,51 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<PackageId>MaksIT.HAMode.PostgreSql</PackageId>
<Version>0.1.0</Version>
<Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company>
<Product>MaksIT.HAMode</Product>
<Copyright>Copyright © Maksym Sadovnychyy (MAKS-IT)</Copyright>
<Description>PostgreSQL runtime lease implementation for MaksIT HA mode, based on app_runtime_leases coordination table.</Description>
<PackageTags>dotnet;ha;high-availability;runtime;lease;coordination;postgresql;npgsql;kubernetes</PackageTags>
<PackageProjectUrl>https://github.com/MAKS-IT-COM/maksit-hamode</PackageProjectUrl>
<RepositoryUrl>https://github.com/MAKS-IT-COM/maksit-hamode</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
<PackageReleaseNotes>See CHANGELOG.md in the package and repository releases.</PackageReleaseNotes>
<RequireLicenseAcceptance>false</RequireLicenseAcceptance>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Results" Version="2.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="11.0.0-preview.5.26302.115" />
<PackageReference Include="Npgsql" Version="10.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MaksIT.HAMode.Abstractions\MaksIT.HAMode.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Link="LICENSE.md" />
<None Include="..\..\README.md" Pack="true" PackagePath="\" Link="README.md" />
<None Include="..\..\CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
</ItemGroup>
</Project>

View File

@ -1,51 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<PackageId>MaksIT.HAMode.Redis</PackageId>
<Version>0.1.0</Version>
<Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company>
<Product>MaksIT.HAMode</Product>
<Copyright>Copyright © Maksym Sadovnychyy (MAKS-IT)</Copyright>
<Description>Redis runtime lease implementation for MaksIT HA mode, using atomic Lua scripts and TTL-bound keys.</Description>
<PackageTags>dotnet;ha;high-availability;runtime;lease;coordination;redis;stackexchange-redis;kubernetes</PackageTags>
<PackageProjectUrl>https://github.com/MAKS-IT-COM/maksit-hamode</PackageProjectUrl>
<RepositoryUrl>https://github.com/MAKS-IT-COM/maksit-hamode</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
<PackageReleaseNotes>See CHANGELOG.md in the package and repository releases.</PackageReleaseNotes>
<RequireLicenseAcceptance>false</RequireLicenseAcceptance>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Results" Version="2.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="11.0.0-preview.5.26302.115" />
<PackageReference Include="StackExchange.Redis" Version="3.0.47-preview" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MaksIT.HAMode.Abstractions\MaksIT.HAMode.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Link="LICENSE.md" />
<None Include="..\..\README.md" Pack="true" PackagePath="\" Link="README.md" />
<None Include="..\..\CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
</ItemGroup>
</Project>

View File

@ -13,9 +13,9 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</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.Extensions.DependencyInjection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -24,10 +24,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MaksIT.HAMode.Abstractions\MaksIT.HAMode.Abstractions.csproj" />
<ProjectReference Include="..\MaksIT.HAMode.PostgreSql\MaksIT.HAMode.PostgreSql.csproj" />
<ProjectReference Include="..\MaksIT.HAMode.Redis\MaksIT.HAMode.Redis.csproj" />
<ProjectReference Include="..\MaksIT.HAMode.Etcd\MaksIT.HAMode.Etcd.csproj" />
<ProjectReference Include="..\MaksIT.HAMode\MaksIT.HAMode.csproj" />
</ItemGroup>

View File

@ -1,8 +1,10 @@
using MaksIT.HAMode.Etcd;
using MaksIT.HAMode.PostgreSql;
using MaksIT.HAMode.Redis;
using dotnet_etcd;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using StackExchange.Redis;
using System.Net;
namespace MaksIT.HAMode.Tests;
@ -153,6 +155,33 @@ public sealed class RuntimeLeaseServiceValidationTests {
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
}
[Fact]
public async Task Redis_TryAcquire_WithSharedMultiplexer_AllowsEmptyConfiguration() {
var multiplexer = ConnectionMultiplexer.Connect("127.0.0.1:63999,abortConnect=false,connectTimeout=1");
var service = new RuntimeLeaseServiceRedis(
new TestRedisProvider { Configuration = "" },
NullLogger<RuntimeLeaseServiceRedis>.Instance,
multiplexer);
var result = await service.TryAcquireAsync("lease", "holder", PositiveTtl, TestContext.Current.CancellationToken);
Assert.NotEqual(HttpStatusCode.BadRequest, result.StatusCode);
await service.DisposeAsync();
}
[Fact]
public async Task Etcd_TryAcquire_WithSharedClient_AllowsEmptyEndpoints() {
var client = new EtcdClient("http://127.0.0.1:2379");
var service = new RuntimeLeaseServiceEtcd(
new TestEtcdProvider { Endpoints = "" },
NullLogger<RuntimeLeaseServiceEtcd>.Instance,
client);
var result = await service.TryAcquireAsync("lease", "holder", PositiveTtl, TestContext.Current.CancellationToken);
Assert.NotEqual(HttpStatusCode.BadRequest, result.StatusCode);
}
[Fact]
public async Task Etcd_TryAcquire_MissingKeyPrefix_ReturnsBadRequest() {
var service = new RuntimeLeaseServiceEtcd(

View File

@ -20,6 +20,94 @@ public sealed class ServiceCollectionExtensionsTests {
var instanceId = provider.GetRequiredService<IRuntimeInstanceId>();
Assert.IsType<RuntimeInstanceIdProvider>(instanceId);
Assert.Same(typeof(RuntimeInstanceIdProvider).Assembly, instanceId.GetType().Assembly);
}
[Fact]
public void AddHAModePostgreSqlLease_WithGenericProvider_RegistersLeaseServiceOnly() {
var services = new ServiceCollection()
.AddLogging()
.AddHAModePostgreSqlLease<TestPgProvider>();
var provider = services.BuildServiceProvider();
Assert.IsType<TestPgProvider>(provider.GetRequiredService<IRuntimeLeaseConnectionStringProvider>());
Assert.IsType<RuntimeLeaseServiceNpgsql>(provider.GetRequiredService<IRuntimeLeaseService>());
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IRuntimeInstanceId>());
}
[Fact]
public void AddHAModeRedisLease_WithGenericProvider_RegistersLeaseServiceOnly() {
var services = new ServiceCollection()
.AddLogging()
.AddHAModeRedisLease<TestRedisProvider>();
var provider = services.BuildServiceProvider();
Assert.IsType<TestRedisProvider>(provider.GetRequiredService<IRuntimeLeaseRedisConnectionProvider>());
Assert.IsType<RuntimeLeaseServiceRedis>(provider.GetRequiredService<IRuntimeLeaseService>());
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IRuntimeInstanceId>());
}
[Fact]
public void AddHAModeEtcdLease_WithGenericProvider_RegistersLeaseServiceOnly() {
var services = new ServiceCollection()
.AddLogging()
.AddHAModeEtcdLease<TestEtcdProvider>();
var provider = services.BuildServiceProvider();
Assert.IsType<TestEtcdProvider>(provider.GetRequiredService<IRuntimeLeaseEtcdConnectionProvider>());
Assert.IsType<RuntimeLeaseServiceEtcd>(provider.GetRequiredService<IRuntimeLeaseService>());
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IRuntimeInstanceId>());
}
[Fact]
public void AddHAModePostgreSqlLease_WithSharedDataSource_RegistersLeaseServiceOnly() {
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()
.AddHAModePostgreSqlLease(configuration, dataSource);
var provider = services.BuildServiceProvider();
Assert.Same(configuration, provider.GetRequiredService<IRuntimeLeaseConnectionStringProvider>());
Assert.Same(dataSource, provider.GetRequiredService<NpgsqlDataSource>());
Assert.IsType<RuntimeLeaseServiceNpgsql>(provider.GetRequiredService<IRuntimeLeaseService>());
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IRuntimeInstanceId>());
}
[Fact]
public void AddHAModeRedisLease_WithSharedMultiplexer_RegistersLeaseServiceOnly() {
var configuration = new TestRedisProvider();
var multiplexer = ConnectionMultiplexer.Connect("127.0.0.1:63999,abortConnect=false,connectTimeout=1");
var services = new ServiceCollection()
.AddLogging()
.AddHAModeRedisLease(configuration, multiplexer);
var provider = services.BuildServiceProvider();
Assert.Same(configuration, provider.GetRequiredService<IRuntimeLeaseRedisConnectionProvider>());
Assert.Same(multiplexer, provider.GetRequiredService<IConnectionMultiplexer>());
Assert.IsType<RuntimeLeaseServiceRedis>(provider.GetRequiredService<IRuntimeLeaseService>());
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IRuntimeInstanceId>());
}
[Fact]
public void AddHAModeEtcdLease_WithSharedClient_RegistersLeaseServiceOnly() {
var configuration = new TestEtcdProvider();
var client = new EtcdClient("http://127.0.0.1:2379");
var services = new ServiceCollection()
.AddLogging()
.AddHAModeEtcdLease(configuration, client);
var provider = services.BuildServiceProvider();
Assert.Same(configuration, provider.GetRequiredService<IRuntimeLeaseEtcdConnectionProvider>());
Assert.Same(client, provider.GetRequiredService<EtcdClient>());
Assert.IsType<RuntimeLeaseServiceEtcd>(provider.GetRequiredService<IRuntimeLeaseService>());
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IRuntimeInstanceId>());
}
[Fact]

View File

@ -1,8 +1,4 @@
<Solution>
<Project Path="MaksIT.HAMode.Abstractions/MaksIT.HAMode.Abstractions.csproj" />
<Project Path="MaksIT.HAMode.Etcd/MaksIT.HAMode.Etcd.csproj" />
<Project Path="MaksIT.HAMode.PostgreSql/MaksIT.HAMode.PostgreSql.csproj" />
<Project Path="MaksIT.HAMode.Redis/MaksIT.HAMode.Redis.csproj" />
<Project Path="MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj" />
<Project Path="MaksIT.HAMode/MaksIT.HAMode.csproj" />
<Project Path="MaksIT.HAMode.Tests/MaksIT.HAMode.Tests.csproj" />
</Solution>

View File

@ -0,0 +1,112 @@
using MaksIT.Results;
namespace MaksIT.HAMode.Abstractions;
internal static class LeaseInputValidation {
internal static Result<bool>? ValidateAcquireInputs(string leaseName, string holderId, TimeSpan ttl) {
if (string.IsNullOrWhiteSpace(leaseName))
return Result<bool>.BadRequest(false, "leaseName is required.");
if (string.IsNullOrWhiteSpace(holderId))
return Result<bool>.BadRequest(false, "holderId is required.");
if (ttl <= TimeSpan.Zero)
return Result<bool>.BadRequest(false, "ttl must be positive.");
return null;
}
internal static Result? ValidateReleaseInputs(string leaseName, string holderId) {
if (string.IsNullOrWhiteSpace(leaseName))
return Result.BadRequest("leaseName is required.");
if (string.IsNullOrWhiteSpace(holderId))
return Result.BadRequest("holderId is required.");
return null;
}
internal static Result<bool>? ValidatePostgreSqlProvider(
IRuntimeLeaseConnectionStringProvider connectionStringProvider,
bool hasSharedDataSource
) {
if (!hasSharedDataSource && 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.");
return null;
}
internal static Result? ValidatePostgreSqlProviderForRelease(
IRuntimeLeaseConnectionStringProvider connectionStringProvider,
bool hasSharedDataSource
) {
if (!hasSharedDataSource && 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.");
return null;
}
internal static Result<bool>? ValidateRedisProvider(
IRuntimeLeaseRedisConnectionProvider connectionProvider,
bool hasSharedMultiplexer
) {
if (!hasSharedMultiplexer && 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.");
return null;
}
internal static Result? ValidateRedisProviderForRelease(
IRuntimeLeaseRedisConnectionProvider connectionProvider,
bool hasSharedMultiplexer
) {
if (!hasSharedMultiplexer && string.IsNullOrWhiteSpace(connectionProvider.Configuration))
return Result.BadRequest("redis configuration is required.");
if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix))
return Result.BadRequest("redis key prefix is required.");
return null;
}
internal static Result<bool>? ValidateEtcdProvider(
IRuntimeLeaseEtcdConnectionProvider connectionProvider,
bool hasSharedClient
) {
if (!hasSharedClient && 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.");
return null;
}
internal static Result? ValidateEtcdProviderForRelease(
IRuntimeLeaseEtcdConnectionProvider connectionProvider,
bool hasSharedClient
) {
if (!hasSharedClient && string.IsNullOrWhiteSpace(connectionProvider.Endpoints))
return Result.BadRequest("etcd endpoints are required.");
if (string.IsNullOrWhiteSpace(connectionProvider.KeyPrefix))
return Result.BadRequest("etcd key prefix is required.");
return null;
}
}

View File

@ -0,0 +1,24 @@
using MaksIT.Core.Extensions;
using MaksIT.Results;
namespace MaksIT.HAMode.Abstractions;
internal static class LeaseResultErrors {
internal static Result<bool> AcquireFailed(Exception exception) =>
Result<bool>.InternalServerError(false, ["Lease acquire failed.", .. exception.ExtractMessages()]);
internal static Result ReleaseFailed(Exception exception) =>
Result.InternalServerError(["Lease release failed.", .. exception.ExtractMessages()]);
internal static Result<bool> AcquireTableMissing(string qualifiedTableName) =>
Result<bool>.InternalServerError(false, [
$"Lease table '{qualifiedTableName}' was not found.",
"Create the table or set Schema/Table in the PostgreSQL connection provider."
]);
internal static Result ReleaseTableMissing(string qualifiedTableName) =>
Result.InternalServerError([
$"Lease table '{qualifiedTableName}' was not found.",
"Create the table or set Schema/Table in the PostgreSQL connection provider."
]);
}

View File

@ -22,20 +22,11 @@ public sealed class RuntimeLeaseServiceEtcd(
: new EtcdClient(connectionProvider.Endpoints)));
public async Task<Result<bool>> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(leaseName))
if (LeaseInputValidation.ValidateAcquireInputs(leaseName, holderId, ttl) is { } acquireValidation)
return acquireValidation;
return Result<bool>.BadRequest(false, "leaseName is required.");
if (string.IsNullOrWhiteSpace(holderId))
return Result<bool>.BadRequest(false, "holderId is required.");
if (ttl <= TimeSpan.Zero)
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.");
if (LeaseInputValidation.ValidateEtcdProvider(connectionProvider, sharedClient is not null) is { } providerValidation)
return providerValidation;
try {
var key = BuildKey(leaseName);
@ -90,22 +81,16 @@ public sealed class RuntimeLeaseServiceEtcd(
}
catch (Exception ex) {
logger.LogError(ex, "etcd TryAcquire lease failed for {LeaseName}", leaseName);
return Result<bool>.InternalServerError(false, ["Lease acquire failed.", ex.Message]);
return LeaseResultErrors.AcquireFailed(ex);
}
}
public async Task<Result> ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(leaseName))
return Result.BadRequest("leaseName is required.");
if (LeaseInputValidation.ValidateReleaseInputs(leaseName, holderId) is { } releaseValidation)
return releaseValidation;
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.");
if (LeaseInputValidation.ValidateEtcdProviderForRelease(connectionProvider, sharedClient is not null) is { } providerValidation)
return providerValidation;
try {
var keyBytes = ByteString.CopyFromUtf8(BuildKey(leaseName));
@ -130,7 +115,7 @@ public sealed class RuntimeLeaseServiceEtcd(
}
catch (Exception ex) {
logger.LogWarning(ex, "etcd Release lease failed for {LeaseName} (ignored).", leaseName);
return Result.InternalServerError(["Lease release failed.", ex.Message]);
return LeaseResultErrors.ReleaseFailed(ex);
}
}

View File

@ -6,10 +6,9 @@
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<IncludeBuildOutput>true</IncludeBuildOutput>
<PackageId>MaksIT.HAMode</PackageId>
<Version>1.0.2</Version>
<Version>1.0.6</Version>
<Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company>
<Product>MaksIT.HAMode</Product>
@ -31,20 +30,15 @@
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MaksIT.HAMode.Abstractions\MaksIT.HAMode.Abstractions.csproj" PrivateAssets="all" TreatAsPackageReference="false" />
<ProjectReference Include="..\MaksIT.HAMode.PostgreSql\MaksIT.HAMode.PostgreSql.csproj" PrivateAssets="all" TreatAsPackageReference="false" />
<ProjectReference Include="..\MaksIT.HAMode.Redis\MaksIT.HAMode.Redis.csproj" PrivateAssets="all" TreatAsPackageReference="false" />
<ProjectReference Include="..\MaksIT.HAMode.Etcd\MaksIT.HAMode.Etcd.csproj" PrivateAssets="all" TreatAsPackageReference="false" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="dotnet-etcd" Version="8.1.0" />
<PackageReference Include="Google.Protobuf" Version="3.35.1" />
<PackageReference Include="MaksIT.Results" Version="2.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="11.0.0-preview.5.26302.115" />
<PackageReference Include="MaksIT.Core" Version="1.6.8" />
<PackageReference Include="MaksIT.Results" Version="2.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageReference Include="Npgsql" Version="10.0.3" />
<PackageReference Include="StackExchange.Redis" Version="3.0.47-preview" />
<PackageReference Include="StackExchange.Redis" Version="3.0.7" />
</ItemGroup>
<ItemGroup>

View File

@ -14,22 +14,11 @@ public sealed class RuntimeLeaseServiceNpgsql(
NpgsqlDataSource? dataSource = null
) : IRuntimeLeaseService {
public async Task<Result<bool>> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(leaseName))
return Result<bool>.BadRequest(false, "leaseName is required.");
if (LeaseInputValidation.ValidateAcquireInputs(leaseName, holderId, ttl) is { } acquireValidation)
return acquireValidation;
if (string.IsNullOrWhiteSpace(holderId))
return Result<bool>.BadRequest(false, "holderId is required.");
if (ttl <= TimeSpan.Zero)
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.");
if (LeaseInputValidation.ValidatePostgreSqlProvider(connectionStringProvider, dataSource is not null) is { } providerValidation)
return providerValidation;
try {
await using var conn = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
@ -68,32 +57,20 @@ public sealed class RuntimeLeaseServiceNpgsql(
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."
]);
return LeaseResultErrors.AcquireTableMissing(qualifiedTableName);
}
catch (Exception ex) {
logger.LogError(ex, "TryAcquire lease failed for {LeaseName}", leaseName);
return Result<bool>.InternalServerError(false, ["Lease acquire failed.", ex.Message]);
return LeaseResultErrors.AcquireFailed(ex);
}
}
public async Task<Result> ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(leaseName))
return Result.BadRequest("leaseName is required.");
if (LeaseInputValidation.ValidateReleaseInputs(leaseName, holderId) is { } releaseValidation)
return releaseValidation;
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.");
if (LeaseInputValidation.ValidatePostgreSqlProviderForRelease(connectionStringProvider, dataSource is not null) is { } providerValidation)
return providerValidation;
try {
await using var conn = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
@ -114,14 +91,11 @@ public sealed class RuntimeLeaseServiceNpgsql(
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."
]);
return LeaseResultErrors.ReleaseTableMissing(qualifiedTableName);
}
catch (Exception ex) {
logger.LogWarning(ex, "Release lease failed for {LeaseName} (ignored).", leaseName);
return Result.InternalServerError(["Lease release failed.", ex.Message]);
return LeaseResultErrors.ReleaseFailed(ex);
}
}

View File

@ -37,20 +37,11 @@ public sealed class RuntimeLeaseServiceRedis(
private ConnectionMultiplexer? _ownedMultiplexer;
public async Task<Result<bool>> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(leaseName))
return Result<bool>.BadRequest(false, "leaseName is required.");
if (LeaseInputValidation.ValidateAcquireInputs(leaseName, holderId, ttl) is { } acquireValidation)
return acquireValidation;
if (string.IsNullOrWhiteSpace(holderId))
return Result<bool>.BadRequest(false, "holderId is required.");
if (ttl <= TimeSpan.Zero)
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.");
if (LeaseInputValidation.ValidateRedisProvider(connectionProvider, sharedMultiplexer is not null) is { } providerValidation)
return providerValidation;
try {
var db = (await GetDatabaseAsync(cancellationToken).ConfigureAwait(false)).Database;
@ -64,22 +55,16 @@ public sealed class RuntimeLeaseServiceRedis(
}
catch (Exception ex) {
logger.LogError(ex, "Redis TryAcquire lease failed for {LeaseName}", leaseName);
return Result<bool>.InternalServerError(false, ["Lease acquire failed.", ex.Message]);
return LeaseResultErrors.AcquireFailed(ex);
}
}
public async Task<Result> ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(leaseName))
return Result.BadRequest("leaseName is required.");
if (LeaseInputValidation.ValidateReleaseInputs(leaseName, holderId) is { } releaseValidation)
return releaseValidation;
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.");
if (LeaseInputValidation.ValidateRedisProviderForRelease(connectionProvider, sharedMultiplexer is not null) is { } providerValidation)
return providerValidation;
try {
var db = (await GetDatabaseAsync(cancellationToken).ConfigureAwait(false)).Database;
@ -89,7 +74,7 @@ public sealed class RuntimeLeaseServiceRedis(
}
catch (Exception ex) {
logger.LogWarning(ex, "Redis Release lease failed for {LeaseName} (ignored).", leaseName);
return Result.InternalServerError(["Lease release failed.", ex.Message]);
return LeaseResultErrors.ReleaseFailed(ex);
}
}