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>
254 lines
9.3 KiB
C#
254 lines
9.3 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Polly.Timeout;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class ResilientConfigReaderTests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-reader-{Guid.NewGuid():N}");
|
|
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
if (!Directory.Exists(_root)) return;
|
|
foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories))
|
|
File.SetAttributes(f, FileAttributes.Normal);
|
|
Directory.Delete(_root, recursive: true);
|
|
}
|
|
catch { /* best-effort */ }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CentralDbSucceeds_ReturnsValue_MarksFresh()
|
|
{
|
|
var cache = new GenerationSealedCache(_root);
|
|
var flag = new StaleConfigFlag { };
|
|
flag.MarkStale(); // pre-existing stale state
|
|
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance);
|
|
|
|
var result = await reader.ReadAsync(
|
|
"cluster-a",
|
|
_ => ValueTask.FromResult("fresh-from-db"),
|
|
_ => "from-cache",
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBe("fresh-from-db");
|
|
flag.IsStale.ShouldBeFalse("successful central-DB read clears stale flag");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CentralDbFails_ExhaustsRetries_FallsBackToCache_MarksStale()
|
|
{
|
|
var cache = new GenerationSealedCache(_root);
|
|
await cache.SealAsync(new GenerationSnapshot
|
|
{
|
|
ClusterId = "cluster-a", GenerationId = 99, CachedAt = DateTime.UtcNow,
|
|
PayloadJson = "{\"cached\":true}",
|
|
});
|
|
var flag = new StaleConfigFlag();
|
|
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
|
timeout: TimeSpan.FromSeconds(10), retryCount: 2);
|
|
var attempts = 0;
|
|
|
|
var result = await reader.ReadAsync(
|
|
"cluster-a",
|
|
_ =>
|
|
{
|
|
attempts++;
|
|
throw new InvalidOperationException("SQL dead");
|
|
#pragma warning disable CS0162
|
|
return ValueTask.FromResult("never");
|
|
#pragma warning restore CS0162
|
|
},
|
|
snap => snap.PayloadJson,
|
|
CancellationToken.None);
|
|
|
|
attempts.ShouldBe(3, "1 initial + 2 retries = 3 attempts");
|
|
result.ShouldBe("{\"cached\":true}");
|
|
flag.IsStale.ShouldBeTrue("cache fallback flips stale flag true");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CentralDbFails_AndCacheAlsoUnavailable_Throws()
|
|
{
|
|
var cache = new GenerationSealedCache(_root);
|
|
var flag = new StaleConfigFlag();
|
|
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
|
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
|
|
|
|
await Should.ThrowAsync<GenerationCacheUnavailableException>(async () =>
|
|
{
|
|
await reader.ReadAsync<string>(
|
|
"cluster-a",
|
|
_ => throw new InvalidOperationException("SQL dead"),
|
|
_ => "never",
|
|
CancellationToken.None);
|
|
});
|
|
|
|
flag.IsStale.ShouldBeFalse("no snapshot ever served, so flag stays whatever it was");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cancellation_NotRetried()
|
|
{
|
|
var cache = new GenerationSealedCache(_root);
|
|
var flag = new StaleConfigFlag();
|
|
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
|
timeout: TimeSpan.FromSeconds(10), retryCount: 5);
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
var attempts = 0;
|
|
|
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
|
{
|
|
await reader.ReadAsync<string>(
|
|
"cluster-a",
|
|
ct =>
|
|
{
|
|
attempts++;
|
|
ct.ThrowIfCancellationRequested();
|
|
return ValueTask.FromResult("ok");
|
|
},
|
|
_ => "cache",
|
|
cts.Token);
|
|
});
|
|
|
|
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")]
|
|
public sealed class StaleConfigFlagTests
|
|
{
|
|
[Fact]
|
|
public void Default_IsFresh()
|
|
{
|
|
new StaleConfigFlag().IsStale.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void MarkStale_ThenFresh_Toggles()
|
|
{
|
|
var flag = new StaleConfigFlag();
|
|
flag.MarkStale();
|
|
flag.IsStale.ShouldBeTrue();
|
|
flag.MarkFresh();
|
|
flag.IsStale.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void ConcurrentWrites_Converge()
|
|
{
|
|
var flag = new StaleConfigFlag();
|
|
Parallel.For(0, 1000, i =>
|
|
{
|
|
if (i % 2 == 0) flag.MarkStale(); else flag.MarkFresh();
|
|
});
|
|
flag.MarkFresh();
|
|
flag.IsStale.ShouldBeFalse();
|
|
}
|
|
}
|