feat(runtime): DriverInstanceActor forwards batched data changes

This commit is contained in:
Joseph Doherty
2026-08-15 12:16:49 -04:00
parent 04e6a4690c
commit e04ff2488c
5 changed files with 332 additions and 27 deletions
@@ -924,6 +924,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<DispatchDeployment>(HandleDispatchFromSteady);
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
@@ -958,6 +959,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<FetchedForApply>(HandleFetchedForApply);
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
@@ -973,6 +975,22 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
}
/// <summary>
/// Temporary bridge: unwrap a batched driver publish back into the per-value
/// <see cref="ForwardToMux"/> path, so the batching ingress seam lands without changing any
/// downstream behaviour.
/// </summary>
// TODO(Task 8 plan 2026-08-15): replace with batched fan-out — one mux hop and one OPC UA publish
// hop per burst instead of one per value, which is the whole point of the batching work.
private void ForwardBatchToMux(DriverInstanceActor.AttributeValueBatchPublished batch)
{
foreach (var i in batch.Items)
{
ForwardToMux(new DriverInstanceActor.AttributeValuePublished(
batch.DriverInstanceId, i.FullReference, i.Value, i.StatusCode, i.TimestampUtc));
}
}
private void ForwardToMux(DriverInstanceActor.AttributeValuePublished msg)
{
// Pass driver-published values to the dependency mux when one is wired (VirtualTag inputs,
@@ -101,13 +101,25 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// state — a no-op when already Reconnecting or Connecting.
/// </summary>
public sealed record ForceReconnect;
/// <summary>Published to the actor's parent whenever the subscribed IDriver fires
/// <see cref="ISubscribable.OnDataChange"/>. The parent forwards to OpcUaPublishActor.
/// <summary>One driver-published value. NOT sent to the parent by the subscription path any more — the
/// actor batches every data change into an <see cref="AttributeValueBatchPublished"/>. Still live as the
/// per-value message the parent fans onward (the dependency mux consumes it, and
/// <c>DriverHostActor.FanCommsLossToDependencyMux</c> fabricates it).
/// <para><paramref name="StatusCode"/> is the driver's full OPC UA status code including its sub-code
/// (#519) — not a severity bucket. Classify it with <see cref="OpcUaStatus.IsGood"/> and friends rather
/// than comparing it to <see cref="OpcUaStatus.Good"/>.</para></summary>
public sealed record AttributeValuePublished(string DriverInstanceId, string FullReference, object? Value, uint StatusCode, DateTime TimestampUtc);
/// <summary>One value within an <see cref="AttributeValueBatchPublished"/>.</summary>
public sealed record AttributeValueItem(string FullReference, object? Value, uint StatusCode, DateTime TimestampUtc);
/// <summary>Batched form of <see cref="AttributeValuePublished"/> — every changed attribute of ONE
/// driver production burst in one message. The parent fans the whole batch to the mux and the OPC UA
/// publish path in one hop each. Status codes ride verbatim (#519).</summary>
public sealed record AttributeValueBatchPublished(string DriverInstanceId, IReadOnlyList<AttributeValueItem> Items);
private sealed record DataChangeForward(string FullReference, DataValueSnapshot Snapshot);
/// <summary>Self-sent when a batch-capable driver (<see cref="IBatchSubscribable"/>) raises
/// <see cref="IBatchSubscribable.OnDataChangeBatch"/> — marshals the whole burst off the driver's
/// thread onto the actor thread in one hop. Parallels <see cref="DataChangeForward"/>.</summary>
private sealed record DataChangeBatchForward(IReadOnlyList<DataChangeItem> Items);
/// <summary>Published to the parent whenever the subscribed driver (an <see cref="IAlarmSource"/>) fires
/// <see cref="IAlarmSource.OnAlarmEvent"/>. The parent (<see cref="DriverHostActor"/>) projects + routes it
/// to the materialised Part 9 condition. Parallels <see cref="AttributeValuePublished"/>.</summary>
@@ -205,6 +217,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// do not need to re-send subscription requests after a reconnect.</summary>
private ISubscriptionHandle? _subscriptionHandle;
private EventHandler<DataChangeEventArgs>? _dataChangeHandler;
/// <summary>The batch data-change handler, attached INSTEAD of <see cref="_dataChangeHandler"/> when the
/// driver is an <see cref="IBatchSubscribable"/>. Exactly one of the two is ever non-null.</summary>
private EventHandler<DataChangeBatchEventArgs>? _batchChangeHandler;
private EventHandler<AlarmEventArgs>? _alarmEventHandler;
private EventHandler<RediscoveryEventArgs>? _rediscoveryHandler;
private EventHandler<HostStatusChangedEventArgs>? _hostStatusHandler;
@@ -478,8 +493,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
});
ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync);
Receive<DataChangeForward>(OnDataChangeForward);
Receive<DataChangeBatchForward>(OnDataChangeBatchForward);
// Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent;
// project it to the parent the same way DataChangeForward projects AttributeValuePublished.
// project it to the parent the same way DataChangeForward projects AttributeValueBatchPublished.
Receive<NativeAlarmRaised>(m => Context.Parent.Tell(new AttributeAlarmPublished(_driverInstanceId, m.Args)));
// ResubscribeDesired self-Tells Subscribe; HandleSubscribeAsync replies SubscriptionEstablished to the
// sender, which on the self-resubscribe path is Self. Swallow it (trace only) so it doesn't dead-letter.
@@ -877,8 +893,21 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
try
{
_dataChangeHandler = (_, args) => self.Tell(new DataChangeForward(args.FullReference, args.Snapshot));
subscribable.OnDataChange += _dataChangeHandler;
// EXACTLY ONE of the two data-change events is subscribed. An IBatchSubscribable driver raises
// OnDataChangeBatch once per production burst AND the per-tag OnDataChange for every item in
// that same burst (the per-tag event is kept for legacy consumers — Galaxy cold reads, driver
// CLIs). Attaching both here would forward every value twice. The batch event is preferred: one
// Tell, one parent hop, one mux pass per burst instead of per tag.
if (subscribable is IBatchSubscribable batchable)
{
_batchChangeHandler = (_, args) => self.Tell(new DataChangeBatchForward(args.Items));
batchable.OnDataChangeBatch += _batchChangeHandler;
}
else
{
_dataChangeHandler = (_, args) => self.Tell(new DataChangeForward(args.FullReference, args.Snapshot));
subscribable.OnDataChange += _dataChangeHandler;
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
// A bulk subscribe spans potentially many hosts, so key the pipeline on the driver
@@ -928,9 +957,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
}
/// <summary>Tear down the data-change + native-alarm event handlers + null the handle. Called from the
/// Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale handler doesn't
/// push data-change / alarm events to an actor that has lost its driver connection.</summary>
/// <summary>Tear down the data-change (per-tag AND batch) + native-alarm event handlers + null the handle.
/// Called from the Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale
/// handler doesn't push data-change / alarm events to an actor that has lost its driver connection.
/// <para>Both data-change fields are cleared unconditionally even though only one is ever attached — the
/// IDriver object outlives the actor when the host respawns a child around it, so a handler left on the
/// driver's event would keep Telling a dead actor.</para></summary>
private void DetachSubscription()
{
if (_driver is ISubscribable subscribable && _dataChangeHandler is not null)
@@ -938,6 +970,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
subscribable.OnDataChange -= _dataChangeHandler;
}
_dataChangeHandler = null;
if (_driver is IBatchSubscribable batchable && _batchChangeHandler is not null)
{
batchable.OnDataChangeBatch -= _batchChangeHandler;
}
_batchChangeHandler = null;
_subscriptionHandle = null;
DetachAlarmSource();
}
@@ -1130,7 +1167,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
}
/// <summary>Forward a driver's data change to the parent for OPC UA publication.
/// <summary>Forward a per-tag-only driver's data change to the parent for OPC UA publication, as a
/// single-item batch — so the parent sees ONE message shape whether or not the driver batches.
/// <para>The driver's status code rides through UNCHANGED (#519). This used to project it onto a
/// 3-state Good/Uncertain/Bad enum here, which the node manager then re-expanded to a bare
/// <c>0x80000000</c> — so no driver's sub-code ever reached a client, and the status constants each
@@ -1138,8 +1176,25 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private void OnDataChangeForward(DataChangeForward msg)
{
var ts = msg.Snapshot.SourceTimestampUtc ?? msg.Snapshot.ServerTimestampUtc;
Context.Parent.Tell(new AttributeValuePublished(
_driverInstanceId, msg.FullReference, msg.Snapshot.Value, msg.Snapshot.StatusCode, ts));
Context.Parent.Tell(new AttributeValueBatchPublished(
_driverInstanceId,
new[] { new AttributeValueItem(msg.FullReference, msg.Snapshot.Value, msg.Snapshot.StatusCode, ts) }));
}
/// <summary>Forward one batch-capable driver production burst to the parent as a single message.
/// <para>Every item's status code and timestamp ride through exactly as
/// <see cref="OnDataChangeForward"/> carries them for a per-tag driver (#519; timestamps come from the
/// driver's snapshot, never from this actor's clock).</para></summary>
private void OnDataChangeBatchForward(DataChangeBatchForward msg)
{
var items = new AttributeValueItem[msg.Items.Count];
for (var i = 0; i < msg.Items.Count; i++)
{
var item = msg.Items[i];
var ts = item.Snapshot.SourceTimestampUtc ?? item.Snapshot.ServerTimestampUtc;
items[i] = new AttributeValueItem(item.FullReference, item.Snapshot.Value, item.Snapshot.StatusCode, ts);
}
Context.Parent.Tell(new AttributeValueBatchPublished(_driverInstanceId, items));
}
private static bool IsGoodStatus(uint statusCode) => OpcUaStatus.IsGood(statusCode);