refactor(runtime): delete the dead in-place ApplyDelta seam; config change = respawn only (#665)

Since #516 a DriverConfig change is stop + respawn, so DriverSpawnPlan.ToApplyDelta
was always empty and the in-place path had no producer. #663 and #664 were both
latent bugs in it. This deletes it:
- host: ApplyChildDelta, _pendingDelta, HandleApplyResult and HandleDeltaApplied
- plan: DriverSpawnPlan.ToApplyDelta
- actor: the ApplyDelta, ApplyResult and DeltaApplied messages, the Connected
  reinit handler and the mid-connect AdoptConfigDuringInit
- tests: the actor tests that drove the seam

IDriver.ReinitializeAsync and each driver's implementation and unit tests stay.
Comments and docs that cited AdoptConfigDuringInit as the reason a driver must
release prior state at init now cite the IDriver contract and overlapping
retries, which still apply.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 11:23:16 -04:00
parent f622d0be2d
commit 1ea1da8236
33 changed files with 112 additions and 559 deletions
@@ -25,8 +25,7 @@ file sealed class HealthPollTick
/// <item><c>Failed</c> — terminal until parent restarts the actor.</item>
/// </list>
///
/// Engine wiring (subscriptions → AttributeValueUpdate publishes, ApplyDelta-driven Reinitialize,
/// per-tag write Asks) is staged for follow-up F7. This skeleton compiles + has a working
/// Engine wiring (subscriptions → AttributeValueUpdate publishes, per-tag write Asks) is staged for follow-up F7. This skeleton compiles + has a working
/// state machine so the Phase 6 control-plane integration tests can target it.
/// </summary>
public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
@@ -41,7 +40,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <c>Connected=true</c> on entering the Connected state, <c>false</c> on entering Reconnecting. The host
/// annotates this driver's native alarm conditions' source-data Quality from it (comms lost → Bad,
/// restored → Good) — independently of alarm transitions, since a comms-lost driver emits no alarm
/// events. Fire-and-forget, mirroring <see cref="DeltaApplied"/>.</summary>
/// events. Fire-and-forget.</summary>
public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected);
/// <summary>
@@ -62,18 +61,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="DriverInstanceId">The driver instance that was restarted.</param>
/// <param name="Reason">The type name of the exception that tripped supervision, for the host's log.</param>
public sealed record ReprovisionRequested(string DriverInstanceId, string Reason);
public sealed record ApplyDelta(string DriverConfigJson, CorrelationId Correlation);
public sealed record ApplyResult(bool Success, string? Reason, CorrelationId Correlation);
/// <summary>
/// Sent to the parent (<see cref="DriverHostActor"/>) AFTER an <see cref="ApplyDelta"/> has fully
/// re-initialised the driver (i.e. <see cref="IDriver.ReinitializeAsync"/> completed). The host uses
/// it to re-register this driver's dependency-consumer mux adapter: a driver may rebuild its declared
/// <see cref="IDependencyConsumer.DependencyRefs"/> during reinit (the Calculation driver re-binds its
/// calc tags), so the re-register MUST happen after reinit — NOT synchronously alongside the ApplyDelta
/// dispatch, which would re-read the STALE ref set. Emitted only on a successful apply.
/// </summary>
/// <param name="DriverInstanceId">The instance whose adapter should be re-registered.</param>
public sealed record DeltaApplied(string DriverInstanceId);
public sealed record WriteAttribute(string TagId, object Value);
public sealed record WriteAttributeResult(bool Success, string? Reason);
/// <summary>
@@ -221,15 +208,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private readonly ILoggingAdapter _log = Context.GetLogger();
private string? _currentConfigJson;
/// <summary>#664: set when <see cref="AdoptConfigDuringInit"/> adopts a new config mid-connect; the next
/// (generation-matched) <see cref="InitializeSucceeded"/> sends <see cref="DeltaApplied"/> to the parent and
/// clears it, so a dependency consumer's mux interest is re-registered against the ADOPTED config — the
/// same post-reinit notification the Connected <see cref="HandleApplyDeltaAsync"/> path sends.</summary>
private bool _adoptedConfigPendingNotify;
/// <summary>Monotonic token tagging each <see cref="InitializeAsync"/> attempt. An init result is
/// honoured only when its generation matches the latest; an older result is from a superseded attempt
/// (e.g. an <see cref="ApplyDelta"/> adopted a new config mid-(re)connect) and is dropped. Touched only
/// (a retry tick that overlapped a still-running attempt) and is dropped. Touched only
/// on the actor thread, so no lock is needed.</summary>
private int _initGeneration;
@@ -442,7 +423,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// Stubbed drivers accept the standard message contracts but return deterministic
// success without touching real hardware. Read returns null; Write succeeds.
Receive<InitializeRequested>(_ => { /* no-op */ });
Receive<ApplyDelta>(msg => Sender.Tell(new ApplyResult(true, "stubbed", msg.Correlation)));
Receive<WriteAttribute>(_ => Sender.Tell(new WriteAttributeResult(true, "stubbed")));
// Stubbed drivers have no upstream alarm system — swallow the ack (it's fire-and-forget, no reply).
Receive<RouteAlarmAck>(_ => { /* stubbed drivers have no alarm backend */ });
@@ -468,13 +448,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// fire-and-forget — no reply to surface — and the OPC UA condition state already committed locally).
Receive<RouteAlarmAck>(_ =>
_log.Debug("DriverInstance {Id}: alarm ack arrived during connect — dropped (driver not connected)", _driverInstanceId));
Receive<ApplyDelta>(AdoptConfigDuringInit);
Receive<InitializeSucceeded>(msg =>
{
if (msg.Generation != _initGeneration) return;
_log.Info("DriverInstance {Id}: connected", _driverInstanceId);
Become(Connected);
NotifyAdoptedConfig();
PublishHealthSnapshot();
ResubscribeDesired();
AttachAlarmSource();
@@ -522,7 +500,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// reconnects but never manages a read is measured from THIS connect, not from a stale earlier one.
_connectedSinceUtc = DateTime.UtcNow;
ReceiveAsync<ApplyDelta>(HandleApplyDeltaAsync);
Receive<DisconnectObserved>(msg =>
{
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
@@ -771,14 +748,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// no reply — the OPC UA condition state already committed locally on the Part 9 ack).
Receive<RouteAlarmAck>(_ =>
_log.Debug("DriverInstance {Id}: alarm ack arrived during reconnect — dropped (driver not connected)", _driverInstanceId));
Receive<ApplyDelta>(AdoptConfigDuringInit);
Receive<InitializeSucceeded>(msg =>
{
if (msg.Generation != _initGeneration) return;
Timers.Cancel("retry-connect");
_log.Info("DriverInstance {Id}: reconnected", _driverInstanceId);
Become(Connected);
NotifyAdoptedConfig();
PublishHealthSnapshot();
ResubscribeDesired();
AttachAlarmSource();
@@ -875,94 +850,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
});
}
/// <summary>Adopt a new config while not connected: ApplyDelta in Connecting/Reconnecting re-inits
/// immediately with the new config. <see cref="InitializeAsync"/> swaps <c>_currentConfigJson</c> and
/// bumps the generation, so the in-flight (old-config) init is superseded and its result is dropped.
/// The actor stays in its current state; the new init's result drives the next transition. In
/// Reconnecting the retry timer is left running — if this immediate attempt fails it keeps retrying
/// the new config (a redundant concurrent attempt is deduped by the generation guard).</summary>
private void AdoptConfigDuringInit(ApplyDelta msg)
{
_log.Info("DriverInstance {Id}: ApplyDelta during (re)connect — adopting new config, re-initialising now",
_driverInstanceId);
InitializeAsync(msg.DriverConfigJson);
_adoptedConfigPendingNotify = true;
Sender.Tell(new ApplyResult(true, "config adopted; reinitializing", msg.Correlation));
}
/// <summary>Send the deferred <see cref="DeltaApplied"/> for a mid-connect adoption (#664) once the driver
/// has actually initialised on the adopted config. A no-op otherwise.</summary>
private void NotifyAdoptedConfig()
{
if (!_adoptedConfigPendingNotify) return;
_adoptedConfigPendingNotify = false;
Context.Parent.Tell(new DeltaApplied(_driverInstanceId));
}
/// <summary>Adopt a new config while Connected, in place, via <see cref="IDriver.ReinitializeAsync"/>.
/// <para>#663: every driver's reinit releases its device/session state (#630), so the driver-side data and
/// native-alarm subscriptions do not survive it — but this handler used to keep both handles, and the
/// health-poll reconcile only re-subscribes a MISSING handle, so the driver went silent until a
/// reconnect or respawn. Both subscriptions are now released explicitly BEFORE the reinit (explicitly,
/// not just by nulling the handles, so a driver whose reinit keeps its subscriptions is not left
/// holding two) and re-established after it — in a <c>finally</c>, because a failed reinit has already
/// lost them too. Reinit stays the adoption path rather than shutdown + initialise: Calculation and
/// Galaxy take their config at construction and ignore <see cref="IDriver.InitializeAsync"/>'s JSON.</para>
/// <para>Not reachable from a deploy since #516 (a DriverConfig change respawns the driver).</para></summary>
private async Task HandleApplyDeltaAsync(ApplyDelta msg)
{
var replyTo = Sender;
await UnsubscribeAsync();
await ReleaseAlarmSubscriptionAsync();
try
{
await _driver.ReinitializeAsync(msg.DriverConfigJson, CancellationToken.None);
_currentConfigJson = msg.DriverConfigJson;
// The driver may have rebuilt its dependency-consumer interest during reinit (the Calculation
// driver re-binds its calc tags + dependency refs). Notify the parent so it re-registers this
// driver's mux adapter AFTER reinit completed — a synchronous re-register at ApplyDelta-dispatch
// time would re-read the driver's STALE DependencyRefs. Parent Tell (fire-and-forget); the
// continuation resumes on the actor context (no ConfigureAwait) so Context.Parent is live.
Context.Parent.Tell(new DeltaApplied(_driverInstanceId));
replyTo.Tell(new ApplyResult(true, null, msg.Correlation));
}
catch (Exception ex)
{
replyTo.Tell(new ApplyResult(false, ex.Message, msg.Correlation));
}
finally
{
ResubscribeDesired();
AttachAlarmSource();
SubscribeDesiredAlarms();
}
}
/// <summary>Release the native-alarm subscription at the driver (best-effort, bounded) and drop the
/// actor's handler + handle. The in-place reinit counterpart of the data <see cref="UnsubscribeAsync"/>
/// (#663).</summary>
private async Task ReleaseAlarmSubscriptionAsync()
{
if (_driver is IAlarmSource src && _alarmSubscriptionHandle is { } handle)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await _invoker.ExecuteAsync(
DriverCapability.AlarmSubscribe,
_driverInstanceId,
async ct => await src.UnsubscribeAlarmsAsync(handle, ct),
cts.Token);
}
catch (Exception ex)
{
_log.Warning(ex, "DriverInstance {Id}: releasing the native-alarm subscription ({Diag}) threw (continuing)",
_driverInstanceId, handle.DiagnosticId);
}
}
DetachAlarmSource();
}
private async Task HandleWriteAsync(WriteAttribute msg)
{
var replyTo = Sender;