fix(configuration): resolve Medium code-review findings (Configuration-002, -003, -006, -009)
Configuration-002: sp_PublishGeneration is transaction-nesting aware (BEGIN TRANSACTION vs SAVE TRANSACTION on @@TRANCOUNT) so a caller's outer transaction survives a publish failure; sp_ValidateDraft wrapped in TRY/CATCH. Configuration-003: ValidatePathLength uses the cluster's actual Enterprise/Site lengths when available, falling back to the conservative approximation. Configuration-006: ResilientConfigReader treats a command-timeout TaskCanceledException as a fault (not caller cancellation) and falls back. Configuration-009: removed the checked-in plaintext sa connection string; CreateDbContext now requires OTOPCUA_CONFIG_CONNECTION. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Polly.Timeout;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
@@ -119,6 +120,104 @@ public sealed class ResilientConfigReaderTests : IDisposable
|
||||
|
||||
attempts.ShouldBeLessThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Configuration-006 — command-timeout TaskCanceledException and TimeoutRejectedException
|
||||
// must fall back to the sealed cache, not propagate as caller cancellation.
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task CommandTimeout_TaskCanceledException_FallsBackToCache()
|
||||
{
|
||||
// A SQL command-level timeout surfaces as a TaskCanceledException thrown by the
|
||||
// delegate itself (not triggered by the caller's CancellationToken). It must be
|
||||
// treated as a transient failure and trigger the cache fallback, not be mistaken
|
||||
// for genuine caller cancellation and propagated.
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(new GenerationSnapshot
|
||||
{
|
||||
ClusterId = "cluster-b", GenerationId = 7, CachedAt = DateTime.UtcNow,
|
||||
PayloadJson = "{\"from\":\"cache\"}",
|
||||
});
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
|
||||
|
||||
// Simulate a command-level timeout: TaskCanceledException with no linked token.
|
||||
var result = await reader.ReadAsync(
|
||||
"cluster-b",
|
||||
_ => throw new TaskCanceledException("SQL command timeout (no caller token)"),
|
||||
snap => snap.PayloadJson,
|
||||
CancellationToken.None); // caller token is NOT cancelled
|
||||
|
||||
result.ShouldBe("{\"from\":\"cache\"}",
|
||||
"command-timeout TaskCanceledException must fall back to sealed cache");
|
||||
flag.IsStale.ShouldBeTrue("cache fallback marks the stale flag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PollyTimeout_TimeoutRejectedException_FallsBackToCache()
|
||||
{
|
||||
// When Polly's own timeout strategy fires it throws TimeoutRejectedException.
|
||||
// That should trigger the cache fallback just like any other transient error.
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(new GenerationSnapshot
|
||||
{
|
||||
ClusterId = "cluster-c", GenerationId = 8, CachedAt = DateTime.UtcNow,
|
||||
PayloadJson = "{\"from\":\"polly-timeout-cache\"}",
|
||||
});
|
||||
var flag = new StaleConfigFlag();
|
||||
// Set an extremely short Polly timeout so the async delay triggers it.
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromMilliseconds(10), retryCount: 0);
|
||||
|
||||
var result = await reader.ReadAsync(
|
||||
"cluster-c",
|
||||
async ct =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), ct); // far exceeds 10 ms timeout
|
||||
return "never";
|
||||
},
|
||||
snap => snap.PayloadJson,
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe("{\"from\":\"polly-timeout-cache\"}",
|
||||
"Polly TimeoutRejectedException must fall back to sealed cache");
|
||||
flag.IsStale.ShouldBeTrue("cache fallback marks the stale flag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallerCancellation_Propagates_NotFallback()
|
||||
{
|
||||
// Explicit caller cancellation must NOT fall back to the sealed cache — the
|
||||
// caller said stop, so we must stop.
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
await cache.SealAsync(new GenerationSnapshot
|
||||
{
|
||||
ClusterId = "cluster-d", GenerationId = 9, CachedAt = DateTime.UtcNow,
|
||||
PayloadJson = "{\"should\":\"not be returned\"}",
|
||||
});
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await reader.ReadAsync<string>(
|
||||
"cluster-d",
|
||||
ct =>
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return ValueTask.FromResult("ok");
|
||||
},
|
||||
_ => "cache-should-not-be-used",
|
||||
cts.Token);
|
||||
});
|
||||
|
||||
flag.IsStale.ShouldBeFalse("no cache snapshot served on genuine cancellation");
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
|
||||
Reference in New Issue
Block a user