mirror of
https://github.com/MAKS-IT-COM/maksit-hamode.git
synced 2026-07-01 23:06:41 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2935aaab19 | ||
|
|
a3b4778a15 | ||
|
|
147461edd8 | ||
|
|
943460bf95 | ||
|
|
f48e338012 | ||
|
|
0efd79e567 |
43
CHANGELOG.md
43
CHANGELOG.md
@ -5,6 +5,49 @@ 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
|
||||
- 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
|
||||
|
||||
### Added
|
||||
|
||||
107
README.md
107
README.md
@ -6,10 +6,12 @@ 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`
|
||||
- `IRuntimeLeaseService`
|
||||
- `IRuntimeLeaseConnectionProvider` (root marker interface)
|
||||
- `IRuntimeLeaseConnectionStringProvider`
|
||||
- `IRuntimeLeaseRedisConnectionProvider`
|
||||
- `IRuntimeLeaseEtcdConnectionProvider`
|
||||
@ -32,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)
|
||||
@ -40,70 +43,122 @@ 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
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MaksIT.HAMode" Version="0.1.0" />
|
||||
<PackageReference Include="MaksIT.HAMode" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
### Shared runtime instance id
|
||||
|
||||
```csharp
|
||||
using MaksIT.HAMode.Abstractions;
|
||||
using MaksIT.HAMode.Extensions;
|
||||
|
||||
builder.Services.AddSingleton<IRuntimeInstanceId, RuntimeInstanceIdProvider>();
|
||||
builder.Services.AddHAModeRuntimeInstanceId();
|
||||
```
|
||||
|
||||
### PostgreSQL backend
|
||||
|
||||
```csharp
|
||||
using MaksIT.HAMode.Abstractions;
|
||||
using MaksIT.HAMode.PostgreSql;
|
||||
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.AddSingleton<IRuntimeLeaseConnectionStringProvider, MyPgLeaseConnectionProvider>();
|
||||
builder.Services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>();
|
||||
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
|
||||
|
||||
```csharp
|
||||
using MaksIT.HAMode.Abstractions;
|
||||
using MaksIT.HAMode.Redis;
|
||||
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.AddSingleton<IRuntimeLeaseRedisConnectionProvider, MyRedisLeaseConnectionProvider>();
|
||||
builder.Services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceRedis>();
|
||||
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
|
||||
|
||||
```csharp
|
||||
using MaksIT.HAMode.Abstractions;
|
||||
using MaksIT.HAMode.Etcd;
|
||||
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.AddSingleton<IRuntimeLeaseEtcdConnectionProvider, MyEtcdLeaseConnectionProvider>();
|
||||
builder.Services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceEtcd>();
|
||||
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
|
||||
@ -147,7 +202,7 @@ In `MaksIT.Vault.Engine.csproj`:
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MaksIT.HAMode" Version="0.1.0" />
|
||||
<PackageReference Include="MaksIT.HAMode" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
@ -209,7 +264,7 @@ In `MaksIT.CertsUI.Engine.csproj`:
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MaksIT.HAMode" Version="0.1.0" />
|
||||
<PackageReference Include="MaksIT.HAMode" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 28.3%">
|
||||
<title>Branch Coverage: 28.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">28.3%</text>
|
||||
<text x="128.75" y="14" fill="#fff">28.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 |
@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 27.4%">
|
||||
<title>Line Coverage: 27.4%</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"/>
|
||||
@ -9,13 +9,13 @@
|
||||
</clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<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)"/>
|
||||
</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="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">27.4%</text>
|
||||
<text x="115.75" y="14" fill="#fff">27.4%</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 |
@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 81.8%">
|
||||
<title>Method Coverage: 81.8%</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"/>
|
||||
@ -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="#4c1"/>
|
||||
<rect x="107.5" width="42.5" height="20" fill="#97ca00"/>
|
||||
<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">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 x="128.75" y="14" fill="#fff">81.8%</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 |
@ -1,9 +0,0 @@
|
||||
namespace MaksIT.HAMode.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Supplies a PostgreSQL connection string for runtime lease persistence.
|
||||
/// Kept as abstraction so host projects own configuration sources.
|
||||
/// </summary>
|
||||
public interface IRuntimeLeaseConnectionStringProvider {
|
||||
string ConnectionString { get; }
|
||||
}
|
||||
@ -1,45 +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" />
|
||||
</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>
|
||||
@ -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.33.2" />
|
||||
<PackageReference Include="MaksIT.Results" Version="2.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
||||
</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>
|
||||
@ -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="10.0.8" />
|
||||
<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>
|
||||
@ -1,90 +0,0 @@
|
||||
using MaksIT.HAMode.Abstractions;
|
||||
using MaksIT.Results;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace MaksIT.HAMode.PostgreSql;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL row lease implementation over public.app_runtime_leases.
|
||||
/// </summary>
|
||||
public sealed class RuntimeLeaseServiceNpgsql(
|
||||
IRuntimeLeaseConnectionStringProvider connectionStringProvider,
|
||||
ILogger<RuntimeLeaseServiceNpgsql> logger
|
||||
) : 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 (string.IsNullOrWhiteSpace(holderId))
|
||||
return Result<bool>.BadRequest(false, "holderId is required.");
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
return Result<bool>.BadRequest(false, "ttl must be positive.");
|
||||
|
||||
try {
|
||||
await using var conn = new NpgsqlConnection(connectionStringProvider.ConnectionString);
|
||||
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var acquiredAt = DateTimeOffset.UtcNow;
|
||||
var expiresAt = acquiredAt.Add(ttl);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO public.app_runtime_leases (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,
|
||||
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
|
||||
RETURNING holder_id;
|
||||
""",
|
||||
conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("name", leaseName);
|
||||
cmd.Parameters.AddWithValue("holder", holderId);
|
||||
cmd.Parameters.AddWithValue("acquired", acquiredAt);
|
||||
cmd.Parameters.AddWithValue("expires", expiresAt);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
return Result<bool>.Ok(false);
|
||||
|
||||
var winner = reader.GetString(0);
|
||||
return Result<bool>.Ok(string.Equals(winner, holderId, StringComparison.Ordinal));
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, "TryAcquire lease failed for {LeaseName}", leaseName);
|
||||
return Result<bool>.InternalServerError(false, ["Lease acquire failed.", ex.Message]);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> 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.");
|
||||
|
||||
try {
|
||||
await using var conn = new NpgsqlConnection(connectionStringProvider.ConnectionString);
|
||||
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"""
|
||||
DELETE FROM public.app_runtime_leases
|
||||
WHERE lease_name = @name AND holder_id = @holder;
|
||||
""",
|
||||
conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("name", leaseName);
|
||||
cmd.Parameters.AddWithValue("holder", holderId);
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Release lease failed for {LeaseName} (ignored).", leaseName);
|
||||
return Result.InternalServerError(["Lease release failed.", ex.Message]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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="10.0.8" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.58" />
|
||||
</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>
|
||||
@ -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<IRuntimeLeaseConnectionStringProvider>(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<IRuntimeLeaseRedisConnectionProvider>(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<IRuntimeLeaseEtcdConnectionProvider>(provider);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,9 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<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>
|
||||
@ -22,10 +24,7 @@
|
||||
</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>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
using MaksIT.HAMode.Abstractions;
|
||||
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;
|
||||
@ -14,7 +16,7 @@ public sealed class RuntimeLeaseServiceValidationTests {
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
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);
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
|
||||
@ -22,15 +24,68 @@ public sealed class RuntimeLeaseServiceValidationTests {
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
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<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]
|
||||
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);
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
|
||||
@ -39,16 +94,42 @@ public sealed class RuntimeLeaseServiceValidationTests {
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
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<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]
|
||||
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);
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
|
||||
@ -56,21 +137,60 @@ public sealed class RuntimeLeaseServiceValidationTests {
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
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<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 {
|
||||
public string Configuration => "localhost:6379";
|
||||
[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();
|
||||
}
|
||||
|
||||
private sealed class EtcdProvider : IRuntimeLeaseEtcdConnectionProvider {
|
||||
public string Endpoints => "http://localhost:2379";
|
||||
[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(
|
||||
new TestEtcdProvider { KeyPrefix = "" },
|
||||
NullLogger<RuntimeLeaseServiceEtcd>.Instance);
|
||||
|
||||
var result = await service.TryAcquireAsync("lease", "holder", PositiveTtl, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
197
src/MaksIT.HAMode.Tests/ServiceCollectionExtensionsTests.cs
Normal file
197
src/MaksIT.HAMode.Tests/ServiceCollectionExtensionsTests.cs
Normal file
@ -0,0 +1,197 @@
|
||||
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);
|
||||
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]
|
||||
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>());
|
||||
}
|
||||
}
|
||||
21
src/MaksIT.HAMode.Tests/TestProviders.cs
Normal file
21
src/MaksIT.HAMode.Tests/TestProviders.cs
Normal 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/";
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
@ -0,0 +1,16 @@
|
||||
namespace MaksIT.HAMode.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Supplies a PostgreSQL connection string for runtime lease persistence.
|
||||
/// Kept as abstraction so host projects own configuration sources.
|
||||
/// </summary>
|
||||
public interface IRuntimeLeaseConnectionStringProvider : IRuntimeLeaseConnectionProvider {
|
||||
/// <summary>PostgreSQL connection string.</summary>
|
||||
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";
|
||||
}
|
||||
@ -3,7 +3,7 @@ namespace MaksIT.HAMode.Abstractions;
|
||||
/// <summary>
|
||||
/// Supplies etcd connection settings for runtime lease persistence.
|
||||
/// </summary>
|
||||
public interface IRuntimeLeaseEtcdConnectionProvider {
|
||||
public interface IRuntimeLeaseEtcdConnectionProvider : IRuntimeLeaseConnectionProvider {
|
||||
/// <summary>Comma-separated etcd endpoint list (for example "http://localhost:2379").</summary>
|
||||
string Endpoints { get; }
|
||||
|
||||
@ -3,7 +3,7 @@ namespace MaksIT.HAMode.Abstractions;
|
||||
/// <summary>
|
||||
/// Supplies Redis connection settings for runtime lease persistence.
|
||||
/// </summary>
|
||||
public interface IRuntimeLeaseRedisConnectionProvider {
|
||||
public interface IRuntimeLeaseRedisConnectionProvider : IRuntimeLeaseConnectionProvider {
|
||||
/// <summary>StackExchange.Redis configuration string.</summary>
|
||||
string Configuration { get; }
|
||||
|
||||
112
src/MaksIT.HAMode/Abstractions/LeaseInputValidation.cs
Normal file
112
src/MaksIT.HAMode/Abstractions/LeaseInputValidation.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
24
src/MaksIT.HAMode/Abstractions/LeaseResultErrors.cs
Normal file
24
src/MaksIT.HAMode/Abstractions/LeaseResultErrors.cs
Normal 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."
|
||||
]);
|
||||
}
|
||||
@ -12,20 +12,21 @@ namespace MaksIT.HAMode.Etcd;
|
||||
/// </summary>
|
||||
public sealed class RuntimeLeaseServiceEtcd(
|
||||
IRuntimeLeaseEtcdConnectionProvider connectionProvider,
|
||||
ILogger<RuntimeLeaseServiceEtcd> logger
|
||||
ILogger<RuntimeLeaseServiceEtcd> logger,
|
||||
EtcdClient? sharedClient = null
|
||||
) : IRuntimeLeaseService {
|
||||
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));
|
||||
: new EtcdClient(connectionProvider.Endpoints)));
|
||||
|
||||
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 (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 (LeaseInputValidation.ValidateAcquireInputs(leaseName, holderId, ttl) is { } acquireValidation)
|
||||
return acquireValidation;
|
||||
|
||||
if (LeaseInputValidation.ValidateEtcdProvider(connectionProvider, sharedClient is not null) is { } providerValidation)
|
||||
return providerValidation;
|
||||
|
||||
try {
|
||||
var key = BuildKey(leaseName);
|
||||
@ -80,15 +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 (string.IsNullOrWhiteSpace(holderId))
|
||||
return Result.BadRequest("holderId is required.");
|
||||
if (LeaseInputValidation.ValidateReleaseInputs(leaseName, holderId) is { } releaseValidation)
|
||||
return releaseValidation;
|
||||
|
||||
if (LeaseInputValidation.ValidateEtcdProviderForRelease(connectionProvider, sharedClient is not null) is { } providerValidation)
|
||||
return providerValidation;
|
||||
|
||||
try {
|
||||
var keyBytes = ByteString.CopyFromUtf8(BuildKey(leaseName));
|
||||
@ -113,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);
|
||||
}
|
||||
}
|
||||
|
||||
245
src/MaksIT.HAMode/Extensions/ServiceCollectionExtensions.cs
Normal file
245
src/MaksIT.HAMode/Extensions/ServiceCollectionExtensions.cs
Normal file
@ -0,0 +1,245 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// DI registration helpers for HAMode abstractions and backend implementations.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions {
|
||||
/// <summary>
|
||||
/// Registers default runtime instance id provider as singleton.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddHAModeRuntimeInstanceId(this IServiceCollection services) {
|
||||
services.AddSingleton<IRuntimeInstanceId, RuntimeInstanceIdProvider>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers only PostgreSQL-backed runtime lease service.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddHAModePostgreSqlLease<TConnectionProvider>(this IServiceCollection services)
|
||||
where TConnectionProvider : class, IRuntimeLeaseConnectionStringProvider {
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton<IRuntimeLeaseConnectionStringProvider, TConnectionProvider>();
|
||||
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>();
|
||||
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>
|
||||
/// Registers full PostgreSQL HA mode scenario.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddHAModePostgreSql<TConnectionProvider>(this IServiceCollection services)
|
||||
where TConnectionProvider : class, IRuntimeLeaseConnectionStringProvider {
|
||||
return services
|
||||
.AddHAModeRuntimeInstanceId()
|
||||
.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>
|
||||
/// Registers only Redis-backed runtime lease service.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddHAModeRedisLease<TConnectionProvider>(this IServiceCollection services)
|
||||
where TConnectionProvider : class, IRuntimeLeaseRedisConnectionProvider {
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton<IRuntimeLeaseRedisConnectionProvider, TConnectionProvider>();
|
||||
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceRedis>();
|
||||
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>
|
||||
/// Registers full Redis HA mode scenario.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddHAModeRedis<TConnectionProvider>(this IServiceCollection services)
|
||||
where TConnectionProvider : class, IRuntimeLeaseRedisConnectionProvider {
|
||||
return services
|
||||
.AddHAModeRuntimeInstanceId()
|
||||
.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>
|
||||
/// Registers only etcd-backed runtime lease service.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddHAModeEtcdLease<TConnectionProvider>(this IServiceCollection services)
|
||||
where TConnectionProvider : class, IRuntimeLeaseEtcdConnectionProvider {
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton<IRuntimeLeaseEtcdConnectionProvider, TConnectionProvider>();
|
||||
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceEtcd>();
|
||||
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>
|
||||
/// Registers full etcd HA mode scenario.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddHAModeEtcd<TConnectionProvider>(this IServiceCollection services)
|
||||
where TConnectionProvider : class, IRuntimeLeaseEtcdConnectionProvider {
|
||||
return services
|
||||
.AddHAModeRuntimeInstanceId()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@ -6,10 +6,9 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
|
||||
<PackageId>MaksIT.HAMode</PackageId>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>1.0.6</Version>
|
||||
<Authors>Maksym Sadovnychyy</Authors>
|
||||
<Company>MAKS-IT</Company>
|
||||
<Product>MaksIT.HAMode</Product>
|
||||
@ -31,35 +30,15 @@
|
||||
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="dotnet-etcd" Version="8.1.0" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.33.2" />
|
||||
<PackageReference Include="MaksIT.Results" Version="2.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.35.1" />
|
||||
<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="2.8.58" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\MaksIT.HAMode.Abstractions\bin\$(Configuration)\$(TargetFramework)\MaksIT.HAMode.Abstractions.*"
|
||||
Pack="true"
|
||||
PackagePath="lib\$(TargetFramework)\" />
|
||||
<None Include="..\MaksIT.HAMode.PostgreSql\bin\$(Configuration)\$(TargetFramework)\MaksIT.HAMode.PostgreSql.*"
|
||||
Pack="true"
|
||||
PackagePath="lib\$(TargetFramework)\" />
|
||||
<None Include="..\MaksIT.HAMode.Redis\bin\$(Configuration)\$(TargetFramework)\MaksIT.HAMode.Redis.*"
|
||||
Pack="true"
|
||||
PackagePath="lib\$(TargetFramework)\" />
|
||||
<None Include="..\MaksIT.HAMode.Etcd\bin\$(Configuration)\$(TargetFramework)\MaksIT.HAMode.Etcd.*"
|
||||
Pack="true"
|
||||
PackagePath="lib\$(TargetFramework)\" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="3.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
116
src/MaksIT.HAMode/PostgreSql/RuntimeLeaseServiceNpgsql.cs
Normal file
116
src/MaksIT.HAMode/PostgreSql/RuntimeLeaseServiceNpgsql.cs
Normal file
@ -0,0 +1,116 @@
|
||||
using MaksIT.HAMode.Abstractions;
|
||||
using MaksIT.Results;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace MaksIT.HAMode.PostgreSql;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL row lease implementation over public.app_runtime_leases.
|
||||
/// </summary>
|
||||
public sealed class RuntimeLeaseServiceNpgsql(
|
||||
IRuntimeLeaseConnectionStringProvider connectionStringProvider,
|
||||
ILogger<RuntimeLeaseServiceNpgsql> logger,
|
||||
NpgsqlDataSource? dataSource = null
|
||||
) : IRuntimeLeaseService {
|
||||
public async Task<Result<bool>> TryAcquireAsync(string leaseName, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default) {
|
||||
if (LeaseInputValidation.ValidateAcquireInputs(leaseName, holderId, ttl) is { } acquireValidation)
|
||||
return acquireValidation;
|
||||
|
||||
if (LeaseInputValidation.ValidatePostgreSqlProvider(connectionStringProvider, dataSource is not null) is { } providerValidation)
|
||||
return providerValidation;
|
||||
|
||||
try {
|
||||
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 {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 = {tableReference}.version + 1,
|
||||
acquired_at_utc = EXCLUDED.acquired_at_utc,
|
||||
expires_at_utc = EXCLUDED.expires_at_utc
|
||||
WHERE {tableReference}.expires_at_utc < EXCLUDED.acquired_at_utc
|
||||
OR {tableReference}.holder_id = EXCLUDED.holder_id
|
||||
RETURNING holder_id;
|
||||
""",
|
||||
conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("name", leaseName);
|
||||
cmd.Parameters.AddWithValue("holder", holderId);
|
||||
cmd.Parameters.AddWithValue("acquired", acquiredAt);
|
||||
cmd.Parameters.AddWithValue("expires", expiresAt);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
return Result<bool>.Ok(false);
|
||||
|
||||
var winner = reader.GetString(0);
|
||||
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 LeaseResultErrors.AcquireTableMissing(qualifiedTableName);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, "TryAcquire lease failed for {LeaseName}", leaseName);
|
||||
return LeaseResultErrors.AcquireFailed(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> ReleaseAsync(string leaseName, string holderId, CancellationToken cancellationToken = default) {
|
||||
if (LeaseInputValidation.ValidateReleaseInputs(leaseName, holderId) is { } releaseValidation)
|
||||
return releaseValidation;
|
||||
|
||||
if (LeaseInputValidation.ValidatePostgreSqlProviderForRelease(connectionStringProvider, dataSource is not null) is { } providerValidation)
|
||||
return providerValidation;
|
||||
|
||||
try {
|
||||
await using var conn = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var tableReference = GetQualifiedTableReference();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"""
|
||||
DELETE FROM {tableReference}
|
||||
WHERE lease_name = @name AND holder_id = @holder;
|
||||
""",
|
||||
conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("name", leaseName);
|
||||
cmd.Parameters.AddWithValue("holder", holderId);
|
||||
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 LeaseResultErrors.ReleaseTableMissing(qualifiedTableName);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Release lease failed for {LeaseName} (ignored).", leaseName);
|
||||
return LeaseResultErrors.ReleaseFailed(ex);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,8 @@ namespace MaksIT.HAMode.Redis;
|
||||
/// </summary>
|
||||
public sealed class RuntimeLeaseServiceRedis(
|
||||
IRuntimeLeaseRedisConnectionProvider connectionProvider,
|
||||
ILogger<RuntimeLeaseServiceRedis> logger
|
||||
ILogger<RuntimeLeaseServiceRedis> logger,
|
||||
IConnectionMultiplexer? sharedMultiplexer = null
|
||||
) : IRuntimeLeaseService, IAsyncDisposable {
|
||||
private static readonly LuaScript AcquireScript = LuaScript.Prepare(
|
||||
"""
|
||||
@ -33,15 +34,14 @@ public sealed class RuntimeLeaseServiceRedis(
|
||||
""");
|
||||
|
||||
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) {
|
||||
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.");
|
||||
if (LeaseInputValidation.ValidateAcquireInputs(leaseName, holderId, ttl) is { } acquireValidation)
|
||||
return acquireValidation;
|
||||
|
||||
if (LeaseInputValidation.ValidateRedisProvider(connectionProvider, sharedMultiplexer is not null) is { } providerValidation)
|
||||
return providerValidation;
|
||||
|
||||
try {
|
||||
var db = (await GetDatabaseAsync(cancellationToken).ConfigureAwait(false)).Database;
|
||||
@ -55,15 +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 (string.IsNullOrWhiteSpace(holderId))
|
||||
return Result.BadRequest("holderId is required.");
|
||||
if (LeaseInputValidation.ValidateReleaseInputs(leaseName, holderId) is { } releaseValidation)
|
||||
return releaseValidation;
|
||||
|
||||
if (LeaseInputValidation.ValidateRedisProviderForRelease(connectionProvider, sharedMultiplexer is not null) is { } providerValidation)
|
||||
return providerValidation;
|
||||
|
||||
try {
|
||||
var db = (await GetDatabaseAsync(cancellationToken).ConfigureAwait(false)).Database;
|
||||
@ -73,24 +74,27 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
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 +102,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 {
|
||||
Loading…
Reference in New Issue
Block a user