feat(sql): IBatchSubscribable and lifecycle-generation-stamped health (R02:P1, R06:S-9)

R02:P1: SqlDriver runs PollGroupEngine, so it now implements IBatchSubscribable and
raises one OnDataChangeBatch per poll burst (DriverInstanceActor forwards one message
per cycle instead of one per tag).
S-9: InitializeAsync/ShutdownAsync take a lifecycle generation; their health writes
apply only while current, and a poll that started under an older generation (one that
outlived the engine's teardown wait) no longer writes its verdict onto the new
configuration. Poll-driven updates can no longer end Initializing - only the
initialize's own final write does. Lifecycle writes now preserve LastSuccessfulRead.

Refs #744
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:03:23 -04:00
parent ab29b76be0
commit a7d21d5a65
2 changed files with 179 additions and 21 deletions
@@ -28,7 +28,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// manufacture an exception to force backoff.</para>
/// </summary>
public sealed class SqlDriver
: IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, ISkippedTagReporter, IAsyncDisposable
: IDriver, ITagDiscovery, IReadable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter, IAsyncDisposable
{
/// <summary>Authored tags this driver could not build into its poll table.</summary>
private readonly SkippedTagLedger _skippedTags = new();
@@ -156,6 +156,16 @@ public sealed class SqlDriver
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>Guards the lifecycle-generation bump against the generation-checked lifecycle health write.</summary>
private readonly object _healthLock = new();
/// <summary>
/// Bumped by every <see cref="InitializeAsync"/> and <see cref="ShutdownAsync"/>. A lifecycle write from
/// a superseded call, and a poll that STARTED under an older generation (a stale poll outliving the
/// engine's 5 s teardown wait, R06:S-9), can no longer overwrite the newer state.
/// </summary>
private long _lifecycleGeneration;
/// <summary>
/// The exception instance <see cref="ReadAsync"/> has already turned into a health verdict, held only
/// until <see cref="HandlePollError"/> — which the poll engine calls with that same object — has
@@ -173,6 +183,9 @@ public sealed class SqlDriver
/// <summary>Occurs when a subscribed tag's value or quality changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Occurs once per poll cycle that produced at least one change, carrying every change of that cycle.</summary>
public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
/// <summary>Occurs when the database endpoint transitions between reachable and unreachable.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
@@ -230,6 +243,9 @@ public sealed class SqlDriver
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap,
// R02:P1 — one batch per poll burst, so DriverInstanceActor forwards one message per cycle instead
// of one per tag (it subscribes exactly one of the two events).
onBatch: (handle, items) => OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)),
// The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this
// closure IS the production consumer of its overrun signal.
onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1,
@@ -352,7 +368,9 @@ public sealed class SqlDriver
// identifier.
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
var generation = BeginLifecycle();
// LastSuccessfulRead is PRESERVED across the lifecycle states — it is the watchdog's evidence clock.
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Initializing, ReadHealth().LastSuccessfulRead, null));
// adopt a changed config BEFORE the tag table is built, so the table and the connection it
// will be polled over come from the same revision. ApplyBinding takes the options, dialect and
@@ -374,7 +392,7 @@ public sealed class SqlDriver
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// The caller tore the initialization down; not a database verdict.
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, ReadHealth().LastSuccessfulRead, null));
throw;
}
catch (Exception ex)
@@ -390,8 +408,8 @@ public sealed class SqlDriver
$"Check the database is reachable and the connection string is valid.";
_logger.LogError(ex, "Sql driver {DriverInstanceId} liveness check against {Endpoint} failed.",
_driverInstanceId, Endpoint);
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
TransitionHostTo(HostState.Stopped);
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, message)))
TransitionHostTo(HostState.Stopped);
throw new InvalidOperationException(message, ex);
}
@@ -404,7 +422,7 @@ public sealed class SqlDriver
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, ReadHealth().LastSuccessfulRead, null));
throw;
}
catch (Exception ex)
@@ -420,13 +438,13 @@ public sealed class SqlDriver
_logger.LogError(ex,
"Sql driver {DriverInstanceId} catalog validation against {Endpoint} failed.",
_driverInstanceId, Endpoint);
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
TransitionHostTo(HostState.Stopped);
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, message)))
TransitionHostTo(HostState.Stopped);
throw new InvalidOperationException(message, ex);
}
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
TransitionHostTo(HostState.Running);
if (WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)))
TransitionHostTo(HostState.Running);
_logger.LogInformation(
"Sql driver {DriverInstanceId} initialized against {Endpoint} with {TagCount} authored tag(s).",
_driverInstanceId, Endpoint, Tags.Count);
@@ -442,9 +460,10 @@ public sealed class SqlDriver
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
var generation = BeginLifecycle();
var lastRead = ReadHealth().LastSuccessfulRead;
await TeardownAsync().ConfigureAwait(false);
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Unknown, lastRead, null));
}
/// <inheritdoc />
@@ -517,10 +536,13 @@ public sealed class SqlDriver
// unreachable" and degrade a perfectly healthy driver.
ArgumentNullException.ThrowIfNull(fullReferences);
// The generation this read started under: if a Shutdown/Initialize lands while it is in flight, its
// verdict describes a superseded configuration and must not touch the newer health (R06:S-9).
var generation = Interlocked.Read(ref _lifecycleGeneration);
try
{
var result = await _reader.ReadDetailedAsync(fullReferences, cancellationToken).ConfigureAwait(false);
ObservePollOutcome(result.Snapshots, result.RowCapExceededGroups);
ObservePollOutcome(result.Snapshots, result.RowCapExceededGroups, generation);
return result.Snapshots;
}
catch (OperationCanceledException)
@@ -535,8 +557,12 @@ public sealed class SqlDriver
_driverInstanceId, Endpoint);
// Type name only — never ex.Message (see InitializeAsync for why); the full exception is already
// on the log sink via the LogWarning exception parameter above.
Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}.");
TransitionHostTo(HostState.Stopped);
if (IsCurrentLifecycle(generation))
{
Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}.");
TransitionHostTo(HostState.Stopped);
}
// Mark THIS exception object as already classified before it goes back to the poll engine, which
// routes it to HandlePollError next. The mark is what makes the deferral there exact — see that
// method for why inferring it from the exception's type was not.
@@ -614,9 +640,15 @@ public sealed class SqlDriver
/// (the planner mints it before any I/O), so the count is what makes them count as the database
/// answering.
/// </param>
internal void ObservePollOutcome(IReadOnlyList<DataValueSnapshot> snapshots, int answeredRefusals = 0)
/// <param name="generation">
/// The lifecycle generation the poll started under; a superseded one is ignored outright. Null (tests)
/// means "current".
/// </param>
internal void ObservePollOutcome(
IReadOnlyList<DataValueSnapshot> snapshots, int answeredRefusals = 0, long? generation = null)
{
if (snapshots.Count == 0) return;
if (generation is { } started && !IsCurrentLifecycle(started)) return;
var good = 0;
var unreachable = 0;
@@ -800,7 +832,11 @@ public sealed class SqlDriver
/// </summary>
/// <param name="project">Builds the snapshot to publish from the current one.</param>
private void UpdateHealthUnlessFaulted(Func<DriverHealth, DriverHealth> project)
=> UpdateHealth(current => current.State == DriverState.Faulted ? null : project(current));
// Initializing too: only the initialize's own final write may end it — a poll landing mid-init
// (a re-init without shutdown keeps the engine running) used to flip Initializing → Healthy.
=> UpdateHealth(current => current.State is DriverState.Faulted or DriverState.Initializing
? null
: project(current));
/// <summary>
/// The one read-modify-write of the health field, as a compare-and-swap retry loop.
@@ -831,13 +867,31 @@ public sealed class SqlDriver
/// <summary>Barrier-protected read of the multi-threaded health field.</summary>
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
/// <summary>Starts a lifecycle operation and returns its generation; every later one supersedes it.</summary>
private long BeginLifecycle()
{
lock (_healthLock) return ++_lifecycleGeneration;
}
/// <summary>Whether <paramref name="generation"/> is still the newest lifecycle operation.</summary>
private bool IsCurrentLifecycle(long generation) => Interlocked.Read(ref _lifecycleGeneration) == generation;
/// <summary>The current lifecycle generation. Test observation of the S-9 guard.</summary>
internal long LifecycleGenerationForTests => Interlocked.Read(ref _lifecycleGeneration);
/// <summary>
/// Unconditional publish of a new health snapshot, for the lifecycle transitions that are
/// last-writer-wins by definition (Initialize / Shutdown). Everything driven by a poll goes through
/// <see cref="UpdateHealth"/> instead, because it has to read the current snapshot first.
/// A lifecycle write that applies only while <paramref name="generation"/> is current — atomic against
/// <see cref="BeginLifecycle"/>, so a superseded initialize/shutdown never overwrites a newer one.
/// </summary>
/// <param name="value">The health snapshot to publish.</param>
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
private bool WriteHealthIfCurrent(long generation, DriverHealth value)
{
lock (_healthLock)
{
if (_lifecycleGeneration != generation) return false;
Volatile.Write(ref _health, value);
return true;
}
}
/// <summary>Records a host-state change and raises <see cref="OnHostStatusChanged"/> — on transitions only.</summary>
private void TransitionHostTo(HostState newState)