feat(admin): wrap LdapGroupRoleMappingService in Phase 6.1-style resilience pipeline (Phase 6.2 Stream A.2)
Add ResilientLdapGroupRoleMappingService — a singleton decorator that wraps the hot-path GetByGroupsAsync call in a Polly pipeline (timeout 2s → retry 3× jittered → fallback to in-memory sealed snapshot) so a transient Config DB outage at Admin sign-in falls back to the last-known-good mapping set rather than denying every login. The static LdapOptions.GroupToRole bootstrap dictionary in AdminRoleGrantResolver remains the lock-out-proof floor regardless of DB state. DI wiring uses keyed services: LdapGroupRoleMappingService (EF, scoped) is registered under key "LdapGroupRoleMappingService.Inner"; the resilient singleton decorator is the primary ILdapGroupRoleMappingService binding. The singleton avoids the captive-dependency anti-pattern by using IServiceScopeFactory to open a short-lived scope for each DB call. Write methods (CreateAsync, DeleteAsync, ListAllAsync) pass through unchanged — resilience is read-path only per Phase 6.1 design decision. 15 new unit tests cover: DB success/failure/retry paths, snapshot sealing and per-group-set isolation, order-independent cache key normalisation, cancellation propagation, and pass-through method routing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,8 +65,17 @@ builder.Services.AddScoped<HostStatusService>();
|
||||
builder.Services.AddScoped<ClusterNodeService>();
|
||||
builder.Services.AddSingleton<RedundancyMetrics>();
|
||||
builder.Services.AddScoped<EquipmentImportBatchService>();
|
||||
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
|
||||
// EF-backed inner service registered under the keyed-service key so the resilient
|
||||
// singleton decorator resolves it per-scope without a captive-dependency issue.
|
||||
builder.Services.AddKeyedScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>(
|
||||
ZB.MOM.WW.OtOpcUa.Admin.Security.ResilientLdapGroupRoleMappingService.InnerServiceKey);
|
||||
// Resilient singleton decorator: timeout 2 s → retry 3× jittered → fallback to in-memory snapshot.
|
||||
// Uses IServiceScopeFactory to open a short-lived scope for each DB call.
|
||||
// The static LdapOptions.GroupToRole bootstrap dictionary in AdminRoleGrantResolver is the
|
||||
// lock-out-proof floor; this decorator only guards the DB-backed augmentation rows.
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||
ZB.MOM.WW.OtOpcUa.Admin.Security.ResilientLdapGroupRoleMappingService>();
|
||||
|
||||
// Phase 7 Stream F — scripting + virtual tag + scripted alarm draft services, test
|
||||
// harness, and historian diagnostics. The historian sink is the Null variant here —
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using Polly.Timeout;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Resilience decorator for <see cref="ILdapGroupRoleMappingService"/> that wraps the
|
||||
/// hot-path <see cref="GetByGroupsAsync"/> call in the Phase 6.1-style pipeline:
|
||||
/// <b>timeout 2 s → retry 3× jittered → fallback to in-memory sealed snapshot</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Registered as a singleton so the in-memory snapshot survives across sign-in
|
||||
/// requests. The inner <see cref="ILdapGroupRoleMappingService"/> is resolved via the
|
||||
/// keyed-service key <c>"inner"</c>, allowing the EF-backed scoped service to be
|
||||
/// registered as the "inner" implementation while this singleton decorator is the primary
|
||||
/// <see cref="ILdapGroupRoleMappingService"/> binding.</para>
|
||||
///
|
||||
/// <para>Because the inner service is scoped (it owns an EF <c>DbContext</c>), this
|
||||
/// singleton uses <see cref="IServiceScopeFactory"/> to open a short-lived scope for
|
||||
/// each DB call. The scope is disposed immediately after the call completes.</para>
|
||||
///
|
||||
/// <para>On each successful <see cref="GetByGroupsAsync"/> the result is stored in a
|
||||
/// <see cref="ConcurrentDictionary{TKey,TValue}"/> keyed by the canonicalised group set. On
|
||||
/// any failure (DB unreachable, SQL exception, timeout) after all retries, the cached
|
||||
/// result for that exact group set is returned. When no prior success exists for the group
|
||||
/// set, an empty list is returned — the static <see cref="LdapOptions.GroupToRole"/>
|
||||
/// bootstrap dictionary in <see cref="AdminRoleGrantResolver"/> is the lock-out-proof
|
||||
/// floor that remains functional regardless of DB state.</para>
|
||||
///
|
||||
/// <para>Write methods (<see cref="CreateAsync"/>, <see cref="DeleteAsync"/>) and
|
||||
/// <see cref="ListAllAsync"/> are passed through unchanged — the resilience layer is
|
||||
/// read-path only, consistent with the Phase 6.1 design decision that writes must fail
|
||||
/// hard on DB outage rather than landing against a stale state.</para>
|
||||
/// </remarks>
|
||||
public sealed class ResilientLdapGroupRoleMappingService : ILdapGroupRoleMappingService
|
||||
{
|
||||
/// <summary>
|
||||
/// DI keyed-service key used to register the inner (EF-backed) implementation so the
|
||||
/// decorator can resolve it without creating a circular dependency on itself.
|
||||
/// </summary>
|
||||
public const string InnerServiceKey = "LdapGroupRoleMappingService.Inner";
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ResiliencePipeline _pipeline;
|
||||
private readonly ILogger<ResilientLdapGroupRoleMappingService> _logger;
|
||||
|
||||
// Keyed by the normalised group set (NUL-separated sorted group names, lower-case).
|
||||
private readonly ConcurrentDictionary<string, IReadOnlyList<LdapGroupRoleMapping>> _snapshot =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public ResilientLdapGroupRoleMappingService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<ResilientLdapGroupRoleMappingService> logger,
|
||||
TimeSpan? timeout = null,
|
||||
int retryCount = 3)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
|
||||
var builder = new ResiliencePipelineBuilder()
|
||||
.AddTimeout(new TimeoutStrategyOptions
|
||||
{
|
||||
Timeout = timeout ?? TimeSpan.FromSeconds(2),
|
||||
});
|
||||
|
||||
if (retryCount > 0)
|
||||
{
|
||||
builder.AddRetry(new RetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = retryCount,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
UseJitter = true,
|
||||
Delay = TimeSpan.FromMilliseconds(100),
|
||||
MaxDelay = TimeSpan.FromSeconds(1),
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(
|
||||
ex => ex is not OperationCanceledException),
|
||||
});
|
||||
}
|
||||
|
||||
_pipeline = builder.Build();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Executed through the timeout → retry pipeline. On full failure the last snapshot
|
||||
/// for this group set (if any) is returned; otherwise an empty list. The static
|
||||
/// <c>appsettings.json</c> bootstrap dictionary in <see cref="AdminRoleGrantResolver"/>
|
||||
/// remains the ultimate fallback — a DB outage never causes a total login denial.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ldapGroups);
|
||||
|
||||
var groupList = ldapGroups.ToList();
|
||||
if (groupList.Count == 0) return [];
|
||||
|
||||
var cacheKey = CacheKey(groupList);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _pipeline.ExecuteAsync(async ct =>
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
|
||||
.GetRequiredKeyedService<ILdapGroupRoleMappingService>(InnerServiceKey);
|
||||
return await inner.GetByGroupsAsync(groupList, ct).ConfigureAwait(false);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Seal the snapshot so a subsequent DB outage can fall back to it.
|
||||
_snapshot[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"LDAP role-mapping DB read failed after retries; falling back to snapshot for group set [{Groups}]",
|
||||
string.Join(", ", groupList));
|
||||
|
||||
return _snapshot.TryGetValue(cacheKey, out var cached)
|
||||
? cached
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Pass-through — not covered by the resilience pipeline (Admin UI listing only).</remarks>
|
||||
public async Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
|
||||
.GetRequiredKeyedService<ILdapGroupRoleMappingService>(InnerServiceKey);
|
||||
return await inner.ListAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Pass-through — writes must fail hard on DB outage per Phase 6.1 design decision.</remarks>
|
||||
public async Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
|
||||
.GetRequiredKeyedService<ILdapGroupRoleMappingService>(InnerServiceKey);
|
||||
return await inner.CreateAsync(row, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Pass-through — writes must fail hard on DB outage per Phase 6.1 design decision.</remarks>
|
||||
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
|
||||
.GetRequiredKeyedService<ILdapGroupRoleMappingService>(InnerServiceKey);
|
||||
await inner.DeleteAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalise a group set into a stable cache key: sort, lower-case, join with NUL.
|
||||
/// Two calls with the same groups in different orders produce the same key.
|
||||
/// </summary>
|
||||
internal static string CacheKey(IEnumerable<string> groups)
|
||||
=> string.Join('\0', groups
|
||||
.Select(g => g.ToLowerInvariant())
|
||||
.Order(StringComparer.Ordinal));
|
||||
}
|
||||
Reference in New Issue
Block a user