fix(historian): #674 review follow-ups — HistoryUpdate boot-window gate, node-level v1 refusal, plan-before-gate, UTC skew compare
- HistoryUpdate Primary gate now reads IHistoryUpdateRoleView (PrimaryGatePolicy.ShouldServeHistoryUpdate): the data-plane verdict but CLOSED until this node's first redundancy snapshot, so both pair nodes cannot accept in the pre-join boot window. Single nodes open on their own singleton's first snapshot; admin-only nodes register no view => no gate. DriverHostActor publishes it at every Primary-verdict publish point. - SqlServer v1: the updater throws NotSupportedException on every arm; the node manager maps it to a node-level BadHistoryOperationUnsupported (was per-item under a Good node for UpdateData/DeleteAtTime). HistoryWrite bit still advertised on v1 (not knowable at materialise time) — documented. - The update plan is evaluated before the Primary gate: an unservable request (Remove, IsDeleteModified, not historized, no updater) is BadHistoryOperationUnsupported off-Primary, not BadInvalidState. - Local-kind SourceTimestamp converted to UTC before the future-skew compare and sent as UTC. - Hosted-service wiring extracted to BuildHistoryUpdatePosture and tested (gate source, skew, clock). - Docs: Historian.md (boot window, ordering, node-level v1, HistoryWrite bit), CLAUDE.md. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -27,10 +27,4 @@ public static class HistorianStatusCodes
|
||||
/// status, or a tag name longer than <see cref="HistorianTagNames.MaxLength"/>.
|
||||
/// </summary>
|
||||
public const uint BadInvalidArgument = 0x80AB0000u;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const uint BadHistoryOperationUnsupported = 0x80720000u;
|
||||
}
|
||||
|
||||
+16
-16
@@ -28,8 +28,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
/// <see cref="HistoryModificationType"/> and <c>SYSUTCDATETIME()</c>. One statement means the sample change and its
|
||||
/// audit row commit or roll back together: a failed update leaves no audit row.</para>
|
||||
/// <para><b>Schema v1 (Gitea #674).</b> Without the trail (<see cref="SqlServerSchemaBootstrapper.SupportsModifiedTrail"/>
|
||||
/// false) a change could not be audited, so every item (and DeleteRaw's single result) reports
|
||||
/// <c>BadHistoryOperationUnsupported</c> and nothing is sent to the database beyond the one-time schema check.</para>
|
||||
/// false) a change could not be audited, so every call throws <see cref="NotSupportedException"/> — which the node
|
||||
/// manager reports as a node-level <c>BadHistoryOperationUnsupported</c>, on all three arms alike — and nothing is
|
||||
/// sent to the database beyond the one-time schema check.</para>
|
||||
/// </summary>
|
||||
internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
|
||||
{
|
||||
@@ -49,20 +50,19 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
|
||||
_tags = tags;
|
||||
}
|
||||
|
||||
/// <summary>#674: ensures the schema and reports whether the Part 11 trail exists (no SQL after the first success).</summary>
|
||||
/// <summary>
|
||||
/// #674: ensures the schema and refuses the call when it has no Part 11 trail (no SQL after the first success).
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>True when the modification trail is available.</returns>
|
||||
private async Task<bool> TrailAvailableAsync(CancellationToken ct)
|
||||
/// <returns>A task completing when the trail is available.</returns>
|
||||
/// <exception cref="NotSupportedException">The schema is below <see cref="SqlServerSchemaBootstrapper.ModifiedTrailVersion"/>.</exception>
|
||||
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).");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -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<long, UpsertOutcome>());
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user