780c87eba3
1 PersistNodeAck wrote a Failed ack's FailureReason unbounded; the column is
nvarchar(2048), so on SQL Server the write failed forever, the ack retried
forever, the deadline deferred forever and every later deploy was refused.
- One shared bound, Configuration.FailureReasons.Truncate (MaxLength 2048),
used by the coordinator and DriverHostActor.UpsertNodeDeploymentState
(exception messages and degraded reasons).
- A non-transient DbUpdateException (not concurrency, no transient
DbException / timeout / socket / IO underneath) is POISON: the ack is
counted from memory with an Error, never retried.
- Deadline deferral on unpersisted acks is capped at 10 x the retry ceiling;
past it the outcome is decided from the acks held in memory.
2 A node restarting in the outcome window boots the cached revision (cache is
written before the outcome) and never acks. Cache-booted revisions
(BootstrapFromCache / TryBootFromCache) are now marked unconfirmed, so a
re-dispatch re-applies and acks the real outcome instead of a clean
short-circuit. docs/Redundancy.md corrected (replay only on the ConfigDb boot
path).
3 A lost dispatch re-driven near its deadline now gets a FULL deadline (a node
needs its whole outcome timeout + margin = ApplyDeadline, so any shorter
remainder timed out nodes that applied). No dispatched-at column: a second
restart recomputes from CreatedAtUtc and closes it instead (documented).
4 docs/Redundancy.md upgrade note: do not deploy while the central pair is mid
rolling-upgrade across this change.
Tests (all fail on the previous code): oversized Failed reason truncated (on a
SaveChanges interceptor that enforces the column like SQL Server); rejected ack
write counted from memory; bounded deadline deferral; full deadline on a
near-deadline re-drive; cache-booted revision re-applies on re-dispatch;
oversized degraded reason truncated on the node's own row.
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
70 lines
3.1 KiB
C#
70 lines
3.1 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
|
|
|
/// <summary>
|
|
/// An in-memory ConfigDb that behaves like SQL Server where the InMemory provider does not (lmxopcua#868 third
|
|
/// review): a <c>FailureReason</c> longer than the column refuses the whole <c>SaveChanges</c> with a
|
|
/// <see cref="DbUpdateException"/>. It can also reject (<see cref="PoisonNodeStateUpdates"/>, a non-transient
|
|
/// <see cref="DbUpdateException"/>) or fail transiently (<see cref="FailNodeStateUpdatesTransiently"/>) every
|
|
/// update of an existing <c>NodeDeploymentState</c> row.
|
|
/// </summary>
|
|
public sealed class ConstraintEnforcingDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
|
|
{
|
|
private readonly Interceptor _interceptor = new();
|
|
|
|
/// <summary>Every update of an existing node row is rejected as a constraint violation.</summary>
|
|
public bool PoisonNodeStateUpdates { get => _interceptor.Poison; set => _interceptor.Poison = value; }
|
|
|
|
/// <summary>Every update of an existing node row fails as if the connection dropped.</summary>
|
|
public bool FailNodeStateUpdatesTransiently { get => _interceptor.Transient; set => _interceptor.Transient = value; }
|
|
|
|
/// <inheritdoc />
|
|
public OtOpcUaConfigDbContext CreateDbContext() => new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
|
.UseInMemoryDatabase(dbName)
|
|
.AddInterceptors(_interceptor)
|
|
.Options);
|
|
|
|
private sealed class Interceptor : SaveChangesInterceptor
|
|
{
|
|
public volatile bool Poison;
|
|
public volatile bool Transient;
|
|
|
|
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
|
|
{
|
|
Check(eventData.Context!);
|
|
return result;
|
|
}
|
|
|
|
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
|
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
|
{
|
|
Check(eventData.Context!);
|
|
return ValueTask.FromResult(result);
|
|
}
|
|
|
|
private void Check(DbContext ctx)
|
|
{
|
|
foreach (var e in ctx.ChangeTracker.Entries())
|
|
{
|
|
var reason = e.Entity switch
|
|
{
|
|
NodeDeploymentState n => n.FailureReason,
|
|
Deployment d => d.FailureReason,
|
|
_ => null,
|
|
};
|
|
if (reason is { Length: > FailureReasons.MaxLength })
|
|
throw new DbUpdateException("String or binary data would be truncated in column 'FailureReason'.");
|
|
if (e.Entity is NodeDeploymentState && e.State == EntityState.Modified)
|
|
{
|
|
if (Poison) throw new DbUpdateException("The UPDATE statement conflicted with a CHECK constraint.");
|
|
if (Transient) throw new InvalidOperationException("A network-related error occurred (simulated).");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|