Merge feat/668-modified-values: Part 11 modified-value history (IsReadModified) on SqlServer + QuestDB (#668)

This commit is contained in:
Joseph Doherty
2026-10-11 09:32:13 -04:00
51 changed files with 2173 additions and 195 deletions
@@ -0,0 +1,69 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
// OPC UA Part 11 modified-value history (Gitea #668): every HistoryUpdate a backend applies leaves an audit row
// carrying the PRIOR value, who made the change and when; HistoryRead with IsReadModified=true serves them.
/// <summary>Who is performing a HistoryUpdate. Threaded into every <see cref="IHistorianDataUpdater"/> call.</summary>
/// <param name="Principal">
/// The session's authenticated principal (an API key's principal, or the reserved <c>anonymous</c>). Recorded as
/// the modification's user name.
/// </param>
/// <param name="ApiKeyId">The API key id the session authenticated with, or <c>null</c> (anonymous / non-key).</param>
public sealed record HistoryUpdateContext(string Principal, string? ApiKeyId)
{
/// <summary>The longest principal / key id a backend stores; longer values are truncated (never refused).</summary>
public const int MaxIdentityLength = 200;
/// <summary>The principal clamped to <see cref="MaxIdentityLength"/> characters.</summary>
public string StoredPrincipal => Clamp(Principal) ?? string.Empty;
/// <summary>The key id clamped to <see cref="MaxIdentityLength"/> characters.</summary>
public string? StoredApiKeyId => Clamp(ApiKeyId);
private static string? Clamp(string? s) => s is { Length: > MaxIdentityLength } ? s[..MaxIdentityLength] : s;
}
/// <summary>
/// The OPC UA Part 11 <c>HistoryUpdateType</c> of a recorded modification. The numeric values are the spec's and
/// are persisted (SQL <c>tinyint</c>, QuestDB <c>BYTE</c>) — never renumber.
/// </summary>
public enum HistoryModificationType : byte
{
/// <summary>A sample was inserted where none existed (no prior value).</summary>
Insert = 1,
/// <summary>An existing sample was replaced.</summary>
Replace = 2,
/// <summary>An upsert (<c>PerformUpdateType.Update</c>): replaced a sample, or inserted one (then no prior value).</summary>
Update = 3,
/// <summary>A sample was deleted (DeleteAtTime / DeleteRaw).</summary>
Delete = 4,
}
/// <summary>One recorded modification: the value as it was BEFORE the change, plus who changed it and when.</summary>
/// <param name="Value">
/// The prior value at its source timestamp. An insert had no prior value: <c>Value = null</c>,
/// <c>StatusCode = BadNoData</c>.
/// </param>
/// <param name="ModificationTimeUtc">When the modification was applied.</param>
/// <param name="UpdateType">The kind of modification.</param>
/// <param name="UserName">The principal that made it.</param>
/// <param name="ModifiedId">The backend's ordering key within one source timestamp (paging cursor).</param>
public sealed record HistoryModifiedValue(
DataValueSnapshot Value,
DateTime ModificationTimeUtc,
HistoryModificationType UpdateType,
string UserName,
long ModifiedId);
/// <summary>Resume position for a paged modified-value read: strictly after (or before, reversed) this key.</summary>
/// <param name="SourceTimeUtc">The last returned modification's source time.</param>
/// <param name="ModifiedId">The last returned modification's id.</param>
public sealed record HistoryModifiedCursor(DateTime SourceTimeUtc, long ModifiedId);
/// <summary>Result of <see cref="IHistorianDataSource.ReadModifiedAsync"/>.</summary>
/// <param name="Values">Modifications ordered by <c>(SourceTimeUtc, ModifiedId)</c> (descending for a reverse read).</param>
/// <param name="HasMore"><c>true</c> when the cap truncated the result and more modifications remain in the window.</param>
public sealed record HistoryModifiedReadResult(IReadOnlyList<HistoryModifiedValue> Values, bool HasMore);
@@ -122,4 +122,32 @@ public interface IHistorianDataSource : IDisposable
/// Same-timestamp ties are unaffected (they are paged within the timestamp, never stepped over).
/// </remarks>
long TimestampResolutionTicks => 1;
/// <summary>
/// Read modified-value history — OPC UA HistoryReadRaw with <c>IsReadModified = true</c> (Part 11, Gitea #668):
/// every modification a HistoryUpdate applied to the tag inside the window, each carrying the PRIOR value.
/// </summary>
/// <remarks>
/// Window: forward (<c>start &lt; end</c>) <c>[start, end)</c> ascending by <c>(SourceTimeUtc, ModifiedId)</c>;
/// reverse (<c>end &lt; start</c>) <c>(end, start]</c> descending; <c>start == end</c> the modifications at
/// exactly that time. <paramref name="resumeAfter"/> continues strictly past that key in the read's direction.
/// The default throws <see cref="NotSupportedException"/> — a backend that keeps no modification trail
/// (Gateway, Null) — which the server reports as <c>BadHistoryOperationUnsupported</c>.
/// </remarks>
/// <param name="fullReference">The historian tagname.</param>
/// <param name="startUtc">Window start (UTC).</param>
/// <param name="endUtc">Window end (UTC).</param>
/// <param name="maxValues">Cap on returned modifications; <c>0</c> is unbounded.</param>
/// <param name="resumeAfter">Resume cursor from a previous page, or <c>null</c> for the first page.</param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns>The modifications, with <see cref="HistoryModifiedReadResult.HasMore"/> set when truncated.</returns>
/// <exception cref="NotSupportedException">The backend keeps no modified-value history.</exception>
Task<HistoryModifiedReadResult> ReadModifiedAsync(
string fullReference,
DateTime startUtc,
DateTime endUtc,
uint maxValues,
HistoryModifiedCursor? resumeAfter,
CancellationToken cancellationToken) =>
throw new NotSupportedException("This historian backend keeps no modified-value history.");
}
@@ -17,12 +17,20 @@ public enum HistoryUpdateMode
/// Optional historian capability behind OPC UA HistoryUpdate. A backend that cannot modify history
/// supplies no updater (the server then reports HistoryUpdate as unsupported).
/// </summary>
/// <remarks>
/// <b>Modified-value history (Part 11, Gitea #668).</b> Every sample an implementation inserts, replaces or deletes
/// MUST leave one modification record carrying the prior value (none for an insert), the
/// <see cref="HistoryUpdateContext.Principal"/>, the modification time and the <see cref="HistoryModificationType"/>,
/// readable through <see cref="IHistorianDataSource.ReadModifiedAsync"/>. A refused item (any non-Good code) leaves
/// no record. Live recorder writes and retention purges never leave one.
/// </remarks>
public interface IHistorianDataUpdater
{
/// <summary>Inserts, replaces or upserts samples for one tag.</summary>
/// <param name="tag">Historian tag name.</param>
/// <param name="mode">Update mode.</param>
/// <param name="values">Samples to apply.</param>
/// <param name="context">Who is making the change (recorded in the modification trail).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// One status code per value, in order: Insert gives <c>GoodEntryInserted</c> or <c>BadEntryExists</c>;
@@ -30,20 +38,24 @@ public interface IHistorianDataUpdater
/// <c>GoodEntryInserted</c> or <c>GoodEntryReplaced</c>; a null timestamp gives <c>BadInvalidTimestamp</c>.
/// </returns>
Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode,
IReadOnlyList<HistorizationValue> values, CancellationToken ct);
IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context, CancellationToken ct);
/// <summary>Deletes raw samples in a time range.</summary>
/// <param name="tag">Historian tag name.</param>
/// <param name="startUtc">Range start (UTC).</param>
/// <param name="endUtc">Range end (UTC).</param>
/// <param name="context">Who is making the change (recorded in the modification trail).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns><c>Good</c>, or <c>BadNoData</c> when the range held nothing.</returns>
Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct);
Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context,
CancellationToken ct);
/// <summary>Deletes the samples at exact timestamps.</summary>
/// <param name="tag">Historian tag name.</param>
/// <param name="timesUtc">Timestamps to delete (UTC).</param>
/// <param name="context">Who is making the change (recorded in the modification trail).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>One status code per time, in order: <c>Good</c> or <c>BadNoEntryExists</c>.</returns>
Task<IReadOnlyList<uint>> DeleteAtTimeAsync(string tag, IReadOnlyList<DateTime> timesUtc, CancellationToken ct);
Task<IReadOnlyList<uint>> DeleteAtTimeAsync(string tag, IReadOnlyList<DateTime> timesUtc, HistoryUpdateContext context,
CancellationToken ct);
}