refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
+316
@@ -0,0 +1,316 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle D (#23 M6-T5) integration tests for
|
||||
/// <see cref="AuditLogPartitionMaintenance"/>. Uses the same
|
||||
/// <see cref="MsSqlMigrationFixture"/> as the AuditLog migration / repository
|
||||
/// tests so the ALTER PARTITION FUNCTION DDL runs against the actual seeded
|
||||
/// <c>pf_AuditLog_Month</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The migration seeds boundaries for every month in 2026 and 2027 (Jan 2026
|
||||
/// through Dec 2027). Tests pick a lookahead relative to the current
|
||||
/// max-boundary at test start (rather than a fixed-target date) so each test
|
||||
/// is robust against earlier tests in the class having added boundaries to
|
||||
/// the shared fixture DB. Tests run sequentially within the class via xunit's
|
||||
/// per-class collection serialisation.
|
||||
/// </remarks>
|
||||
public class AuditLogPartitionMaintenanceTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public AuditLogPartitionMaintenanceTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private ScadaBridgeDbContext CreateContext() =>
|
||||
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString).Options);
|
||||
|
||||
private AuditLogPartitionMaintenance NewMaintenance(ScadaBridgeDbContext ctx) =>
|
||||
new(ctx, NullLogger<AuditLogPartitionMaintenance>.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the lookahead-in-months required to fall strictly inside the
|
||||
/// already-covered boundary range. Picks something well below the
|
||||
/// distance from "now" to the current max — guaranteed not to need any
|
||||
/// new SPLIT.
|
||||
/// </summary>
|
||||
private static int LookaheadInsideExistingRange(DateTime max)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
// (max - now) in whole months, minus a 1-month safety margin so we
|
||||
// never accidentally hit the boundary horizon edge case.
|
||||
var months = ((max.Year - now.Year) * 12) + max.Month - now.Month - 1;
|
||||
return Math.Max(1, months);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the lookahead-in-months required to add exactly
|
||||
/// <paramref name="extraBoundaries"/> new boundaries past the current max.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EnsureLookaheadAsync defines horizon =
|
||||
/// <c>NormalizeToFirstOfMonth(UtcNow) + lookaheadMonths</c>. The new
|
||||
/// boundaries it issues are first-of-month values strictly greater than
|
||||
/// max, up to and including horizon. So
|
||||
/// <c>lookaheadMonths = monthsBetween(NormalizeToFirstOfMonth(UtcNow), max) + extraBoundaries</c>
|
||||
/// is the exact value that lands horizon on <c>max + extraBoundaries</c>
|
||||
/// months.
|
||||
/// </remarks>
|
||||
private static int LookaheadForExtraBoundaries(DateTime max, int extraBoundaries)
|
||||
{
|
||||
var nowFirstOfMonth = FirstOfNextMonth(DateTime.UtcNow);
|
||||
var monthsToMax = ((max.Year - nowFirstOfMonth.Year) * 12) + max.Month - nowFirstOfMonth.Month;
|
||||
return monthsToMax + extraBoundaries;
|
||||
}
|
||||
|
||||
private static DateTime FirstOfNextMonth(DateTime instant)
|
||||
{
|
||||
var firstOfThisMonth = new DateTime(instant.Year, instant.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
return firstOfThisMonth.AddMonths(1);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task EnsureLookahead_AlreadyHasFutureRange_NoSplit_ReturnsEmpty()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var ctx = CreateContext();
|
||||
var maintenance = NewMaintenance(ctx);
|
||||
|
||||
var max = await maintenance.GetMaxBoundaryAsync();
|
||||
Assert.NotNull(max);
|
||||
|
||||
// Pick a lookahead small enough that horizon (NormalizeToFirstOfMonth(now)
|
||||
// + lookahead) lands well INSIDE the already-covered range — no SPLIT
|
||||
// should fire.
|
||||
var lookahead = LookaheadInsideExistingRange(max.Value);
|
||||
|
||||
var added = await maintenance.EnsureLookaheadAsync(lookahead);
|
||||
|
||||
Assert.Empty(added);
|
||||
|
||||
// Sanity: the max boundary is unchanged after the no-op call.
|
||||
var maxAfter = await maintenance.GetMaxBoundaryAsync();
|
||||
Assert.Equal(max, maxAfter);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task EnsureLookahead_NeedsOneMoreBoundary_Splits_Returns1Boundary()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var ctx = CreateContext();
|
||||
var maintenance = NewMaintenance(ctx);
|
||||
|
||||
var maxBefore = await maintenance.GetMaxBoundaryAsync();
|
||||
Assert.NotNull(maxBefore);
|
||||
|
||||
var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 1);
|
||||
var expectedAdded = maxBefore.Value.AddMonths(1);
|
||||
|
||||
var added = await maintenance.EnsureLookaheadAsync(lookahead);
|
||||
|
||||
Assert.Single(added);
|
||||
Assert.Equal(expectedAdded, added[0]);
|
||||
|
||||
var maxAfter = await maintenance.GetMaxBoundaryAsync();
|
||||
Assert.Equal(expectedAdded, maxAfter);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task EnsureLookahead_NeedsThreeBoundaries_Splits_Returns3Boundaries()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var ctx = CreateContext();
|
||||
var maintenance = NewMaintenance(ctx);
|
||||
|
||||
var maxBefore = await maintenance.GetMaxBoundaryAsync();
|
||||
Assert.NotNull(maxBefore);
|
||||
|
||||
var lookahead = LookaheadForExtraBoundaries(maxBefore.Value, extraBoundaries: 3);
|
||||
|
||||
var added = await maintenance.EnsureLookaheadAsync(lookahead);
|
||||
|
||||
Assert.Equal(3, added.Count);
|
||||
Assert.Equal(maxBefore.Value.AddMonths(1), added[0]);
|
||||
Assert.Equal(maxBefore.Value.AddMonths(2), added[1]);
|
||||
Assert.Equal(maxBefore.Value.AddMonths(3), added[2]);
|
||||
|
||||
var maxAfter = await maintenance.GetMaxBoundaryAsync();
|
||||
Assert.Equal(maxBefore.Value.AddMonths(3), maxAfter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-024: CD-019 removed the try/catch around the per-month
|
||||
/// SPLIT call so a genuine SQL failure (deadlock, permission, log full, transient
|
||||
/// connection drop) now aborts the loop instead of leaving partition holes. This
|
||||
/// test pins that abort behaviour: with an interceptor that throws on the SECOND
|
||||
/// SPLIT, the call must propagate the exception AND the first SPLIT's boundary
|
||||
/// must already be persisted in <c>pf_AuditLog_Month</c> (visible to a fresh
|
||||
/// <see cref="AuditLogPartitionMaintenance"/> instance) — proof that the loop did
|
||||
/// commit boundary N before throwing, and that the next tick can resume from
|
||||
/// boundary N+1 at-least-once with no holes.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted()
|
||||
{
|
||||
// STM: CD-024-SecondSplitThrowsAbortsLoop marker.
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Baseline max-boundary observed via a clean context.
|
||||
await using var baselineCtx = CreateContext();
|
||||
var maxBefore = await NewMaintenance(baselineCtx).GetMaxBoundaryAsync();
|
||||
Assert.NotNull(maxBefore);
|
||||
|
||||
var lookahead = LookaheadForExtraBoundaries(maxBefore!.Value, extraBoundaries: 3);
|
||||
var expectedFirst = maxBefore.Value.AddMonths(1);
|
||||
|
||||
// Build a fresh context with an interceptor that throws on the 2nd ALTER
|
||||
// PARTITION FUNCTION SPLIT RANGE. EF Core surfaces the throw through
|
||||
// ExecuteSqlRawAsync exactly as a SqlException would — the loop has no
|
||||
// try/catch (CD-019), so the exception propagates after the first SPLIT
|
||||
// has already committed.
|
||||
var interceptor = new SecondSplitThrowsInterceptor();
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString)
|
||||
.AddInterceptors(interceptor)
|
||||
.Options;
|
||||
await using var ctx = new ScadaBridgeDbContext(options);
|
||||
var maintenance = NewMaintenance(ctx);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => maintenance.EnsureLookaheadAsync(lookahead));
|
||||
|
||||
// Verify exactly one ALTER PARTITION FUNCTION SPLIT RANGE actually ran
|
||||
// before the interceptor's throw: split #1 committed, split #2 threw,
|
||||
// split #3 was never attempted.
|
||||
Assert.Equal(1, interceptor.SuccessfulSplits);
|
||||
|
||||
// And verify the first boundary IS now persisted — the loop aborted but
|
||||
// boundary N is durable so the next tick resumes from N+1 (no holes).
|
||||
await using var verifyCtx = CreateContext();
|
||||
var maxAfter = await NewMaintenance(verifyCtx).GetMaxBoundaryAsync();
|
||||
Assert.Equal(expectedFirst, maxAfter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core command interceptor: lets the first <c>ALTER PARTITION FUNCTION
|
||||
/// pf_AuditLog_Month() SPLIT RANGE</c> through and throws <see cref="InvalidOperationException"/>
|
||||
/// on the second one. Threads through synchronous + async + scalar + reader
|
||||
/// entry-points because <c>ExecuteSqlRawAsync</c> routes through the
|
||||
/// non-query async path but other code paths still go through the same
|
||||
/// interceptor pipeline. <see cref="SuccessfulSplits"/> counts the splits
|
||||
/// that were allowed to run so the test can pin the abort-after-one
|
||||
/// behaviour.
|
||||
/// </summary>
|
||||
private sealed class SecondSplitThrowsInterceptor : DbCommandInterceptor
|
||||
{
|
||||
public int SuccessfulSplits { get; private set; }
|
||||
|
||||
private bool IsTargetSplit(DbCommand command) =>
|
||||
command.CommandText.Contains("SPLIT RANGE", StringComparison.OrdinalIgnoreCase)
|
||||
&& command.CommandText.Contains("pf_AuditLog_Month", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public override InterceptionResult<int> NonQueryExecuting(
|
||||
DbCommand command,
|
||||
CommandEventData eventData,
|
||||
InterceptionResult<int> result)
|
||||
{
|
||||
ThrowIfSecondSplit(command);
|
||||
return base.NonQueryExecuting(command, eventData, result);
|
||||
}
|
||||
|
||||
public override ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(
|
||||
DbCommand command,
|
||||
CommandEventData eventData,
|
||||
InterceptionResult<int> result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfSecondSplit(command);
|
||||
return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken);
|
||||
}
|
||||
|
||||
public override int NonQueryExecuted(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
int result)
|
||||
{
|
||||
if (IsTargetSplit(command))
|
||||
{
|
||||
SuccessfulSplits++;
|
||||
}
|
||||
return base.NonQueryExecuted(command, eventData, result);
|
||||
}
|
||||
|
||||
public override ValueTask<int> NonQueryExecutedAsync(
|
||||
DbCommand command,
|
||||
CommandExecutedEventData eventData,
|
||||
int result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (IsTargetSplit(command))
|
||||
{
|
||||
SuccessfulSplits++;
|
||||
}
|
||||
return base.NonQueryExecutedAsync(command, eventData, result, cancellationToken);
|
||||
}
|
||||
|
||||
private void ThrowIfSecondSplit(DbCommand command)
|
||||
{
|
||||
if (!IsTargetSplit(command))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow the first SPLIT through; throw on the second so the loop's
|
||||
// post-CD-019 "let it propagate" behaviour can be asserted.
|
||||
if (SuccessfulSplits >= 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Simulated SqlException on the second SPLIT RANGE — exercising CD-019's no-try/catch abort path.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task EnsureLookahead_BoundaryAlreadyExists_NoError_Idempotent()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var ctx1 = CreateContext();
|
||||
var m1 = NewMaintenance(ctx1);
|
||||
|
||||
var maxStart = await m1.GetMaxBoundaryAsync();
|
||||
Assert.NotNull(maxStart);
|
||||
|
||||
// First call: add one boundary.
|
||||
var lookahead = LookaheadForExtraBoundaries(maxStart.Value, extraBoundaries: 1);
|
||||
var firstAdded = await m1.EnsureLookaheadAsync(lookahead);
|
||||
Assert.Single(firstAdded);
|
||||
|
||||
// Second call: the boundary just added is now part of pf_AuditLog_Month,
|
||||
// so the same lookahead value should be a no-op — no exception, no
|
||||
// duplicate SPLIT.
|
||||
await using var ctx2 = CreateContext();
|
||||
var m2 = NewMaintenance(ctx2);
|
||||
var secondAdded = await m2.EnsureLookaheadAsync(lookahead);
|
||||
|
||||
Assert.Empty(secondAdded);
|
||||
|
||||
// The max boundary is unchanged across the second call.
|
||||
var maxAfter = await m2.GetMaxBoundaryAsync();
|
||||
Assert.Equal(firstAdded[0], maxAfter);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user