diff --git a/CLAUDE.md b/CLAUDE.md
index fee02b58..32e4a6e2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1347,7 +1347,7 @@ historian tagname, which is the tag's driver `FullName`.
- **QuestDB** (`Driver.Historian.QuestDb`, `ServerHistorian:QuestDb:*`, Npgsql over PG wire; docker-dev overlay `docker-compose.questdb.yml`): same kinds/status/aggregates/HistoryUpdate as SqlServer, soft delete by tombstone (INSERT only — never `UPDATE`/`DELETE`; every read filters `NOT deleted`). ⚠️ **Timestamps are µs** (`QuestDbConnection.Round`/`BindTimestamp`; `IHistorianDataSource.TimestampResolutionTicks` = 10 steps HistoryRead paging), **pre-1970 cannot be stored** (writer drops, HistoryUpdate `BadOutOfRange`), every write waits for WAL visibility, TTL is enforced on write commits only, set Npgsql `Maximum Pool Size` < QuestDB's 64. `docs/Historian.md` §"QuestDB backend".
- **Real status fix:** the recorder used to stamp every sample Good (`192`); it now carries the driver's status. ⚠️ On the **Gateway** backend this changes nothing stored: the gateway discards quality (C-002), so AVEVA still stores Good.
- **Comms loss is historized as an outage marker** (`Empty` + `BadNoCommunication`, one per tag per outage) on SqlServer; dropped (`DroppedUnsupportedKind`) on Gateway. Outbox records are **format v2** — drain or delete `ContinuousHistorization:OutboxPath` before downgrading a node.
-- **HistoryUpdate** (SqlServer + QuestDb; Gateway ⇒ `BadHistoryOperationUnsupported`): Insert/Replace/Update/DeleteRaw/DeleteAtTime, **Primary-only** since #674 (off-Primary ⇒ `BadInvalidState`, after authorization, same `IDriverPrimaryRoleSource` verdict as the native-ack gate; reads ungated), items with a `SourceTimestamp` past now + `ServerHistorian:HistoryUpdateMaxFutureSkew` (default 1 day, ≤0 disables) ⇒ `BadOutOfRange` in the node manager (every backend), gated in both ACL modes — flag off needs the `HistoryUpdate` role from a **Cluster-scoped** grant (+ key re-check), flag on uses `DataPlaneAuthorizer`'s HistoryUpdate operation; `WriteOperate` never implies it. The `HistoryWrite` AccessLevel bit is set only when an updater is wired at materialise time. `docs/Historian.md` §HistoryUpdate, `docs/security.md`. ⚠️ **SqlServer schema v1 is accepted (#674)** with `AutoCreateSchema=false`: writes/reads/events/retention work; modified reads + HistoryUpdate ⇒ `BadHistoryOperationUnsupported` (nothing sent), retention skips `SampleModified`, one Warning per boot (`SqlServerSchemaBootstrapper.SupportsModifiedTrail`; `RequiredVersion` = 1, `ModifiedTrailVersion` = 2). QuestDB: keep `cairo.ttl.use.wall.clock=true`; a QuestDB trail INSERT retried after a client timeout duplicates (no DEDUP key).
+- **HistoryUpdate** (SqlServer + QuestDb; Gateway ⇒ `BadHistoryOperationUnsupported`): Insert/Replace/Update/DeleteRaw/DeleteAtTime, **Primary-only** since #674 (off-Primary ⇒ `BadInvalidState`, after authorization AND after planning — an unservable request (Remove / `IsDeleteModified` / not historized) stays `BadHistoryOperationUnsupported` off-Primary; the verdict is `IHistoryUpdateRoleView` = the data-plane verdict but **closed until this node's first redundancy snapshot**, so a pair cannot both accept in the boot window; admin-only nodes register no view ⇒ no gate; reads ungated), items with a `SourceTimestamp` past now + `ServerHistorian:HistoryUpdateMaxFutureSkew` (default 1 day, ≤0 disables) ⇒ `BadOutOfRange` in the node manager (every backend), gated in both ACL modes — flag off needs the `HistoryUpdate` role from a **Cluster-scoped** grant (+ key re-check), flag on uses `DataPlaneAuthorizer`'s HistoryUpdate operation; `WriteOperate` never implies it. The `HistoryWrite` AccessLevel bit is set only when an updater is wired at materialise time. `docs/Historian.md` §HistoryUpdate, `docs/security.md`. ⚠️ **SqlServer schema v1 is accepted (#674)** with `AutoCreateSchema=false`: writes/reads/events/retention work; modified reads + HistoryUpdate ⇒ node-level `BadHistoryOperationUnsupported` on every arm (the updater throws `NotSupportedException`, nothing sent; the `HistoryWrite` bit is still advertised — not knowable at materialise time), retention skips `SampleModified`, one Warning per boot (`SqlServerSchemaBootstrapper.SupportsModifiedTrail`; `RequiredVersion` = 1, `ModifiedTrailVersion` = 2). QuestDB: keep `cairo.ttl.use.wall.clock=true`; a QuestDB trail INSERT retried after a client timeout duplicates (no DEDUP key).
- **Modified-value history (#668, Part 11):** every applied HistoryUpdate item leaves a trail row (prior value, principal from the session's API key / `anonymous`, `HistoryModificationType`, time) — SqlServer `hist.SampleModified` (**schema v2**, written by `OUTPUT … INTO` in the same statement ⇒ atomic; ⚠️ no FK/CHECK/trigger on it, and `AutoCreateSchema=false` installs must run `002_sample_modified.sql` before upgrading), QuestDB `{p}sample_modified` (append-only, written **before** the change ⇒ a crash leaves an orphan trail row, never an untracked change). `IsReadModified=true` HistoryRead serves it as `HistoryModifiedData` via `IHistorianDataSource.ReadModifiedAsync` (default ⇒ `NotSupported` ⇒ `BadHistoryOperationUnsupported` on Gateway/Null), paged on `(SourceTime, ModifiedId)`, gated by plain HistoryRead. Live writes and retention never write it; retention/TTL purge it on source time. `IHistorianDataUpdater` methods take a `HistoryUpdateContext`. CLI `historyread --modified`. `docs/Historian.md` §Modified-value history.
### Read path (`ServerHistorian` section)
diff --git a/docs/Historian.md b/docs/Historian.md
index e95f1f10..02ffc2d0 100644
--- a/docs/Historian.md
+++ b/docs/Historian.md
@@ -690,8 +690,11 @@ modification time and the Part 11 `HistoryUpdateType`. A HistoryReadRaw with `Is
per tag alongside `hist.Sample`; QuestDB puts the same TTL on `{p}sample_modified`.
- **SqlServer schema v1 (#674).** With `AutoCreateSchema=false` a database still at schema **version 1** (script `002_sample_modified.sql` not
applied) keeps historizing — live writes, raw/processed/at-time reads, alarm events and retention all work — but has no trail: modified reads
- and every HistoryUpdate item answer **`BadHistoryOperationUnsupported`** with nothing sent to SQL, retention skips `SampleModified`, and the
- node logs **one Warning per boot**. Apply `002_sample_modified.sql` and restart the node to enable them (the verdict is taken once per
+ and every HistoryUpdate request answer a **node-level `BadHistoryOperationUnsupported`** (all three arms alike — the updater throws
+ `NotSupportedException`, as the read side does) with nothing sent to SQL, retention skips `SampleModified`, and the
+ node logs **one Warning per boot**. ⚠️ Historized nodes still advertise the `HistoryWrite` AccessLevel bit on a v1 database: the bit is
+ decided at materialise time from "an updater is wired", and whether the schema has the trail is only known after the backend's lazy
+ first-use bootstrap (I/O the node manager does not do while materialising), so a v1 node is admitted to HistoryUpdate and then refused. Apply `002_sample_modified.sql` and restart the node to enable them (the verdict is taken once per
process). `AutoCreateSchema=true` (the default) still upgrades v1 to v2 in place. See [Schema](#schema).
- **QuestDB retry duplicates (#674).** `{p}sample_modified` is append-only with **no DEDUP key** (a key would collapse genuinely distinct
modifications of one sample), and each trail row gets a fresh `modified_id`. So a HistoryUpdate a client **retries** after a timeout — when the
@@ -1289,7 +1292,7 @@ mixed (Part 11 reports item outcomes in `OperationResults`); it is Bad only for
**Per-item rejections** (answered without touching SQL; other items still proceed): a null item ⇒ `BadInvalidArgument`;
`SourceTimestamp == DateTime.MinValue` ⇒ `BadInvalidTimestamp`; a **`SourceTimestamp` later than `now + ServerHistorian:HistoryUpdateMaxFutureSkew`**
-(default 1 day, server clock; #674) ⇒ **`BadOutOfRange`** — on every backend, never sent; a value the historian cannot type (array, unsupported CLR type) ⇒
+(default 1 day, server clock; #674 — a `Local`-kind timestamp is converted to UTC first, and is sent as UTC) ⇒ **`BadOutOfRange`** — on every backend, never sent; a value the historian cannot type (array, unsupported CLR type) ⇒
`BadTypeMismatch`; a **`NaN` / `±Infinity` `Double` ⇒ `BadInvalidArgument`** — it cannot live in a SQL `float`, and unlike the live-value
writer (which stores it as `Empty`) an operator's explicit update is rejected rather than silently rewritten; and a **null value with a
Good or Uncertain status ⇒ `BadInvalidArgument`** — an `Empty` sample is always Bad, so a null value is accepted only with a Bad status
@@ -1312,15 +1315,28 @@ outcome, later ones `BadNoEntryExists`. **Unknown tag:** Insert / Update create
`ServerHistorian:Enabled=false`); `PerformUpdateType.Remove`; `DeleteRawModifiedDetails.IsDeleteModified=true` (the
[modification trail](#modified-value-history-part-11) is an audit record, aged out only by retention); and the out-of-scope detail types — **event** HistoryUpdate (`UpdateEventDetails` / `DeleteEventDetails`) and `UpdateStructureData` —
which are not overridden, so the SDK base rejects them. A backend exception is `BadInternalError` for that node and a deadline is `BadTimeout`.
-On a `SqlServer` database still at schema v1 every item is `BadHistoryOperationUnsupported` ([#674](#modified-value-history-part-11)).
+On a `SqlServer` database still at schema v1 the whole node is `BadHistoryOperationUnsupported` on every arm ([#674](#modified-value-history-part-11)).
No `AuditHistoryUpdateEvent` is emitted (#674.5, not implemented).
### Primary-only (#674)
HistoryUpdate is served only by the node holding the redundancy **Primary** data-plane role — the same verdict
(`IDriverPrimaryRoleSource`, published by `DriverHostActor`) that gates native-alarm acknowledges (#597). On any other node every node of
-the request that **passed authorization** is refused **`BadInvalidState`** (node-level code, with a message pointing at the Primary) and
-nothing reaches the backend. Authorization runs first, so an unauthorized caller learns `BadUserAccessDenied`, not the node's role.
+the request that **passed authorization and is servable** is refused **`BadInvalidState`** (node-level code, with a message pointing at the
+Primary) and nothing reaches the backend. Order: authorization first (an unauthorized caller learns `BadUserAccessDenied`, not the node's
+role), then the request is **planned** (a request no server could serve — no updater, a node that is not historized, `Remove`,
+`IsDeleteModified` — is the honest `BadHistoryOperationUnsupported` on every node, so a Secondary does not send the client to a Primary
+that would refuse it too), then the gate.
+
+**Closed until the first redundancy snapshot (boot window).** The gate reads its own verdict, `IHistoryUpdateRoleView.ShouldServeHistoryUpdate`
+(`PrimaryGatePolicy.ShouldServeHistoryUpdate`): the data-plane Primary verdict, but **closed until this node has received its first redundancy
+snapshot**. The data-plane verdict itself allows during the pre-join boot window (a node not yet joined sees no driver peer, which reads as
+"single node") — right for live writes and acks, which must not stall on a genuine single node, but it would let both nodes of a pair accept a
+HistoryUpdate for a few seconds (up to ~25 s under the bootstrap guard). "No verdict yet" is told apart from "single node" by the snapshot
+itself: the redundancy singleton runs on every driver node — a lone node included — and publishes on every change and every 10 s heartbeat,
+so a single node is named Primary within seconds of joining and opens then. Until that first snapshot every node refuses `BadInvalidState`;
+after it the gate is exactly the data-plane verdict (including the unknown-role / member-count rule for a snapshot that omits this node). A
+node with no redundancy manager (admin-only — it registers no `IHistoryUpdateRoleView`) wires no gate and allows.
HistoryRead is **not** gated. Why: rewriting history is a single-writer operation — both backends capture the prior value for the
[modification trail](#modified-value-history-part-11) under a per-tag, **per-process** lock (QuestDB) or statement (SqlServer), so two pair
nodes updating one tag concurrently could record a wrong prior value. A single-node deployment (no redundancy manager) is unaffected. Clients
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataUpdater.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataUpdater.cs
index 7890fbd9..1a94f252 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataUpdater.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataUpdater.cs
@@ -23,6 +23,12 @@ public enum HistoryUpdateMode
/// , the modification time and the ,
/// readable through . A refused item (any non-Good code) leaves
/// no record. Live recorder writes and retention purges never leave one.
+///
+/// Cannot update at all (Gitea #674): an implementation that is wired but cannot perform any update right
+/// now — the SqlServer backend on a schema without the modification trail — throws
+/// from every method without touching storage. The server reports it as a
+/// node-level BadHistoryOperationUnsupported, the same as .
+///
///
public interface IHistorianDataUpdater
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianStatusCodes.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianStatusCodes.cs
index 686b1126..9fa08bcf 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianStatusCodes.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/HistorianStatusCodes.cs
@@ -27,10 +27,4 @@ public static class HistorianStatusCodes
/// status, or a tag name longer than .
///
public const uint BadInvalidArgument = 0x80AB0000u;
-
- ///
- /// A modified-value read or HistoryUpdate item against a SQL Server schema still at version 1 (no Part 11
- /// modification trail, Gitea #674): refused without touching the database.
- ///
- public const uint BadHistoryOperationUnsupported = 0x80720000u;
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs
index 33bb097f..d4ac533e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs
@@ -28,8 +28,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
/// and SYSUTCDATETIME(). One statement means the sample change and its
/// audit row commit or roll back together: a failed update leaves no audit row.
/// Schema v1 (Gitea #674). Without the trail (
-/// false) a change could not be audited, so every item (and DeleteRaw's single result) reports
-/// BadHistoryOperationUnsupported and nothing is sent to the database beyond the one-time schema check.
+/// false) a change could not be audited, so every call throws — which the node
+/// manager reports as a node-level BadHistoryOperationUnsupported, on all three arms alike — and nothing is
+/// sent to the database beyond the one-time schema check.
///
internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
{
@@ -49,20 +50,19 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
_tags = tags;
}
- /// #674: ensures the schema and reports whether the Part 11 trail exists (no SQL after the first success).
+ ///
+ /// #674: ensures the schema and refuses the call when it has no Part 11 trail (no SQL after the first success).
+ ///
/// Cancellation.
- /// True when the modification trail is available.
- private async Task TrailAvailableAsync(CancellationToken ct)
+ /// A task completing when the trail is available.
+ /// The schema is below .
+ private async Task RequireTrailAsync(CancellationToken ct)
{
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
- return _bootstrap.SupportsModifiedTrail;
- }
-
- private static uint[] Unsupported(int count)
- {
- var codes = new uint[count];
- Array.Fill(codes, HistorianStatusCodes.BadHistoryOperationUnsupported);
- return codes;
+ if (!_bootstrap.SupportsModifiedTrail)
+ throw new NotSupportedException(
+ $"Historian schema [{_conn.Schema}] is below version {SqlServerSchemaBootstrapper.ModifiedTrailVersion}: " +
+ "HistoryUpdate needs the Part 11 modification trail (apply 002_sample_modified.sql and restart).");
}
///
@@ -72,7 +72,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
ArgumentNullException.ThrowIfNull(tag);
ArgumentNullException.ThrowIfNull(values);
ArgumentNullException.ThrowIfNull(context);
- if (!await TrailAvailableAsync(ct).ConfigureAwait(false)) return Unsupported(values.Count);
+ await RequireTrailAsync(ct).ConfigureAwait(false);
var plan = HistoryUpdatePlanner.Plan(tag, mode, values);
if (plan.Winners.Count == 0) return HistoryUpdatePlanner.Complete(plan, mode, new Dictionary());
@@ -92,7 +92,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
{
ArgumentNullException.ThrowIfNull(tag);
ArgumentNullException.ThrowIfNull(context);
- if (!await TrailAvailableAsync(ct).ConfigureAwait(false)) return HistorianStatusCodes.BadHistoryOperationUnsupported;
+ await RequireTrailAsync(ct).ConfigureAwait(false);
if (!HistorianTagNames.IsValid(tag)) return HistorianStatusCodes.BadInvalidArgument;
var tagId = await _tags.TryGetTagIdAsync(tag, ct).ConfigureAwait(false);
if (tagId is null) return HistoryUpdatePlanner.DeleteRawResult(tagKnown: false, 0);
@@ -120,7 +120,7 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
ArgumentNullException.ThrowIfNull(tag);
ArgumentNullException.ThrowIfNull(timesUtc);
ArgumentNullException.ThrowIfNull(context);
- if (!await TrailAvailableAsync(ct).ConfigureAwait(false)) return Unsupported(timesUtc.Count);
+ await RequireTrailAsync(ct).ConfigureAwait(false);
if (!HistorianTagNames.IsValid(tag))
{
var refused = new uint[timesUtc.Count];
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
index 78827f5f..32af5abd 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
@@ -13,6 +13,7 @@ using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
+using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
@@ -46,6 +47,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
private readonly ILogger _logger;
private readonly IDriverPrimaryRoleSource? _primaryRoleSource;
private readonly HistorianUpdaterHolder _historianUpdater;
+ private readonly IHistoryUpdateRoleView? _historyUpdateRoleView;
private OpcUaApplicationHost? _appHost;
private OtOpcUaSdkServer? _server;
@@ -98,6 +100,9 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
/// The selected historian backend's optional HistoryUpdate capability.
/// Optional: when unregistered it resolves to and HistoryUpdate is
/// reported unsupported on every node.
+ /// Gitea #674 — the HistoryUpdate verdict DriverHostActor publishes (the
+ /// data-plane Primary verdict, closed until this node's first redundancy snapshot). Optional: registered on every
+ /// driver-role node; an admin-only node has none and wires no HistoryUpdate Primary gate (allow).
public OtOpcUaServerHostedService(
IOptions options,
DeferredAddressSpaceSink deferredSink,
@@ -113,7 +118,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
TimeProvider time,
ILoggerFactory loggerFactory,
IDriverPrimaryRoleSource? primaryRoleSource = null,
- HistorianUpdaterHolder? historianUpdater = null)
+ HistorianUpdaterHolder? historianUpdater = null,
+ IHistoryUpdateRoleView? historyUpdateRoleView = null)
{
ArgumentNullException.ThrowIfNull(aclOptions);
ArgumentNullException.ThrowIfNull(time);
@@ -137,6 +143,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_loggerFactory = loggerFactory;
_primaryRoleSource = primaryRoleSource;
_historianUpdater = historianUpdater ?? HistorianUpdaterHolder.None;
+ _historyUpdateRoleView = historyUpdateRoleView;
_logger = loggerFactory.CreateLogger();
}
@@ -252,15 +259,13 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
if (_primaryRoleSource is { } primaryRoleSource)
{
_server.SetNativeAlarmAckPrimaryGate(() => primaryRoleSource.ShouldActAsPrimary);
- // Gitea #674: HistoryUpdate is single-writer — only the Primary rewrites history (BadInvalidState elsewhere).
- _server.SetHistoryUpdatePrimaryGate(() => primaryRoleSource.ShouldActAsPrimary);
}
else
{
// Every driver node registers one (AddOtOpcUaRuntime), so this is a wiring regression, not a mode:
// say so rather than let a Secondary silently go back to accepting acks it will revert.
_logger.LogWarning(
- "OtOpcUaServerHostedService: no IDriverPrimaryRoleSource registered — native-alarm acknowledges and HistoryUpdate are accepted on every node regardless of redundancy role");
+ "OtOpcUaServerHostedService: no IDriverPrimaryRoleSource registered — native-alarm acknowledges are accepted on every node regardless of redundancy role");
}
// Wire the reverse-path inbound operator-write gateway: a client write to a writable equipment-tag
@@ -319,10 +324,13 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_server.NodeManager.MaxConcurrentHistoryReadBatches = _serverHistorianOptions.MaxConcurrentHistoryReadBatches;
_server.NodeManager.HistoryReadDeadline = _serverHistorianOptions.HistoryReadDeadline;
- // Gitea #674: HistoryUpdate refuses a SourceTimestamp beyond now + HistoryUpdateMaxFutureSkew (BadOutOfRange),
+ // Gitea #674: HistoryUpdate is Primary-only (BadInvalidState elsewhere, and closed until this node's first
+ // redundancy snapshot) and refuses a SourceTimestamp beyond now + HistoryUpdateMaxFutureSkew (BadOutOfRange),
// judged against the host clock.
- _server.NodeManager.HistoryUpdateMaxFutureSkew = _serverHistorianOptions.HistoryUpdateMaxFutureSkew;
- _server.NodeManager.Clock = _time;
+ var historyUpdate = BuildHistoryUpdatePosture();
+ _server.NodeManager.HistoryUpdatePrimaryGate = historyUpdate.PrimaryGate;
+ _server.NodeManager.HistoryUpdateMaxFutureSkew = historyUpdate.MaxFutureSkew;
+ _server.NodeManager.Clock = historyUpdate.Clock;
// ServiceLevel publisher needs IServerInternal — only available after Start.
if (_server.CurrentInstance is { } serverInternal)
@@ -339,6 +347,28 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound");
}
+ ///
+ /// Gitea #674 — what StartAsync wires onto the node manager for HistoryUpdate: the Primary gate (read from
+ /// the on every call; null when none is registered, i.e. allow), the
+ /// future-timestamp bound from ServerHistorian:HistoryUpdateMaxFutureSkew, and the host clock it is judged
+ /// against. Separate from StartAsync so the wiring is testable without booting the SDK.
+ ///
+ /// The posture to apply.
+ internal HistoryUpdatePosture BuildHistoryUpdatePosture()
+ {
+ Func? gate = _historyUpdateRoleView is { } view ? () => view.ShouldServeHistoryUpdate : null;
+ if (gate is null)
+ _logger.LogInformation(
+ "OtOpcUaServerHostedService: no IHistoryUpdateRoleView registered (no redundancy manager on this node) — HistoryUpdate is not Primary-gated");
+ return new HistoryUpdatePosture(gate, _serverHistorianOptions.HistoryUpdateMaxFutureSkew, _time);
+ }
+
+ /// Gitea #674 — the HistoryUpdate settings produces.
+ /// The node manager's HistoryUpdate Primary gate; null allows.
+ /// The future-timestamp bound; non-positive disables it.
+ /// The clock the bound is judged against.
+ internal sealed record HistoryUpdatePosture(Func? PrimaryGate, TimeSpan MaxFutureSkew, TimeProvider Clock);
+
///
/// Arms the clock-driven monitored-item revalidation tick (arch-review 03/S19).
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index d7b1db61..363678c2 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -5484,6 +5484,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
});
}
+ ///
+ /// #674 review: a HistoryUpdate timestamp as UTC. Wire timestamps are UTC; an in-process caller can hand a
+ /// value, which is converted (comparing its raw ticks against a UTC bound would be
+ /// off by the zone offset). is treated as UTC, as the backends treat it.
+ ///
+ /// The item's source timestamp.
+ /// The same instant as a UTC .
+ private static DateTime ToUtc(DateTime value) => value.Kind switch
+ {
+ DateTimeKind.Local => value.ToUniversalTime(),
+ DateTimeKind.Unspecified => DateTime.SpecifyKind(value, DateTimeKind.Utc),
+ _ => value,
+ };
+
///
/// #674: the latest SourceTimestamp a HistoryUpdate item may carry — 's now plus
/// — or null when the bound is disabled (non-positive skew).
@@ -5533,9 +5547,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
codes[i] = StatusCodes.BadInvalidTimestamp;
}
- else if (latest is { } bound && item.SourceTimestamp > bound)
+ else if (latest is { } bound && ToUtc(item.SourceTimestamp) > bound)
{
- // #674: a far-future history edit is refused before any backend sees it (wire timestamps are UTC).
+ // #674: a far-future history edit is refused before any backend sees it.
codes[i] = StatusCodes.BadOutOfRange;
}
else if (!HistorianValue.TryFromObject(item.Value, out var value))
@@ -5544,7 +5558,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
else
{
- toSend.Add(new HistorizationValue(item.SourceTimestamp, value, item.StatusCode.Code));
+ toSend.Add(new HistorizationValue(ToUtc(item.SourceTimestamp), value, item.StatusCode.Code));
sentIndex.Add(i);
}
}
@@ -5588,13 +5602,35 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
nodesToProcess = AuthorizeHistoryUpdate(context, nodesToProcess, results, errors);
if (nodesToProcess.Count == 0) return;
- // #674 — PRIMARY GATE, after authorization (a caller who may not update learns nothing about the node's role).
- // Off-Primary every authorized node is refused BadInvalidState and nothing reaches the updater.
+ // Plan BEFORE the Primary gate (#674 review): a request this server can never serve (no updater, a node that is
+ // not historized, Remove, IsDeleteModified) is answered with the honest BadHistoryOperationUnsupported on every
+ // node, so a Secondary does not send the client to the Primary for something the Primary would also refuse.
+ // Planning is pure — it builds closures and reads the in-memory tagname map; nothing reaches a backend.
+ var updater = _historianDataUpdater;
+ var planned = new List<(NodeHandle Handle, string Tagname,
+ Func> Operation)>(nodesToProcess.Count);
+ foreach (var handle in nodesToProcess)
+ {
+ var operation = updater is null ? null : plan(handle);
+ if (operation is null || !TryGetHistorizedTagname(handle.NodeId, out var tagname) || tagname is null)
+ {
+ errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
+ results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
+ continue;
+ }
+
+ planned.Add((handle, tagname, operation));
+ }
+
+ if (planned.Count == 0) return;
+
+ // #674 — PRIMARY GATE, after authorization (a caller who may not update learns nothing about the node's role)
+ // and after planning. Off-Primary every servable node is refused BadInvalidState and nothing reaches the updater.
if (HistoryUpdatePrimaryGate is { } primary && !primary())
{
var refusal = new ServiceResult(StatusCodes.BadInvalidState,
new LocalizedText("This server is not the redundancy Primary; update history on the Primary (the server with the higher ServiceLevel)."));
- foreach (var handle in nodesToProcess)
+ foreach (var (handle, _, _) in planned)
{
errors[handle.Index] = refusal;
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadInvalidState };
@@ -5606,25 +5642,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Who is rewriting history — recorded on every modification-trail row (Part 11 ModificationInfo.UserName, #668).
var who = HistoryUpdateCallerOf(context);
- WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
+ WithHistoryReadBatchLimiter(planned.Select(p => p.Handle).ToList(), results, errors, () =>
{
using var deadline = CreateDeadline();
var ct = deadline?.Token ?? CancellationToken.None;
- var updater = _historianDataUpdater;
- var work = new List>(nodesToProcess.Count);
- foreach (var handle in nodesToProcess)
+ var work = new List>(planned.Count);
+ foreach (var (handle, tagname, operation) in planned)
{
- var operation = updater is null ? null : plan(handle);
- if (operation is null || !TryGetHistorizedTagname(handle.NodeId, out var tagname) || tagname is null)
- {
- errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
- results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
- continue;
- }
-
var h = handle;
var tag = tagname;
- work.Add(() => ServeUpdateNodeAsync(h, results, errors, token => operation(updater!, tag, who, token), ct));
+ var op = operation;
+ work.Add(() => ServeUpdateNodeAsync(h, results, errors, token => op(updater!, tag, who, token), ct));
}
RunBounded(work, HistoryReadBatchParallelism);
@@ -5677,6 +5705,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadTimeout };
}
+ catch (NotSupportedException)
+ {
+ // The backend cannot perform this update at all (e.g. a SqlServer historian still at schema v1, with no
+ // modification trail to record it in, #674) — the spec status for the whole node, not a server fault.
+ // Mirrors the HistoryRead side's handling of the same exception.
+ errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
+ results[handle.Index] = new HistoryUpdateResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
+ }
catch (Exception ex)
{
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryUpdate failed for node {NodeId}", handle.NodeId);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
index b0127e4b..adfeb79e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -452,6 +452,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
///
private readonly IHistorizationRoleView? _historizationRoleView;
+ /// Gitea #674 review: where the HistoryUpdate verdict is published. Null on nodes that wire no redundancy manager.
+ private readonly IHistoryUpdateRoleView? _historyUpdateRoleView;
+
+ /// Gitea #674 review: whether any redundancy snapshot has reached this host — the HistoryUpdate gate stays
+ /// closed until one has (see ).
+ private bool _redundancySnapshotReceived;
+
///
/// Tests whether a node named in a redundancy snapshot serves this node's address space — i.e.
/// is a per-cluster mesh partner recording the very same historized refs. Null (the default)
@@ -689,7 +696,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
TimeSpan? commsRecoveryAuditDelay = null,
Func? sharesAlarmQueueWithLocal = null,
TimeSpan? applyOutcomeTimeout = null,
- bool captureWhilePrimaryUnreachable = true) =>
+ bool captureWhilePrimaryUnreachable = true,
+ IHistoryUpdateRoleView? historyUpdateRoleView = null) =>
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
@@ -702,7 +710,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView,
sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay,
- sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable));
+ sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView));
/// Initializes a new DriverHostActor with the specified dependencies.
/// Database context factory for configuration database access.
@@ -775,6 +783,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// reported UNREACHABLE by the cluster failure detector, stop deferring to it and capture — rather than waiting
/// for auto-down + a fresh redundancy snapshot. Affects ONLY the historization capture verdict; the alarm drain
/// and the Primary data-plane verdict are unchanged. false restores the pre-#672 behaviour.
+ /// Gitea #674 review: where the OPC UA HistoryUpdate verdict
+ /// ( — the data-plane verdict, but closed until the first
+ /// redundancy snapshot) is published for the node manager's HistoryUpdate Primary gate. Null on nodes that wire no
+ /// redundancy manager.
public DriverHostActor(
IDbContextFactory? dbFactory,
CommonsNodeId localNode,
@@ -807,9 +819,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
TimeSpan? commsRecoveryAuditDelay = null,
Func? sharesAlarmQueueWithLocal = null,
TimeSpan? applyOutcomeTimeout = null,
- bool captureWhilePrimaryUnreachable = true)
+ bool captureWhilePrimaryUnreachable = true,
+ IHistoryUpdateRoleView? historyUpdateRoleView = null)
{
_captureWhilePrimaryUnreachable = captureWhilePrimaryUnreachable;
+ _historyUpdateRoleView = historyUpdateRoleView;
_applyOutcomeTimeout = applyOutcomeTimeout ?? DefaultApplyOutcomeTimeout;
_sharesAlarmQueueWithLocal = sharesAlarmQueueWithLocal;
_applyWedgeTimeout = applyWedgeTimeout ?? DefaultApplyWedgeTimeout;
@@ -909,6 +923,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
///
private void StartPrimaryVerdictPublishing()
{
+ PublishHistoryUpdateVerdict();
if (_primaryRoleSource is null) return;
PublishPrimaryVerdict();
try
@@ -948,6 +963,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// The snapshot path publishes on every snapshot, as the other verdicts do.
private void PublishPrimaryVerdict()
{
+ PublishHistoryUpdateVerdict();
if (_primaryRoleSource is null) return;
var verdict = ShouldServiceAsPrimary();
_lastPublishedPrimaryVerdict = verdict;
@@ -958,10 +974,18 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// role information, so republishing an unchanged verdict on each would be noise.
private void PublishPrimaryVerdictIfChanged()
{
+ PublishHistoryUpdateVerdict();
if (_primaryRoleSource is null || ShouldServiceAsPrimary() == _lastPublishedPrimaryVerdict) return;
PublishPrimaryVerdict();
}
+ /// Gitea #674 review: publishes (no-op when no view
+ /// is wired). Rides every Primary-verdict publish point — host start, every snapshot, membership changes — so it is
+ /// closed from host start and opens only once a snapshot has arrived and the data-plane verdict allows.
+ private void PublishHistoryUpdateVerdict() =>
+ _historyUpdateRoleView?.PublishHistoryUpdateRole(
+ PrimaryGatePolicy.ShouldServeHistoryUpdate(_redundancySnapshotReceived, _localRole, DriverMemberCount()));
+
///
/// Gitea #672: follow cluster reachability so the historization capture gate stops deferring to a recording
/// Primary the failure detector already considers dead. Its OWN subscription, deliberately not folded into
@@ -2270,6 +2294,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// ScriptedAlarmHostActor.OnRedundancyStateChanged / OpcUaPublishActor.
private void OnRedundancyStateChanged(RedundancyStateChanged msg)
{
+ _redundancySnapshotReceived = true;
var local = msg.Nodes.FirstOrDefault(n => n.NodeId == _localNode);
if (local is not null)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs
index 8f8bcb17..7b049cfc 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/PrimaryGatePolicy.cs
@@ -26,6 +26,36 @@ public static class PrimaryGatePolicy
_ => driverMemberCount <= 1, // unknown: allow only when no driver peer exists
};
+ ///
+ /// Gitea #674 review: decide whether this node may serve an OPC UA HistoryUpdate. The
+ /// verdict, but CLOSED until this node has received its first
+ /// redundancy snapshot.
+ ///
+ ///
+ ///
+ /// deliberately allows an unknown role while no driver peer is
+ /// visible, which covers the pre-join boot window: a node spawned before it has joined its mesh sees a
+ /// member count of 0. For a live data-plane operation (a device write, an ack) that is the right trade —
+ /// denying would stall a genuine single node during its own join. HistoryUpdate is not live: a client can
+ /// retry it, and both nodes of a pair accepting one concurrently during that window is exactly the
+ /// wrong-prior-value race the Primary gate exists for. So it waits for a real verdict.
+ ///
+ ///
+ /// "No snapshot yet" is told apart from "single node" by the snapshot itself: the redundancy singleton runs
+ /// on every driver node (one per mesh, a single node included) and publishes on every change and every
+ /// 10 s heartbeat, so a single node is named Primary within seconds of joining. Once a snapshot has
+ /// arrived this is exactly (including its unknown-role member-count
+ /// rule for a snapshot that omits this node). A node with no redundancy manager at all (admin-only, or a
+ /// harness) wires no gate and so allows.
+ ///
+ ///
+ /// Whether any redundancy snapshot has reached this node yet.
+ /// This node's last-known redundancy role, or null when unknown.
+ /// Count of Up cluster members carrying the driver role.
+ /// true to serve HistoryUpdate; false to refuse it (BadInvalidState).
+ public static bool ShouldServeHistoryUpdate(bool redundancySnapshotReceived, RedundancyRole? localRole, int driverMemberCount) =>
+ redundancySnapshotReceived && ShouldServiceAsPrimary(localRole, driverMemberCount);
+
///
/// Decide whether this node should drain the replicated alarm store-and-forward queue.
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Redundancy/IRedundancyRoleView.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Redundancy/IRedundancyRoleView.cs
index 9565c658..9a46f931 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Redundancy/IRedundancyRoleView.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Redundancy/IRedundancyRoleView.cs
@@ -75,9 +75,29 @@ public interface IHistorizationRoleView
void PublishHistorizationRole(bool shouldRecordHistorizedValues);
}
+///
+/// Gitea #674 review: a singleton snapshot of the OPC UA HistoryUpdate verdict
+/// (), read by the node manager's HistoryUpdate Primary gate.
+///
+///
+/// A separate verdict from because it is closed until the
+/// first redundancy snapshot, where that one mirrors the live data-plane gate and is open through the pre-join boot
+/// window. Registered unconditionally on every driver-role node (same object as the other views); a node that does
+/// not register it wires no gate.
+///
+public interface IHistoryUpdateRoleView
+{
+ /// Whether this node may serve HistoryUpdate right now.
+ bool ShouldServeHistoryUpdate { get; }
+
+ /// Records a fresh decision. Called by DriverHostActor whenever it publishes its Primary verdict.
+ /// The decision produced.
+ void PublishHistoryUpdateRole(bool shouldServeHistoryUpdate);
+}
+
///
/// Thread-safe + +
-/// , each backed by its own volatile field.
+/// + , each backed by its own volatile field.
///
///
///
@@ -90,8 +110,15 @@ public interface IHistorizationRoleView
/// them is the bug that live gate already found once.
///
///
-public sealed class RedundancyRoleView : IRedundancyRoleView, IDriverPrimaryRoleSource, IHistorizationRoleView
+public sealed class RedundancyRoleView : IRedundancyRoleView, IDriverPrimaryRoleSource, IHistorizationRoleView,
+ IHistoryUpdateRoleView
{
+ // Gitea #674 review: seeded CLOSED, unlike the data-plane verdict. HistoryUpdate waits for a real redundancy verdict
+ // (PrimaryGatePolicy.ShouldServeHistoryUpdate); a node that never publishes here is not a driver node with a
+ // redundancy manager, and such a node does not register this view as its gate source.
+ private volatile bool _shouldServeHistoryUpdate =
+ PrimaryGatePolicy.ShouldServeHistoryUpdate(redundancySnapshotReceived: false, localRole: null, driverMemberCount: 1);
+
// Seeded with the decision for "role unknown" rather than a bare false. An unpublished view and a
// node whose role nothing ever reports are genuinely the same situation, and they must behave the
// same: a deployment that runs no redundancy at all never publishes here, and defaulting closed
@@ -121,6 +148,12 @@ public sealed class RedundancyRoleView : IRedundancyRoleView, IDriverPrimaryRole
///
public bool ShouldActAsPrimary => _shouldActAsPrimary;
+ ///
+ public bool ShouldServeHistoryUpdate => _shouldServeHistoryUpdate;
+
+ ///
+ public void PublishHistoryUpdateRole(bool shouldServeHistoryUpdate) => _shouldServeHistoryUpdate = shouldServeHistoryUpdate;
+
///
public void Publish(bool shouldDrainAlarmHistory) => _shouldDrainAlarmHistory = shouldDrainAlarmHistory;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
index 7cb8a36a..1debb006 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
@@ -98,6 +98,11 @@ public static class ServiceCollectionExtensions
// with no gate at all, and both nodes would write every sample twice with nothing to say so.
services.TryAddSingleton(sp => sp.GetRequiredService());
+ // Gitea #674 review: the OPC UA HistoryUpdate verdict — the data-plane verdict, but closed until this node's first
+ // redundancy snapshot. Same instance; registered here (driver-role nodes only) so an admin-only node, which has no
+ // redundancy manager, wires no HistoryUpdate gate at all.
+ services.TryAddSingleton(sp => sp.GetRequiredService());
+
// The node→central ack transport under MeshTransport:Mode = Grpc (mesh gRPC command plane,
// Task 1B.2). Registered unconditionally but resolved only by the Grpc arm of
// WithOtOpcUaRuntimeActors — the mode is not knowable here (this runs before configuration is
@@ -631,7 +636,9 @@ public static class ServiceCollectionExtensions
// Review R6 MINOR-4: the passive half of a LocalDb pair has no PeerAddress, so it is told
// its queue-sharing peer as its mesh partner instead — otherwise it never stands down.
sharesAlarmQueueWithLocal: BuildAlarmQueuePeerPredicate(
- resolver.GetService(), roleInfo, replicationPeerHost)),
+ resolver.GetService(), roleInfo, replicationPeerHost),
+ // Gitea #674 review: the HistoryUpdate verdict (closed until the first redundancy snapshot).
+ historyUpdateRoleView: resolver.GetService()),
DriverHostActorName);
registry.Register(driverHost);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/StatusCodeOracleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/StatusCodeOracleTests.cs
index 177c7c19..a672a621 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/StatusCodeOracleTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/StatusCodeOracleTests.cs
@@ -14,11 +14,6 @@ public sealed class StatusCodeOracleTests
((uint)Opc.Ua.AggregateBits.Calculated).ShouldBe(HistorianInfoBits.HistorianCalculated);
}
- /// #674: the v1-schema refusal code is the SDK's BadHistoryOperationUnsupported.
- [Fact]
- public void History_operation_unsupported_matches_the_SDK() =>
- HistorianStatusCodes.BadHistoryOperationUnsupported.ShouldBe(Opc.Ua.StatusCodes.BadHistoryOperationUnsupported);
-
[Fact]
public void Interpolated_bits_match_the_SDK()
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SchemaV1PostureTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SchemaV1PostureTests.cs
index 85f14fa0..7cdeb51e 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SchemaV1PostureTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SchemaV1PostureTests.cs
@@ -59,20 +59,20 @@ public sealed class SchemaV1PostureTests(SqlHistorianFixture fx)
source.ReadModifiedAsync("Plant.Speed", Recent.AddDays(-1), Recent.AddDays(1), 10, null, Ct));
source.GetHealthSnapshot().LastError.ShouldBeNull();
- // HistoryUpdate: every item refused, and NOTHING reaches SQL — a sent MERGE/DELETE would either change the
- // samples or fail on the missing SampleModified target; an Insert on an unknown tag would create its Tag row.
+ // HistoryUpdate: every arm refused as a whole (NotSupportedException ⇒ the node manager's node-level
+ // BadHistoryOperationUnsupported), and NOTHING reaches SQL — a sent MERGE/DELETE would either change the samples or
+ // fail on the missing SampleModified target (SqlException, not NotSupported); an Insert on an unknown tag would
+ // create its Tag row.
var updater = provider.CreateUpdater(sp)!;
var tagsBefore = await CountAsync($"SELECT COUNT(*) FROM [{s}].Tag");
- (await updater.UpdateDataAsync("Plant.Speed", HistoryUpdateMode.Update,
+ await Should.ThrowAsync(() => updater.UpdateDataAsync("Plant.Speed", HistoryUpdateMode.Update,
[new HistorizationValue(Recent, HistorianValue.FromDouble(99), 0), new HistorizationValue(Recent.AddSeconds(1), HistorianValue.FromDouble(98), 0)],
- Who, Ct)).ShouldBe([HistorianStatusCodes.BadHistoryOperationUnsupported, HistorianStatusCodes.BadHistoryOperationUnsupported]);
- (await updater.UpdateDataAsync("Plant.Brand.New", HistoryUpdateMode.Insert,
- [new HistorizationValue(Recent, HistorianValue.FromDouble(1), 0)], Who, Ct))
- .ShouldBe([HistorianStatusCodes.BadHistoryOperationUnsupported]);
- (await updater.DeleteRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), Who, Ct))
- .ShouldBe(HistorianStatusCodes.BadHistoryOperationUnsupported);
- (await updater.DeleteAtTimeAsync("Plant.Speed", [Recent], Who, Ct))
- .ShouldBe([HistorianStatusCodes.BadHistoryOperationUnsupported]);
+ Who, Ct));
+ await Should.ThrowAsync(() => updater.UpdateDataAsync("Plant.Brand.New", HistoryUpdateMode.Insert,
+ [new HistorizationValue(Recent, HistorianValue.FromDouble(1), 0)], Who, Ct));
+ await Should.ThrowAsync(() =>
+ updater.DeleteRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), Who, Ct));
+ await Should.ThrowAsync(() => updater.DeleteAtTimeAsync("Plant.Speed", [Recent], Who, Ct));
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Tag")).ShouldBe(tagsBefore);
(await source.ReadRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, Ct))
.Samples.Select(v => v.Value).ShouldBe([2d]);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceHistoryUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceHistoryUpdateTests.cs
new file mode 100644
index 00000000..9b3e9dc5
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceHistoryUpdateTests.cs
@@ -0,0 +1,116 @@
+using Akka.Hosting;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Authorization;
+using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer;
+using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
+using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
+
+///
+/// Gitea #674 review — pins what the hosted service wires onto the node manager for HistoryUpdate: the Primary gate
+/// from (read live on every call), and the future-skew bound + clock from
+/// ServerHistorian options. Asserted through
+/// (what StartAsync applies) so the SDK need not boot.
+///
+public sealed class OtOpcUaServerHostedServiceHistoryUpdateTests
+{
+ [Fact]
+ public void A_registered_view_wires_a_gate_that_reads_it_live()
+ {
+ var view = new FlippableView { Serve = false };
+ var posture = Service(view).BuildHistoryUpdatePosture();
+
+ posture.PrimaryGate.ShouldNotBeNull();
+ posture.PrimaryGate().ShouldBeFalse();
+ view.Serve = true;
+ posture.PrimaryGate().ShouldBeTrue("the gate must read the view on every call, not capture its value");
+ }
+
+ /// The gate comes from the HistoryUpdate view, NOT the data-plane source — a source saying "Primary" must not
+ /// open it while the HistoryUpdate verdict is closed (the boot-window distinction).
+ [Fact]
+ public void The_gate_ignores_the_data_plane_primary_source()
+ {
+ var posture = Service(new FlippableView { Serve = false }, new AlwaysPrimary()).BuildHistoryUpdatePosture();
+
+ posture.PrimaryGate!().ShouldBeFalse();
+ }
+
+ /// No view (an admin-only node: no redundancy manager) ⇒ no gate ⇒ HistoryUpdate allowed.
+ [Fact]
+ public void No_view_wires_no_gate()
+ {
+ Service(view: null).BuildHistoryUpdatePosture().PrimaryGate.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Skew_and_clock_come_from_options_and_the_host_clock()
+ {
+ var clock = new FixedClock();
+ var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary
+ {
+ ["ServerHistorian:HistoryUpdateMaxFutureSkew"] = "00:07:00",
+ }).Build();
+
+ var posture = Service(view: null, config: config, clock: clock).BuildHistoryUpdatePosture();
+
+ posture.MaxFutureSkew.ShouldBe(TimeSpan.FromMinutes(7));
+ posture.Clock.ShouldBeSameAs(clock);
+ }
+
+ [Fact]
+ public void An_absent_section_keeps_the_one_day_default()
+ {
+ Service(view: null).BuildHistoryUpdatePosture().MaxFutureSkew.ShouldBe(TimeSpan.FromDays(1));
+ }
+
+ private static OtOpcUaServerHostedService Service(
+ IHistoryUpdateRoleView? view, IDriverPrimaryRoleSource? primary = null, IConfiguration? config = null,
+ TimeProvider? clock = null) => new(
+ Options.Create(new OpcUaApplicationHostOptions()),
+ new DeferredAddressSpaceSink(),
+ new DeferredServiceLevelPublisher(),
+ NullOpcUaUserAuthenticator.Instance,
+ NullHistorianDataSource.Instance,
+ EmptyDataPlaneAuthoritySource.Instance,
+ config ?? new ConfigurationBuilder().Build(),
+ () => throw new NotSupportedException("no ActorSystem is needed for the HistoryUpdate posture"),
+ new ActorRegistry(),
+ dataPlaneAuthorizer: null,
+ Options.Create(new AclOptions()),
+ clock ?? TimeProvider.System,
+ NullLoggerFactory.Instance,
+ primaryRoleSource: primary,
+ historyUpdateRoleView: view);
+
+ private sealed class FlippableView : IHistoryUpdateRoleView
+ {
+ public volatile bool Serve;
+
+ public bool ShouldServeHistoryUpdate => Serve;
+
+ public void PublishHistoryUpdateRole(bool shouldServeHistoryUpdate) => Serve = shouldServeHistoryUpdate;
+ }
+
+ private sealed class AlwaysPrimary : IDriverPrimaryRoleSource
+ {
+ public bool ShouldActAsPrimary => true;
+
+ public void PublishPrimaryRole(bool shouldActAsPrimary)
+ {
+ }
+ }
+
+ private sealed class FixedClock : TimeProvider
+ {
+ public override DateTimeOffset GetUtcNow() => new(2026, 10, 11, 0, 0, 0, TimeSpan.Zero);
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs
index 5f1cb99d..12665523 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs
@@ -669,7 +669,7 @@ public sealed class HistoryUpdateTests : IDisposable
var (results, errors) = Insert(nm, identity, nodeId, Value(T1, 1d));
var (rawResults, rawErrors) = Call(nm, identity, typeof(DeleteRawModifiedDetails),
- new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2 });
+ new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false });
var (atResults, atErrors) = Call(nm, identity, typeof(DeleteAtTimeDetails),
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } });
@@ -742,6 +742,123 @@ public sealed class HistoryUpdateTests : IDisposable
finally { await host.DisposeAsync(); }
}
+ // ───────────────────────────── #674 review follow-ups ─────────────────────────────
+
+ ///
+ /// #674 review: a backend that cannot perform the update at all (the SqlServer updater on a v1 schema throws
+ /// ) is a NODE-level BadHistoryOperationUnsupported on all three arms —
+ /// not a per-item code under a Good node, and not BadInternalError.
+ ///
+ [Fact]
+ public async Task A_not_supported_backend_is_node_level_unsupported_on_every_arm()
+ {
+ var (host, server) = await BootAsync();
+ try
+ {
+ var nm = server.NodeManager!;
+ nm.HistorianDataUpdater = new FakeUpdater { Throw = new NotSupportedException("schema v1: no trail") };
+ WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
+ var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
+ var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
+
+ var update = Insert(nm, identity, nodeId, Value(T1, 1d));
+ var deleteRaw = Call(nm, identity, typeof(DeleteRawModifiedDetails),
+ new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false });
+ var deleteAt = Call(nm, identity, typeof(DeleteAtTimeDetails),
+ new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } });
+
+ foreach (var (results, errors) in new[] { update, deleteRaw, deleteAt })
+ {
+ errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
+ results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
+ (results[0].OperationResults?.Count ?? 0).ShouldBe(0);
+ }
+ }
+ finally { await host.DisposeAsync(); }
+ }
+
+ ///
+ /// #674 review: a request this server can never serve is answered with the honest
+ /// BadHistoryOperationUnsupported even off-Primary — the plan is evaluated BEFORE the Primary gate, which is
+ /// not even consulted when nothing in the request is servable. A servable node in the same situation still gets
+ /// BadInvalidState (the control: the gate is wired and closed).
+ ///
+ [Fact]
+ public async Task Off_primary_an_unservable_request_is_unsupported_not_invalid_state()
+ {
+ var (host, server) = await BootAsync();
+ try
+ {
+ var nm = server.NodeManager!;
+ var updater = new FakeUpdater();
+ nm.HistorianDataUpdater = updater;
+ var consulted = 0;
+ nm.HistoryUpdatePrimaryGate = () => { Interlocked.Increment(ref consulted); return false; };
+ WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
+ var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
+ var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
+
+ var (removeResults, removeErrors) = Update(nm, identity, nodeId, PerformUpdateType.Remove, Value(T1, 1d));
+ var (modResults, modErrors) = Call(nm, identity, typeof(DeleteRawModifiedDetails),
+ new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = true });
+
+ removeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
+ removeResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
+ modErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
+ modResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
+ consulted.ShouldBe(0);
+
+ var (_, servableErrors) = Insert(nm, identity, nodeId, Value(T1, 1d));
+ servableErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState);
+ consulted.ShouldBe(1);
+ updater.UpdateCalls.ShouldBeEmpty();
+ }
+ finally { await host.DisposeAsync(); }
+ }
+
+ ///
+ /// #674 review: a SourceTimestamp is converted to UTC before the future-skew
+ /// compare (and before it is sent). Two items one tick either side of the bound, expressed in local time: the one
+ /// past the bound is refused and the one inside it is sent as UTC. Comparing raw ticks would get exactly one of
+ /// them wrong in any non-UTC zone (which one depends on the sign of the offset).
+ ///
+ [Fact]
+ public async Task A_local_kind_source_time_is_judged_and_sent_as_utc()
+ {
+ var (host, server) = await BootAsync();
+ try
+ {
+ var nm = server.NodeManager!;
+ var updater = new FakeUpdater();
+ nm.HistorianDataUpdater = updater;
+ nm.Clock = new FakeClock(Start);
+ nm.HistoryUpdateMaxFutureSkew = TimeSpan.FromHours(1);
+ WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
+ var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
+ var bound = Start.AddHours(1);
+ var justPast = bound.AddTicks(1).ToLocalTime();
+ var justInside = bound.AddTicks(-1).ToLocalTime();
+ justPast.Kind.ShouldBe(DateTimeKind.Local);
+
+ var (results, _) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, PerformUpdateType.Update,
+ LocalValue(justPast, 1d), LocalValue(justInside, 2d));
+
+ results[0].OperationResults.Select(c => c.Code)
+ .ShouldBe([StatusCodes.BadOutOfRange, StatusCodes.GoodEntryInserted]);
+ var sent = updater.UpdateCalls.ShouldHaveSingleItem().Values.ShouldHaveSingleItem();
+ sent.TimestampUtc.ShouldBe(bound.AddTicks(-1));
+ sent.TimestampUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
+ }
+ finally { await host.DisposeAsync(); }
+ }
+
+ private static DataValue LocalValue(DateTime local, double value)
+ {
+ var dv = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good };
+ dv.SourceTimestamp = local;
+ return dv;
+ }
+
// ───────────────────────────── helpers ─────────────────────────────
private static DataValue Value(DateTime timestamp, object value) =>
@@ -849,6 +966,7 @@ public sealed class HistoryUpdateTests : IDisposable
CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
+ if (Throw is not null) throw Throw;
lock (DeleteRawCalls) { DeleteRawCalls.Add((tag, startUtc, endUtc)); lock (UpdateCalls) Callers.Add(context); }
return DeleteRawCode;
}
@@ -857,6 +975,7 @@ public sealed class HistoryUpdateTests : IDisposable
string tag, IReadOnlyList timesUtc, HistoryUpdateContext context, CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
+ if (Throw is not null) throw Throw;
lock (DeleteAtTimeCalls) { DeleteAtTimeCalls.Add(timesUtc.ToList()); lock (UpdateCalls) Callers.Add(context); }
return DeleteAtTimeCodes ?? Enumerable.Repeat(StatusCodes.Good, timesUtc.Count).ToArray();
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorHistoryUpdateRoleViewTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorHistoryUpdateRoleViewTests.cs
new file mode 100644
index 00000000..8a8547e4
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorHistoryUpdateRoleViewTests.cs
@@ -0,0 +1,133 @@
+using Akka.Actor;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
+using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
+using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
+
+///
+/// Gitea #674 review — the OPC UA HistoryUpdate verdict () is the Primary
+/// data-plane verdict but CLOSED until this node's first redundancy snapshot, so both nodes of a pair cannot accept a
+/// HistoryUpdate during the pre-join boot window (where the data-plane verdict allows: member count 0/1). A single node
+/// still opens — its own redundancy singleton names it Primary in its first snapshot.
+///
+///
+/// Assertions are on what was PUBLISHED, in order, so a missing publish cannot pass for a closed seed.
+///
+public sealed class DriverHostActorHistoryUpdateRoleViewTests : RuntimeActorTestBase
+{
+ private static readonly NodeId TestNode = NodeId.Parse("driver-history-update-test");
+ private static readonly TimeSpan Timeout = PresenceBudget;
+
+ /// The boot window: a single-driver node (data-plane verdict ALLOW) publishes CLOSED for HistoryUpdate until
+ /// a snapshot arrives; its first snapshot naming it Primary opens it.
+ [Fact]
+ public void A_single_node_is_closed_until_its_first_snapshot_then_opens()
+ {
+ var view = new RecordingHistoryUpdateRoleView();
+ var host = SpawnHost(view, () => 1);
+
+ AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout);
+
+ host.Tell(Snapshot(RedundancyRole.Primary));
+ AwaitAssert(() => view.Published[^1].ShouldBeTrue(), duration: Timeout);
+ }
+
+ /// Once a snapshot has arrived, an unknown role on a single-driver cluster (a snapshot that omits this
+ /// node) follows the data-plane rule and opens — a real verdict exists, it just does not name us.
+ [Fact]
+ public void After_a_snapshot_an_unknown_role_follows_the_data_plane_rule()
+ {
+ var view = new RecordingHistoryUpdateRoleView();
+ var host = SpawnHost(view, () => 1);
+ AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout);
+
+ host.Tell(new RedundancyStateChanged([], CorrelationId.NewId()));
+ AwaitAssert(() => view.Published[^1].ShouldBeTrue(), duration: Timeout);
+ }
+
+ /// A Secondary stays closed after its snapshot; becoming Primary opens it.
+ [Fact]
+ public void A_secondary_stays_closed_and_a_promotion_opens()
+ {
+ var view = new RecordingHistoryUpdateRoleView();
+ var host = SpawnHost(view, () => 2);
+ AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout);
+
+ host.Tell(Snapshot(RedundancyRole.Secondary));
+ AwaitAssert(() => view.Published.Count.ShouldBeGreaterThanOrEqualTo(2), duration: Timeout);
+ view.Published.ShouldAllBe(v => !v);
+
+ host.Tell(Snapshot(RedundancyRole.Primary));
+ AwaitAssert(() => view.Published[^1].ShouldBeTrue(), duration: Timeout);
+ }
+
+ /// The policy itself: no snapshot ⇒ closed whatever the role/count; with one ⇒ the data-plane verdict.
+ [Theory]
+ [InlineData(false, null, 0, false)]
+ [InlineData(false, null, 1, false)]
+ [InlineData(false, RedundancyRole.Primary, 1, false)]
+ [InlineData(true, null, 1, true)]
+ [InlineData(true, null, 2, false)]
+ [InlineData(true, RedundancyRole.Primary, 2, true)]
+ [InlineData(true, RedundancyRole.Secondary, 2, false)]
+ [InlineData(true, RedundancyRole.Detached, 1, false)]
+ public void Policy_is_the_data_plane_verdict_gated_on_a_snapshot(
+ bool snapshot, RedundancyRole? role, int members, bool expected) =>
+ PrimaryGatePolicy.ShouldServeHistoryUpdate(snapshot, role, members).ShouldBe(expected);
+
+ /// The production view is seeded CLOSED (unlike the data-plane verdict's allow seed).
+ [Fact]
+ public void The_role_view_is_seeded_closed()
+ {
+ var view = new RedundancyRoleView();
+ view.ShouldServeHistoryUpdate.ShouldBeFalse();
+ view.ShouldActAsPrimary.ShouldBeTrue("control: the data-plane seed is the single-node allow");
+ view.PublishHistoryUpdateRole(true);
+ view.ShouldServeHistoryUpdate.ShouldBeTrue();
+ }
+
+ private IActorRef SpawnHost(IHistoryUpdateRoleView view, Func driverMemberCount) =>
+ Sys.ActorOf(DriverHostActor.Props(
+ NewInMemoryDbFactory(),
+ TestNode,
+ ackRouter: null,
+ driverMemberCountProvider: driverMemberCount,
+ historyUpdateRoleView: view));
+
+ private static RedundancyStateChanged Snapshot(RedundancyRole role) =>
+ new(
+ [
+ new NodeRedundancyState(TestNode, role,
+ IsClusterLeader: role == RedundancyRole.Primary,
+ IsDriverPrimary: role == RedundancyRole.Primary,
+ AsOfUtc: DateTime.UtcNow),
+ ],
+ CorrelationId.NewId());
+
+ /// Remembers every published verdict, in order.
+ private sealed class RecordingHistoryUpdateRoleView : IHistoryUpdateRoleView
+ {
+ private readonly List _published = [];
+ private readonly Lock _gate = new();
+
+ public IReadOnlyList Published
+ {
+ get { lock (_gate) return _published.ToArray(); }
+ }
+
+ public bool ShouldServeHistoryUpdate
+ {
+ get { lock (_gate) return _published.Count > 0 && _published[^1]; }
+ }
+
+ public void PublishHistoryUpdateRole(bool shouldServeHistoryUpdate)
+ {
+ lock (_gate) _published.Add(shouldServeHistoryUpdate);
+ }
+ }
+}