2124f21ab6
v2-ci / build (pull_request) Failing after 38s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (pull_request) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (pull_request) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (pull_request) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (pull_request) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (pull_request) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (pull_request) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (pull_request) Has been skipped
HistorianGateway is now the sole historian backend (read + alarm SendEvent + continuous WriteLiveValues). Document the final state and retire the Wonderware sidecar from the docs/config/labels: - CLAUDE.md: rewrite the Historian section — ServerHistorian / ContinuousHistorization / AlarmHistorian config keys, the IHistorianProvisioning EnsureTags hook, the GatewayAlarmHistorianWriter SendEvent path + ReadEvents dependency on gateway RuntimeDb:EventReadsEnabled=true, gateway-side prerequisites (RuntimeDb flags + historian:read/write/tags:write scopes), migration note, and two KNOWN-LIMITATION callouts (live-validation gate + empty historized-ref-set recorder follow-on). - appsettings.json: fix the stale ServerHistorian block (Host/Port/SharedSecret/ ServerCertThumbprint -> Endpoint/ApiKey/UseTls/AllowUntrustedServerCertificate/ CaCertificatePath/CallTimeout, keep MaxTieClusterOverfetch); add a disabled ContinuousHistorization block; prune the orphaned Wonderware keys from AlarmHistorian (keep the SQLite knobs). ApiKey env-supplied via ServerHistorian__ApiKey (commented; valid strict JSON via _comment keys). - README.md + docs (Historian.md, AlarmHistorian.md, Configuration.md, ServiceHosting.md, DriverLifecycle.md, drivers/README.md, Uns.md, VirtualTags.md, AlarmTracking.md, Client.UI.md, README.md, TestConnectProbes.md): retire the Wonderware historian backend from current-backend descriptions; fix the stale ServerHistorian/AlarmHistorian config tables (now gateway shape); convert drivers/Historian.Wonderware.md to a retired stub pointing at the gateway. - Source/UI labels (descriptive text only, no behavior change): OtOpcUaServerHostedService.cs, HistoryPaging.cs, OtOpcUaSdkServer.cs, HistorianAdapterActor.cs, VirtualTagModal.razor, ScriptedAlarmModal.razor, AlarmsHistorian.razor now name the HistorianGateway backend. Build clean (0 errors); AdminUI.Tests green (514 passed). Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
218 lines
14 KiB
C#
218 lines
14 KiB
C#
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
/// <summary>
|
|
/// The server-side resume state stored behind an opaque continuation point for a single paged
|
|
/// HistoryRead-Raw read. Captures exactly enough to continue the SAME logical read from where the
|
|
/// previous page stopped: the tagname, the original (inclusive) end of the window, the next start of
|
|
/// the window, and the tie-safe boundary skip. (Only Raw pages server-side — Processed and AtTime
|
|
/// carry no client count cap, so they are single-shot and never produce a continuation point; see
|
|
/// <see cref="HistoryPaging"/>.)
|
|
/// <para>
|
|
/// The boundary fields (<see cref="NextStartUtc"/> + <see cref="BoundarySkipCount"/>) encode a
|
|
/// tie-safe resume cursor: the next page reads from <see cref="NextStartUtc"/> INCLUSIVE and
|
|
/// then drops the first <see cref="BoundarySkipCount"/> samples whose SourceTimestamp equals
|
|
/// <see cref="NextStartUtc"/>. This guarantees that samples sharing the page-boundary timestamp
|
|
/// are neither re-returned (duplicate) nor skipped — see <see cref="HistoryPaging"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// This record carries no SDK types so the whole paging decision surface is a pure, allocation-
|
|
/// cheap value that unit tests can drive directly.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="Tagname">The resolved historian tagname (NOT the NodeId) to read from.</param>
|
|
/// <param name="NextStartUtc">
|
|
/// Inclusive lower bound for the next page — the boundary timestamp the previous page stopped on.
|
|
/// </param>
|
|
/// <param name="EndUtc">The original (inclusive) upper bound of the read window; unchanged across pages.</param>
|
|
/// <param name="BoundarySkipCount">
|
|
/// How many samples whose SourceTimestamp equals <see cref="NextStartUtc"/> were already returned on
|
|
/// prior pages and must be dropped from the head of the next page (tie de-dup).
|
|
/// </param>
|
|
/// <param name="NumValuesPerNode">The client's per-page cap; re-applied to every resumed page.</param>
|
|
internal sealed record HistoryContinuationState(
|
|
string Tagname,
|
|
DateTime NextStartUtc,
|
|
DateTime EndUtc,
|
|
int BoundarySkipCount,
|
|
uint NumValuesPerNode);
|
|
|
|
/// <summary>
|
|
/// Pure server-side continuation-point paging decisions for the count-capped variable-history arms
|
|
/// (Raw / Processed). The backend (the HistorianGateway read client) does NOT page — it returns up to
|
|
/// <c>NumValuesPerNode</c> samples with a null continuation point — so paging is synthesised here,
|
|
/// time-based:
|
|
/// <list type="bullet">
|
|
/// <item>A page that returns EXACTLY the requested cap (<c>NumValuesPerNode > 0</c>) MAY have
|
|
/// more behind it ⇒ emit a continuation point.</item>
|
|
/// <item>A short page (fewer than the cap) is the last page ⇒ no continuation point.</item>
|
|
/// <item><c>NumValuesPerNode == 0</c> means "all values, no limit" (OPC UA Part 11) ⇒ never page;
|
|
/// return everything in one shot.</item>
|
|
/// </list>
|
|
/// All methods are static + pure so they unit-test without a server, a session, or the SDK.
|
|
/// </summary>
|
|
internal static class HistoryPaging
|
|
{
|
|
/// <summary>
|
|
/// Decide whether a just-returned page is "full" and therefore MAY be followed by more data —
|
|
/// the signal to emit a continuation point. A page is full when the client asked for a finite
|
|
/// cap (<paramref name="numValuesPerNode"/> > 0) and the backend returned exactly that many
|
|
/// samples. A short page (or an unlimited <c>0</c> request) is terminal.
|
|
/// </summary>
|
|
/// <param name="returnedCount">The number of samples the backend returned for this page.</param>
|
|
/// <param name="numValuesPerNode">The client's per-page cap; <c>0</c> means "no limit".</param>
|
|
/// <returns><c>true</c> when a continuation point should be emitted; otherwise <c>false</c>.</returns>
|
|
public static bool IsFullPage(int returnedCount, uint numValuesPerNode) =>
|
|
numValuesPerNode > 0 && returnedCount >= numValuesPerNode;
|
|
|
|
/// <summary>
|
|
/// Build the resume cursor (next-start + boundary skip count) from the last sample of a full
|
|
/// page, tie-safe against samples that share the boundary SourceTimestamp.
|
|
/// <para>
|
|
/// The next page resumes from the LAST returned sample's SourceTimestamp <em>inclusive</em>
|
|
/// (NOT advanced by a tick), and the returned <paramref name="boundarySkipCount"/> counts how
|
|
/// many samples in the page already carry that exact boundary timestamp. Resuming inclusively
|
|
/// + dropping that many head samples guarantees:
|
|
/// <list type="bullet">
|
|
/// <item>no sample is re-returned (the ones already emitted at the boundary are skipped), and</item>
|
|
/// <item>no sample is skipped (any un-emitted ties at the boundary are still read because we
|
|
/// start AT the boundary, not after it).</item>
|
|
/// </list>
|
|
/// A naive "+1 tick" advance would skip un-emitted ties; this carry-offset strategy does not.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="page">The page just returned (chronological, non-empty — guaranteed by the caller,
|
|
/// which only pages a full page and a full page implies <c>NumValuesPerNode > 0</c> samples).</param>
|
|
/// <param name="nextStartUtc">The boundary timestamp the next page resumes from (inclusive).</param>
|
|
/// <param name="boundarySkipCount">How many head samples at <paramref name="nextStartUtc"/> the next
|
|
/// page must drop (samples already emitted at the boundary timestamp).</param>
|
|
public static void ComputeResumeCursor(
|
|
IReadOnlyList<DataValueSnapshot> page,
|
|
out DateTime nextStartUtc,
|
|
out int boundarySkipCount)
|
|
{
|
|
// Enforce the documented precondition at the API boundary rather than relying on the caller's guard
|
|
// (the only caller only pages a full, non-empty page, but this is a public static helper).
|
|
if (page.Count == 0)
|
|
throw new ArgumentOutOfRangeException(nameof(page), "ComputeResumeCursor requires a non-empty page.");
|
|
|
|
// The boundary is the last returned sample's SourceTimestamp. A sample whose SourceTimestamp is
|
|
// null (Bad/unset) cannot anchor a time cursor; fall back to MinValue so the next read covers the
|
|
// whole remaining window rather than silently dropping data — duplicates are then de-duped by the
|
|
// skip count below.
|
|
var last = page[^1];
|
|
nextStartUtc = last.SourceTimestampUtc ?? DateTime.MinValue;
|
|
|
|
// Count how many trailing samples in THIS page share the boundary timestamp — those are the ties
|
|
// already emitted at the boundary that the next page must drop from its head.
|
|
var skip = 0;
|
|
for (var i = page.Count - 1; i >= 0; i--)
|
|
{
|
|
if ((page[i].SourceTimestampUtc ?? DateTime.MinValue) == nextStartUtc) skip++;
|
|
else break;
|
|
}
|
|
|
|
boundarySkipCount = skip;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drop the first <paramref name="boundarySkipCount"/> samples of a freshly-read resumed page
|
|
/// whose SourceTimestamp equals the boundary <paramref name="boundaryUtc"/> — the ties already
|
|
/// emitted on the previous page. Samples past the boundary timestamp are always kept (only the
|
|
/// exact-boundary head is trimmed), so a backend that returns fewer boundary ties than expected
|
|
/// (data pruned between pages) still yields a correct, monotonic result.
|
|
/// </summary>
|
|
/// <param name="resumedPage">The page returned by the resumed backend read (chronological).</param>
|
|
/// <param name="boundaryUtc">The boundary timestamp the resume read started at (inclusive).</param>
|
|
/// <param name="boundarySkipCount">How many head samples at <paramref name="boundaryUtc"/> to drop.</param>
|
|
/// <returns>The page with the already-emitted boundary ties trimmed from the head.</returns>
|
|
public static IReadOnlyList<DataValueSnapshot> TrimBoundaryDuplicates(
|
|
IReadOnlyList<DataValueSnapshot> resumedPage,
|
|
DateTime boundaryUtc,
|
|
int boundarySkipCount)
|
|
{
|
|
if (boundarySkipCount <= 0 || resumedPage.Count == 0) return resumedPage;
|
|
|
|
var dropped = 0;
|
|
var i = 0;
|
|
while (i < resumedPage.Count
|
|
&& dropped < boundarySkipCount
|
|
&& (resumedPage[i].SourceTimestampUtc ?? DateTime.MinValue) == boundaryUtc)
|
|
{
|
|
i++;
|
|
dropped++;
|
|
}
|
|
|
|
if (i == 0) return resumedPage;
|
|
// Slice off the trimmed head; return a copy so the caller owns a plain list (no SDK coupling).
|
|
var trimmed = new List<DataValueSnapshot>(resumedPage.Count - i);
|
|
for (var j = i; j < resumedPage.Count; j++) trimmed.Add(resumedPage[j]);
|
|
return trimmed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Page WITHIN a single oversized "tie cluster" — a set of raw samples that all share one
|
|
/// SourceTimestamp <paramref name="boundaryT"/> and is larger than the client's per-page cap.
|
|
/// <para>
|
|
/// The fixed-<c>(start, end, cap)</c> historian backend cannot skip/offset, so an oversized
|
|
/// tie cluster defeats the (timestamp, skip) resume cursor that <see cref="ComputeResumeCursor"/>
|
|
/// builds: every resume re-reads the first <c>cap</c> ties, the boundary-tie trim empties the
|
|
/// page, and the cursor never advances. To page past it, the caller over-fetches the WHOLE
|
|
/// cluster (a bounded <c>start == end == T</c> read) and hands it here. We then carve out the
|
|
/// next <paramref name="cap"/> ties starting at <paramref name="skip"/> and compute a cursor
|
|
/// that advances within the cluster, then steps off it when it is drained.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Advance is lossless.</b> When the slice drains the cluster (<c>emitted == clusterCount</c>)
|
|
/// we resume from <c>T + 1 tick</c> with a fresh skip of <c>0</c>. This skips no data because no
|
|
/// <see cref="DateTime"/> value exists strictly between <c>T</c> and <c>T + 1 tick</c> — a tick
|
|
/// (100 ns) is the type's resolution — so every remaining sample in the window has a timestamp
|
|
/// of at least <c>T + 1 tick</c>. We do NOT resume inclusively at <c>T</c> here (the way
|
|
/// <see cref="ComputeResumeCursor"/> does for cross-boundary ties): we have already over-fetched
|
|
/// and emitted the entire <c>T</c> cluster, so resuming at <c>T</c> would needlessly re-read it.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Short-page-with-CP exception.</b> A page that fully drains the cluster but is SHORTER than
|
|
/// <paramref name="cap"/> (the cluster had fewer than <c>cap</c> remaining ties) STILL emits a
|
|
/// continuation point when the window extends past <c>T</c> (<c>T + 1 tick <= endUtc</c>). This
|
|
/// deliberately violates the usual "short page ⇒ terminal" rule (<see cref="IsFullPage"/>):
|
|
/// the page is short only because the cluster ran out, not because the window did, so there may
|
|
/// still be data after <c>T</c> that the next page must read.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Self-heal.</b> If <paramref name="skip"/> meets or exceeds <paramref name="clusterCount"/>
|
|
/// (e.g. a stale cursor against a cluster that shrank between reads), <c>sliceStart</c> clamps to
|
|
/// <paramref name="clusterCount"/> and <c>sliceCount</c> is <c>0</c>; since <c>emitted</c> then
|
|
/// equals <paramref name="clusterCount"/>, the cursor advances/terminates rather than looping.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="clusterCount">The total number of ties at <paramref name="boundaryT"/> (the over-fetched cluster size).</param>
|
|
/// <param name="skip">How many ties at <paramref name="boundaryT"/> were already emitted on prior pages.</param>
|
|
/// <param name="cap">The client's per-page cap (<c>NumValuesPerNode</c>); must be > 0 to make progress.</param>
|
|
/// <param name="boundaryT">The single SourceTimestamp every tie in the cluster shares.</param>
|
|
/// <param name="endUtc">The (inclusive) upper bound of the read window; unchanged across pages.</param>
|
|
/// <param name="sliceStart">The index into the cluster the emitted slice starts at (clamped to the count).</param>
|
|
/// <param name="sliceCount">How many ties the emitted slice contains (may be <c>0</c> on a stale-skip self-heal).</param>
|
|
/// <param name="nextStartUtc">The next page's inclusive start: <paramref name="boundaryT"/> while the
|
|
/// cluster still has un-emitted ties, <c>boundaryT + 1 tick</c> once it is drained and the window
|
|
/// remains, or <c>null</c> when the window is exhausted (terminal — no continuation point).</param>
|
|
/// <param name="nextSkip">The next page's boundary skip count: the running emitted-tie total while the
|
|
/// cluster drains, else <c>0</c> after advancing past it.</param>
|
|
public static void SliceTieCluster(
|
|
int clusterCount, int skip, uint cap, DateTime boundaryT, DateTime endUtc,
|
|
out int sliceStart, out int sliceCount, out DateTime? nextStartUtc, out int nextSkip)
|
|
{
|
|
sliceStart = Math.Min(skip, clusterCount);
|
|
sliceCount = Math.Min((int)cap, clusterCount - sliceStart);
|
|
var emitted = sliceStart + sliceCount;
|
|
if (emitted < clusterCount) { nextStartUtc = boundaryT; nextSkip = emitted; }
|
|
else
|
|
{
|
|
var next = boundaryT.AddTicks(1);
|
|
if (next <= endUtc) { nextStartUtc = next; nextSkip = 0; }
|
|
else { nextStartUtc = null; nextSkip = 0; }
|
|
}
|
|
}
|
|
}
|