Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
91 lines
3.8 KiB
C#
91 lines
3.8 KiB
C#
using Microsoft.Extensions.Logging;
|
||
using Polly;
|
||
using Polly.Retry;
|
||
using Polly.Timeout;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||
|
||
/// <summary>
|
||
/// Wraps a central-DB fetch function with Phase 6.1 Stream D.2 resilience:
|
||
/// <b>timeout 2 s → retry 3× jittered → fallback to sealed cache</b>. Maintains the
|
||
/// <see cref="StaleConfigFlag"/> — fresh on central-DB success, stale on cache fallback.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>Read-path only per plan. The write path (draft save, publish) bypasses this
|
||
/// wrapper entirely and fails hard on DB outage so inconsistent writes never land.</para>
|
||
///
|
||
/// <para>Fallback is triggered by <b>any exception</b> the fetch raises (central-DB
|
||
/// unreachable, SqlException, timeout). If the sealed cache also fails (no pointer,
|
||
/// corrupt file, etc.), <see cref="GenerationCacheUnavailableException"/> surfaces — caller
|
||
/// must fail the current request (InitializeAsync for a driver, etc.).</para>
|
||
/// </remarks>
|
||
public sealed class ResilientConfigReader
|
||
{
|
||
private readonly GenerationSealedCache _cache;
|
||
private readonly StaleConfigFlag _staleFlag;
|
||
private readonly ResiliencePipeline _pipeline;
|
||
private readonly ILogger<ResilientConfigReader> _logger;
|
||
|
||
public ResilientConfigReader(
|
||
GenerationSealedCache cache,
|
||
StaleConfigFlag staleFlag,
|
||
ILogger<ResilientConfigReader> logger,
|
||
TimeSpan? timeout = null,
|
||
int retryCount = 3)
|
||
{
|
||
_cache = cache;
|
||
_staleFlag = staleFlag;
|
||
_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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Execute <paramref name="centralFetch"/> through the resilience pipeline. On full failure
|
||
/// (post-retry), reads the sealed cache for <paramref name="clusterId"/> and passes the
|
||
/// snapshot to <paramref name="fromSnapshot"/> to extract the requested shape.
|
||
/// </summary>
|
||
public async ValueTask<T> ReadAsync<T>(
|
||
string clusterId,
|
||
Func<CancellationToken, ValueTask<T>> centralFetch,
|
||
Func<GenerationSnapshot, T> fromSnapshot,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||
ArgumentNullException.ThrowIfNull(centralFetch);
|
||
ArgumentNullException.ThrowIfNull(fromSnapshot);
|
||
|
||
try
|
||
{
|
||
var result = await _pipeline.ExecuteAsync(centralFetch, cancellationToken).ConfigureAwait(false);
|
||
_staleFlag.MarkFresh();
|
||
return result;
|
||
}
|
||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||
{
|
||
_logger.LogWarning(ex, "Central-DB read failed after retries; falling back to sealed cache for cluster {ClusterId}", clusterId);
|
||
// GenerationCacheUnavailableException surfaces intentionally — fails the caller's
|
||
// operation. StaleConfigFlag stays unchanged; the flag only flips when we actually
|
||
// served a cache snapshot.
|
||
var snapshot = await _cache.ReadCurrentAsync(clusterId, cancellationToken).ConfigureAwait(false);
|
||
_staleFlag.MarkStale();
|
||
return fromSnapshot(snapshot);
|
||
}
|
||
}
|
||
}
|