feat(historian): Part 11 modified-value history on SqlServer + QuestDB (#668)

Every HistoryUpdate a SQL backend applies now leaves a modification trail
(prior value, principal, Part 11 update type, modification time), and
HistoryReadRaw with IsReadModified=true serves it as HistoryModifiedData.

- Core: HistoryUpdateContext threaded into all three IHistorianDataUpdater
  methods; IHistorianDataSource.ReadModifiedAsync (default NotSupported =>
  BadHistoryOperationUnsupported on Gateway/Null); HistoryModified* contracts.
- SqlServer: schema v2 (002_sample_modified.sql, RequiredVersion=2, docs copy);
  trail written by OUTPUT ... INTO on the same MERGE/DELETE statement, so a
  failed update leaves no audit row; retention purges SampleModified per tag on
  the source-time cutoff; ReadModifiedAsync keyed on (SourceTimeUtc, ModifiedId).
- QuestDB: append-only {prefix}sample_modified (TTL like samples), trail row
  written BEFORE the change (orphan-on-crash, never an untracked change);
  existence reads now carry the prior value; ReadModifiedAsync.
- Node manager: modified reads dispatch + page via the #669 continuation store
  (HistoryContinuationState gains HistoryCursorKind + LastModifiedId), gated by
  the existing HistoryRead decision; session principal / key id passed to the
  updater. IsDeleteModified stays unsupported.
- Client.CLI: historyread --modified.
- Tests: node-manager dispatch/paging/cursor binding/principal threading,
  over-the-wire HistoryModifiedData, shared ModifiedValueConformance on both
  backends, SqlServer atomicity (forced trail failure => no change) + v1->v2
  upgrade + retention trail purge.
- Docs: Historian.md (Modified-value history section, schema v2 upgrade note),
  security.md, Client.CLI.md, CLAUDE.md.
This commit is contained in:
Joseph Doherty
2026-10-11 09:26:36 -04:00
parent d803e0eab4
commit cfd403710e
51 changed files with 2173 additions and 195 deletions
@@ -326,6 +326,20 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
return Task.FromResult(HistoryReadResult);
}
/// <summary>Gets the list of (NodeId, start, end, maxValues) tuples from HistoryReadModifiedAsync calls.</summary>
public List<(NodeId NodeId, DateTime Start, DateTime End, int MaxValues)> HistoryReadModifiedCalls { get; } = [];
/// <summary>Gets or sets the entries returned by HistoryReadModifiedAsync.</summary>
public IReadOnlyList<HistoryModifiedEntry> HistoryReadModifiedResult { get; set; } = [];
/// <inheritdoc />
public Task<IReadOnlyList<HistoryModifiedEntry>> HistoryReadModifiedAsync(
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default)
{
HistoryReadModifiedCalls.Add((nodeId, startTime, endTime, maxValues));
return Task.FromResult(HistoryReadModifiedResult);
}
/// <summary>Gets the details passed to HistoryUpdateAsync.</summary>
public List<HistoryUpdateDetails> HistoryUpdateCalls { get; } = [];
@@ -146,4 +146,55 @@ public class HistoryReadCommandTests
fakeService.DisconnectCalled.ShouldBeTrue();
fakeService.DisposeCalled.ShouldBeTrue();
}
}
/// <summary>#668: --modified reads the modification trail (not raw) and prints update type, user and prior value.</summary>
[Fact]
public async Task Execute_ModifiedRead_CallsHistoryReadModified_and_prints_the_trail()
{
var t = new DateTime(2025, 6, 15, 10, 0, 0, DateTimeKind.Utc);
var fakeService = new FakeOpcUaClientService
{
HistoryReadModifiedResult =
[
new HistoryModifiedEntry(new DataValue(Variant.Null, StatusCodes.BadNoData, t, t), t.AddDays(1), HistoryUpdateType.Insert, "editor"),
new HistoryModifiedEntry(new DataValue(new Variant(1.5), StatusCodes.Good, t, t), t.AddDays(2), HistoryUpdateType.Replace, "editor"),
],
};
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(fakeService))
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=HistNode",
Modified = true,
MaxValues = 50,
};
using var console = TestConsoleHelper.CreateConsole();
await command.ExecuteAsync(console);
fakeService.HistoryReadModifiedCalls.ShouldHaveSingleItem().MaxValues.ShouldBe(50);
fakeService.HistoryReadRawCalls.ShouldBeEmpty();
var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Modified history for ns=2;s=HistNode");
output.ShouldContain("Replace");
output.ShouldContain("Insert");
output.ShouldContain("editor");
output.ShouldContain("(none)");
output.ShouldContain("2 modifications returned.");
}
/// <summary>--modified cannot be combined with --aggregate.</summary>
[Fact]
public async Task Execute_ModifiedWithAggregate_ThrowsCommandException()
{
var command = new HistoryReadCommand(new FakeOpcUaClientServiceFactory(new FakeOpcUaClientService()))
{
Url = "opc.tcp://localhost:4840",
NodeId = "ns=2;s=HistNode",
Modified = true,
Aggregate = "Average",
};
using var console = TestConsoleHelper.CreateConsole();
await Should.ThrowAsync<CliFx.Exceptions.CommandException>(async () => await command.ExecuteAsync(console));
}
}
@@ -8,6 +8,9 @@ public static class ConformanceData
/// <summary>Base time: 2026-10-09 08:00:00.1234567 UTC (sub-millisecond ticks on purpose).</summary>
public static readonly DateTime T0 = new DateTime(2026, 10, 9, 8, 0, 0, DateTimeKind.Utc).AddTicks(1_234_567);
/// <summary>The HistoryUpdate caller the suite passes to every updater call (recorded in the modification trail).</summary>
public static readonly ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryUpdateContext Editor = new("conformance-editor", "k-conf");
/// <summary>Good.</summary>
public const uint Good = 0x00000000u;
/// <summary>Bad (generic).</summary>
@@ -0,0 +1,217 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
/// <summary>
/// OPC UA Part 11 modified-value history (Gitea #668): every HistoryUpdate a backend applies leaves an ordered
/// modification trail — the PRIOR value, the caller's principal, the Part 11 update type and the modification time —
/// readable through <see cref="IHistorianDataSource.ReadModifiedAsync"/>. Refused items, live recorder writes and
/// reads leave nothing.
/// </summary>
public abstract class ModifiedValueConformance : ConformanceBase
{
private static readonly DateTime T0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static DateTime T(int n) => T0.AddSeconds(n).AddTicks(1230); // µs-aligned so both backends store it exactly
private static readonly DateTime Lo = T0.AddHours(-1);
private static readonly DateTime Hi = T0.AddHours(1);
private static readonly CancellationToken Ct = CancellationToken.None;
private static readonly HistoryUpdateContext Editor = ConformanceData.Editor;
private static HistorizationValue V(int n, double d, uint status = Good) =>
new(T(n), HistorianValue.FromDouble(d), status);
private async Task<IReadOnlyList<HistoryModifiedValue>> TrailAsync(
string tag, DateTime? start = null, DateTime? end = null, uint max = 0, HistoryModifiedCursor? after = null)
{
using var src = B.CreateDataSource();
var r = await src.ReadModifiedAsync(tag, start ?? Lo, end ?? Hi, max, after, Ct);
if (max == 0) r.HasMore.ShouldBeFalse();
return r.Values;
}
private static void ShouldBeNoPrior(HistoryModifiedValue m)
{
m.Value.Value.ShouldBeNull();
m.Value.StatusCode.ShouldBe(HistorianStatusCodes.BadNoData);
}
[Fact]
public async Task Insert_records_one_Insert_per_sample_with_no_prior_value_and_the_principal()
{
var before = DateTime.UtcNow.AddSeconds(-5);
var u = B.CreateUpdater();
(await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(2, 2), V(1, 1)], Editor, Ct))
.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted]);
var trail = await TrailAsync("t");
trail.Count.ShouldBe(2);
trail.Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(1), T(2)]); // ordered by source time
trail.ShouldAllBe(m => m.UpdateType == HistoryModificationType.Insert);
trail.ShouldAllBe(m => m.UserName == "conformance-editor");
trail.ShouldAllBe(m => m.ModificationTimeUtc >= before && m.ModificationTimeUtc <= DateTime.UtcNow.AddSeconds(5));
foreach (var m in trail) ShouldBeNoPrior(m);
}
[Fact]
public async Task Replace_records_the_prior_value_and_status_after_the_insert()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1)], Editor, Ct);
var other = new HistoryUpdateContext("second-editor", null);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(1, 11, Uncertain)], other, Ct))
.ShouldBe([HistorianStatusCodes.GoodEntryReplaced]);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(1, 111)], Editor, Ct))
.ShouldBe([HistorianStatusCodes.GoodEntryReplaced]);
var trail = await TrailAsync("t");
trail.Select(m => m.UpdateType).ShouldBe(
[HistoryModificationType.Insert, HistoryModificationType.Replace, HistoryModificationType.Replace]);
trail.Select(m => m.UserName).ShouldBe(["conformance-editor", "second-editor", "conformance-editor"]);
ShouldBeNoPrior(trail[0]);
trail[1].Value.Value.ShouldBe(1d); // what the first Replace overwrote
trail[1].Value.StatusCode.ShouldBe(Good);
trail[2].Value.Value.ShouldBe(11d); // what the second Replace overwrote, with its status
trail[2].Value.StatusCode.ShouldBe(Uncertain);
trail.ShouldAllBe(m => m.Value.SourceTimestampUtc == T(1));
trail.Select(m => m.ModifiedId).ShouldBeInOrder(SortDirection.Ascending);
trail.Select(m => m.ModifiedId).Distinct().Count().ShouldBe(3);
// The sample itself carries the latest value.
(await B.ReadBackAsync("t")).ShouldHaveSingleItem().V.Number.ShouldBe(111d);
}
[Fact]
public async Task Update_records_Update_with_the_prior_value_when_it_replaced_and_none_when_it_inserted()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1)], Editor, Ct);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(2, 2), V(1, 10)], Editor, Ct))
.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryReplaced]);
var trail = await TrailAsync("t");
trail.Select(m => (m.Value.SourceTimestampUtc, m.UpdateType)).ShouldBe(
[
(T(1), HistoryModificationType.Insert),
(T(1), HistoryModificationType.Update),
(T(2), HistoryModificationType.Update),
]);
trail[1].Value.Value.ShouldBe(1d);
ShouldBeNoPrior(trail[2]);
}
[Fact]
public async Task DeleteAtTime_records_a_Delete_carrying_each_deleted_value()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2), V(3, 3, Uncertain)], Editor, Ct);
var deleter = new HistoryUpdateContext("deleter", "k-del");
(await u.DeleteAtTimeAsync("t", [T(3), T(1), T(9)], deleter, Ct))
.ShouldBe([Good, Good, HistorianStatusCodes.BadNoEntryExists]);
var deletes = (await TrailAsync("t")).Where(m => m.UpdateType == HistoryModificationType.Delete).ToList();
deletes.Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(1), T(3)]);
deletes.Select(m => (double)m.Value.Value!).ShouldBe([1d, 3d]);
deletes[1].Value.StatusCode.ShouldBe(Uncertain);
deletes.ShouldAllBe(m => m.UserName == "deleter");
}
[Fact]
public async Task DeleteRaw_records_a_Delete_for_every_sample_it_removed()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2), V(3, 3)], Editor, Ct);
(await u.DeleteRawAsync("t", T(1), T(3), Editor, Ct)).ShouldBe(Good); // [T1, T3)
var deletes = (await TrailAsync("t")).Where(m => m.UpdateType == HistoryModificationType.Delete).ToList();
deletes.Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(1), T(2)]);
deletes.Select(m => (double)m.Value.Value!).ShouldBe([1d, 2d]);
}
[Fact]
public async Task Refused_items_leave_no_trail()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1)], Editor, Ct);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 99)], Editor, Ct)).ShouldBe([HistorianStatusCodes.BadEntryExists]);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(5, 5)], Editor, Ct)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [new HistorizationValue(null, HistorianValue.FromDouble(1), Good)], Editor, Ct))
.ShouldBe([HistorianStatusCodes.BadInvalidTimestamp]);
(await u.DeleteAtTimeAsync("t", [T(7)], Editor, Ct)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await u.DeleteRawAsync("t", T(10), T(20), Editor, Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
var trail = await TrailAsync("t");
trail.ShouldHaveSingleItem().UpdateType.ShouldBe(HistoryModificationType.Insert); // only the first, applied insert
}
[Fact]
public async Task Live_recorder_writes_leave_no_trail()
{
var w = B.CreateValueWriter();
(await w.WriteLiveValuesAsync("live", [new(T(1), HistorianValue.FromDouble(1), Good), new(T(2), HistorianValue.FromDouble(2), Good)], Ct))
.ShouldBeTrue();
(await B.ReadBackAsync("live")).Count.ShouldBe(2); // the writes landed …
(await TrailAsync("live")).ShouldBeEmpty(); // … and recorded no modification
}
[Fact]
public async Task An_unknown_tag_has_an_empty_trail_and_reads_create_no_tag()
{
(await TrailAsync("never")).ShouldBeEmpty();
(await B.TagExistsAsync("never")).ShouldBeFalse();
}
[Fact]
public async Task The_trail_pages_by_key_exactly_once_and_reports_HasMore()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2), V(3, 3)], Editor, Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(2, 20)], Editor, Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(2, 200)], Editor, Ct);
var all = await TrailAsync("t");
all.Count.ShouldBe(5); // T1 ins | T2 ins, rep, rep | T3 ins
var seen = new List<long>();
HistoryModifiedCursor? cursor = null;
using var src = B.CreateDataSource();
for (var page = 0; page < 10; page++)
{
var r = await src.ReadModifiedAsync("t", Lo, Hi, 2, cursor, Ct);
seen.AddRange(r.Values.Select(m => m.ModifiedId));
if (!r.HasMore) break;
r.Values.Count.ShouldBe(2);
var last = r.Values[^1];
cursor = new HistoryModifiedCursor(last.Value.SourceTimestampUtc!.Value, last.ModifiedId);
}
seen.ShouldBe(all.Select(m => m.ModifiedId)); // a page boundary inside the T2 run neither repeats nor skips
}
[Fact]
public async Task A_reverse_window_reads_the_trail_descending()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2)], Editor, Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(2, 20)], Editor, Ct);
var forward = await TrailAsync("t");
var reverse = await TrailAsync("t", start: Hi, end: Lo);
reverse.Select(m => m.ModifiedId).ShouldBe(forward.Select(m => m.ModifiedId).Reverse());
}
[Fact]
public async Task The_window_is_half_open_on_source_time()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2), V(3, 3)], Editor, Ct);
(await TrailAsync("t", start: T(1), end: T(3))).Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(1), T(2)]);
(await TrailAsync("t", start: T(2), end: T(2))).Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(2)]);
}
}
@@ -85,7 +85,7 @@ public abstract class QualityContractConformance : ConformanceBase
new(At(1), HistorianValue.Empty, 0x40000000u),
new(At(2), HistorianValue.Empty, BadNoCommunication),
new(At(3), HistorianValue.FromDouble(1), 0u),
], Ct);
], ConformanceData.Editor, Ct);
r.ShouldBe([BadInvalidArgument, BadInvalidArgument, HistorianStatusCodes.GoodEntryInserted,
HistorianStatusCodes.GoodEntryInserted]);
(await B.CreateDataSource().ReadRawAsync("u", At(0), At(10), 0, Ct)).Samples
@@ -39,10 +39,10 @@ public abstract class TagNameConformance : ConformanceBase
var u = B.CreateUpdater();
(await u.UpdateDataAsync(Long, HistoryUpdateMode.Update,
[new(At(0), HistorianValue.FromDouble(9), 0u), new(At(1), HistorianValue.FromDouble(9), 0u)], Ct))
[new(At(0), HistorianValue.FromDouble(9), 0u), new(At(1), HistorianValue.FromDouble(9), 0u)], ConformanceData.Editor, Ct))
.ShouldBe([BadInvalidArgument, BadInvalidArgument]);
(await u.DeleteRawAsync(Long, At(-1), At(10), Ct)).ShouldBe(BadInvalidArgument);
(await u.DeleteAtTimeAsync(Long, [At(0)], Ct)).ShouldBe([BadInvalidArgument]);
(await u.DeleteRawAsync(Long, At(-1), At(10), ConformanceData.Editor, Ct)).ShouldBe(BadInvalidArgument);
(await u.DeleteAtTimeAsync(Long, [At(0)], ConformanceData.Editor, Ct)).ShouldBe([BadInvalidArgument]);
// The prefix series is untouched: not replaced, not deleted.
var raw = (await B.CreateDataSource().ReadRawAsync(Prefix, At(-1), At(10), 0, Ct)).Samples;
@@ -23,10 +23,10 @@ public abstract class UpdateConformance : ConformanceBase
public async Task Insert_new_is_GoodEntryInserted_and_existing_is_BadEntryExists_in_order()
{
var u = B.CreateUpdater();
var r1 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], Ct);
var r1 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], ConformanceData.Editor, Ct);
r1.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted]);
// Mixed: T(2) exists, T(3) new, T(1) exists - out of timestamp order on purpose.
var r2 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(3), 3), V(T(2), 99), V(T(1), 98)], Ct);
var r2 = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(3), 3), V(T(2), 99), V(T(1), 98)], ConformanceData.Editor, Ct);
r2.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadEntryExists, HistorianStatusCodes.BadEntryExists]);
var rows = await B.ReadBackAsync("t");
rows.Select(x => (double?)x.V.Number).ShouldBe([1d, 2d, 3d]); // existing values untouched
@@ -37,9 +37,9 @@ public abstract class UpdateConformance : ConformanceBase
public async Task Replace_existing_is_GoodEntryReplaced_with_new_value_and_status_missing_is_BadNoEntryExists()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2)], ConformanceData.Editor, Ct);
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Replace,
[V(T(2), 22, UncertainStatus), V(T(5), 5), V(T(1), 11)], Ct);
[V(T(2), 22, UncertainStatus), V(T(5), 5), V(T(1), 11)], ConformanceData.Editor, Ct);
r.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.BadNoEntryExists, HistorianStatusCodes.GoodEntryReplaced]);
var rows = await B.ReadBackAsync("t");
rows.Count.ShouldBe(2); // T(5) was not created
@@ -53,8 +53,8 @@ public abstract class UpdateConformance : ConformanceBase
public async Task Update_upserts_inserted_or_replaced_per_item()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct);
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(2), 2), V(T(1), 10)], Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], ConformanceData.Editor, Ct);
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(2), 2), V(T(1), 10)], ConformanceData.Editor, Ct);
r.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryReplaced]);
(await B.ReadBackAsync("t")).Select(x => (double?)x.V.Number).ShouldBe([10d, 2d]);
}
@@ -63,15 +63,15 @@ public abstract class UpdateConformance : ConformanceBase
public async Task Unknown_tag_Insert_and_Update_create_it_but_Replace_and_deletes_do_not()
{
var u = B.CreateUpdater();
(await u.UpdateDataAsync("rep", HistoryUpdateMode.Replace, [V(T(1), 1)], Ct))
(await u.UpdateDataAsync("rep", HistoryUpdateMode.Replace, [V(T(1), 1)], ConformanceData.Editor, Ct))
.ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await u.DeleteRawAsync("del", T(0), T(9), Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
(await u.DeleteAtTimeAsync("del2", [T(1)], Ct)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await u.DeleteRawAsync("del", T(0), T(9), ConformanceData.Editor, Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
(await u.DeleteAtTimeAsync("del2", [T(1)], ConformanceData.Editor, Ct)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
foreach (var name in new[] { "rep", "del", "del2" })
(await B.TagExistsAsync(name)).ShouldBeFalse(name); // none of those created a tag
(await u.UpdateDataAsync("ins", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
(await u.UpdateDataAsync("upd", HistoryUpdateMode.Update, [V(T(1), 1)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
(await u.UpdateDataAsync("ins", HistoryUpdateMode.Insert, [V(T(1), 1)], ConformanceData.Editor, Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
(await u.UpdateDataAsync("upd", HistoryUpdateMode.Update, [V(T(1), 1)], ConformanceData.Editor, Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
(await B.TagExistsAsync("ins")).ShouldBeTrue();
(await B.TagExistsAsync("upd")).ShouldBeTrue();
}
@@ -81,14 +81,14 @@ public abstract class UpdateConformance : ConformanceBase
{
var u = B.CreateUpdater();
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert,
[V(null, 1), V(T(1), 2), V(DateTime.MinValue, 3)], Ct);
[V(null, 1), V(T(1), 2), V(DateTime.MinValue, 3)], ConformanceData.Editor, Ct);
r.ShouldBe([HistorianStatusCodes.BadInvalidTimestamp, HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadInvalidTimestamp]);
(await B.ReadBackAsync("t")).Count.ShouldBe(1);
// All-invalid request: no DB work, no tag created, still one code per item.
var u2 = B.CreateUpdater();
await u2.UpdateDataAsync("seed", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct); // bootstraps the schema
(await u2.UpdateDataAsync("x", HistoryUpdateMode.Insert, [V(null, 1)], Ct)).ShouldBe([HistorianStatusCodes.BadInvalidTimestamp]);
await u2.UpdateDataAsync("seed", HistoryUpdateMode.Insert, [V(T(1), 1)], ConformanceData.Editor, Ct); // bootstraps the schema
(await u2.UpdateDataAsync("x", HistoryUpdateMode.Insert, [V(null, 1)], ConformanceData.Editor, Ct)).ShouldBe([HistorianStatusCodes.BadInvalidTimestamp]);
(await B.TagExistsAsync("seed")).ShouldBeTrue();
(await B.TagExistsAsync("x")).ShouldBeFalse(); // "x" was never created
}
@@ -98,7 +98,7 @@ public abstract class UpdateConformance : ConformanceBase
{
var u = B.CreateUpdater();
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert,
[V(T(1), double.NaN), V(T(2), 2), V(T(3), double.PositiveInfinity), V(T(4), double.NegativeInfinity)], Ct);
[V(T(1), double.NaN), V(T(2), 2), V(T(3), double.PositiveInfinity), V(T(4), double.NegativeInfinity)], ConformanceData.Editor, Ct);
r.ShouldBe([HistorianStatusCodes.BadInvalidArgument, HistorianStatusCodes.GoodEntryInserted,
HistorianStatusCodes.BadInvalidArgument, HistorianStatusCodes.BadInvalidArgument]);
(await B.ReadBackAsync("t")).Select(x => (double?)x.V.Number).ShouldBe([2d]);
@@ -108,7 +108,7 @@ public abstract class UpdateConformance : ConformanceBase
public async Task Duplicate_timestamp_in_one_Insert_request_second_is_BadEntryExists_first_wins()
{
var u = B.CreateUpdater();
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(1), 99)], Ct);
var r = await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(1), 99)], ConformanceData.Editor, Ct);
r.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadEntryExists]);
(await B.ReadBackAsync("t")).Select(x => (double?)x.V.Number).ShouldBe([1d, 2d]);
}
@@ -117,12 +117,12 @@ public abstract class UpdateConformance : ConformanceBase
public async Task Duplicate_timestamp_in_one_Replace_or_Update_request_last_wins_and_all_share_the_code()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct);
var rep = await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T(1), 5), V(T(1), 6)], Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], ConformanceData.Editor, Ct);
var rep = await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T(1), 5), V(T(1), 6)], ConformanceData.Editor, Ct);
rep.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.GoodEntryReplaced]);
(await B.ReadBackAsync("t")).Single().V.Number.ShouldBe(6d);
var upd = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(7), 7), V(T(7), 8)], Ct);
var upd = await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(T(7), 7), V(T(7), 8)], ConformanceData.Editor, Ct);
upd.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted]);
(await B.ReadBackAsync("t")).Last().V.Number.ShouldBe(8d);
}
@@ -131,9 +131,9 @@ public abstract class UpdateConformance : ConformanceBase
public async Task Replace_and_Update_can_change_the_value_kind_and_clear_stale_columns()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [new HistorizationValue(T(1), HistorianValue.FromDouble(1.5), Good)], Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [new HistorizationValue(T(1), HistorianValue.FromDouble(1.5), Good)], ConformanceData.Editor, Ct);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Replace,
[new HistorizationValue(T(1), HistorianValue.FromString("hi"), Good)], Ct)).ShouldBe([HistorianStatusCodes.GoodEntryReplaced]);
[new HistorizationValue(T(1), HistorianValue.FromString("hi"), Good)], ConformanceData.Editor, Ct)).ShouldBe([HistorianStatusCodes.GoodEntryReplaced]);
var rows = await B.ReadBackAsync("t");
rows.Single().V.ShouldBe(HistorianValue.FromString("hi")); // Number cleared (0), not left at 1.5
}
@@ -142,13 +142,13 @@ public abstract class UpdateConformance : ConformanceBase
public async Task DeleteRaw_is_half_open_Good_when_rows_removed_BadNoData_when_none()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], ConformanceData.Editor, Ct);
// [T(1), T(3)) removes T(1) and T(2); T(3) (the end) survives.
(await u.DeleteRawAsync("t", T(1), T(3), Ct)).ShouldBe(Good);
(await u.DeleteRawAsync("t", T(1), T(3), ConformanceData.Editor, Ct)).ShouldBe(Good);
(await B.ReadBackAsync("t")).Select(x => x.T).ShouldBe([B.Round(T(3))]);
(await u.DeleteRawAsync("t", T(1), T(3), Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
(await u.DeleteRawAsync("t", T(1), T(3), ConformanceData.Editor, Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
// sub-ms boundary: a start one resolution unit after T(3) excludes it.
(await u.DeleteRawAsync("t", T(3).AddTicks(B.ResolutionTicks), T(3).AddTicks(100 * B.ResolutionTicks), Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
(await u.DeleteRawAsync("t", T(3).AddTicks(B.ResolutionTicks), T(3).AddTicks(100 * B.ResolutionTicks), ConformanceData.Editor, Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
(await B.ReadBackAsync("t")).Count.ShouldBe(1);
}
@@ -156,8 +156,8 @@ public abstract class UpdateConformance : ConformanceBase
public async Task DeleteAtTime_reports_per_time_in_order_with_exact_tick_matching()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], Ct);
var r = await u.DeleteAtTimeAsync("t", [T(3), T(2).AddTicks(B.ResolutionTicks) /* near miss */, T(1), DateTime.MinValue], Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1), V(T(2), 2), V(T(3), 3)], ConformanceData.Editor, Ct);
var r = await u.DeleteAtTimeAsync("t", [T(3), T(2).AddTicks(B.ResolutionTicks) /* near miss */, T(1), DateTime.MinValue], ConformanceData.Editor, Ct);
r.ShouldBe([Good, HistorianStatusCodes.BadNoEntryExists, Good, HistorianStatusCodes.BadInvalidTimestamp]);
(await B.ReadBackAsync("t")).Select(x => x.T).ShouldBe([B.Round(T(2))]);
}
@@ -166,8 +166,8 @@ public abstract class UpdateConformance : ConformanceBase
public async Task DeleteAtTime_duplicate_time_first_reports_Good_later_BadNoEntryExists()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], Ct);
(await u.DeleteAtTimeAsync("t", [T(1), T(1)], Ct)).ShouldBe([Good, HistorianStatusCodes.BadNoEntryExists]);
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T(1), 1)], ConformanceData.Editor, Ct);
(await u.DeleteAtTimeAsync("t", [T(1), T(1)], ConformanceData.Editor, Ct)).ShouldBe([Good, HistorianStatusCodes.BadNoEntryExists]);
(await B.ReadBackAsync("t")).ShouldBeEmpty();
}
}
@@ -52,7 +52,7 @@ public sealed class BootstrapTests(QuestDbFixture fx)
var p = fx.UniquePrefix();
var (c, boot, _) = Make(p);
await boot.EnsureAsync(default);
(await TablesAsync(c, p)).ShouldBe([p + "event", p + "sample", p + "tag"]);
(await TablesAsync(c, p)).ShouldBe([p + "event", p + "sample", p + "sample_modified", p + "tag"]);
}
[Fact]
@@ -69,7 +69,7 @@ public sealed class BootstrapTests(QuestDbFixture fx)
await Task.Delay(300); // an ALTER would have been applied by now
(await SequencerTxnAsync(c2, p + "sample")).ShouldBe(before);
(await SequencerTxnAsync(c2, p + "event")).ShouldBe(await SequencerTxnAsync(c, p + "event"));
(await TablesAsync(c2, p)).Count.ShouldBe(3);
(await TablesAsync(c2, p)).Count.ShouldBe(4);
}
[Fact]
@@ -84,7 +84,7 @@ public sealed class BootstrapTests(QuestDbFixture fx)
await Task.WhenAll(
Task.Run(() => a.Boot.EnsureAsync(default)),
Task.Run(() => b.Boot.EnsureAsync(default)));
(await TablesAsync(a.Conn, p)).Count.ShouldBe(3);
(await TablesAsync(a.Conn, p)).Count.ShouldBe(4);
}
}
@@ -97,6 +97,8 @@ public sealed class BootstrapTests(QuestDbFixture fx)
await b1.EnsureAsync(default);
QuestDbTables.TtlHours((await TtlAsync(c1, withTtl + "sample")).Value, (await TtlAsync(c1, withTtl + "sample")).Unit).ShouldBe(168);
QuestDbTables.TtlHours((await TtlAsync(c1, withTtl + "event")).Value, (await TtlAsync(c1, withTtl + "event")).Unit).ShouldBe(168);
// #668: the modification trail ages out with the samples it describes.
QuestDbTables.TtlHours((await TtlAsync(c1, withTtl + "sample_modified")).Value, (await TtlAsync(c1, withTtl + "sample_modified")).Unit).ShouldBe(168);
(await TtlAsync(c1, withTtl + "tag")).Value.ShouldBe(0); // the tag catalog never expires
var noTtl = fx.UniquePrefix();
@@ -53,3 +53,10 @@ public sealed class QuestDbTagNameConformance(QuestDbFixture fx) : TagNameConfor
/// <inheritdoc/>
protected override Task<IConformanceBackend> CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
}
[Collection(QuestDbCollection.Name)]
public sealed class QuestDbModifiedValueConformance(QuestDbFixture fx) : ModifiedValueConformance
{
/// <inheritdoc/>
protected override Task<IConformanceBackend> CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
}
@@ -26,7 +26,7 @@ public sealed partial class TombstoneTests
];
(await writer.WriteLiveValuesAsync("r", batch, default)).ShouldBeTrue();
(await rig.Updater.DeleteAtTimeAsync("r", [T0], default)).ShouldBe([HistorianStatusCodes.Good]);
(await rig.Updater.DeleteAtTimeAsync("r", [T0], UpdateTests.Who, default)).ShouldBe([HistorianStatusCodes.Good]);
(await writer.WriteLiveValuesAsync("r", batch, default)).ShouldBeTrue(); // the redelivery
(await UpdateTests.RowsAsync(rig, "r", liveOnly: true)).Select(r => r.Num).ShouldBe([2d]);
@@ -17,8 +17,8 @@ public sealed partial class TombstoneTests(QuestDbFixture fx)
fx.SkipIfUnavailable();
var rig = UpdateTests.MakeRig(fx);
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert,
[UpdateTests.V(T0, 1), UpdateTests.V(T0.AddSeconds(1), 2)], default);
(await rig.Updater.DeleteAtTimeAsync("t", [T0], default)).ShouldBe([HistorianStatusCodes.Good]);
[UpdateTests.V(T0, 1), UpdateTests.V(T0.AddSeconds(1), 2)], UpdateTests.Who, default);
(await rig.Updater.DeleteAtTimeAsync("t", [T0], UpdateTests.Who, default)).ShouldBe([HistorianStatusCodes.Good]);
(await UpdateTests.RowsAsync(rig, "t", liveOnly: true)).Select(r => r.Num).ShouldBe([2d]);
var all = await UpdateTests.RowsAsync(rig, "t");
@@ -35,12 +35,12 @@ public sealed partial class TombstoneTests(QuestDbFixture fx)
{
fx.SkipIfUnavailable();
var rig = UpdateTests.MakeRig(fx);
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [UpdateTests.V(T0, 1)], default);
await rig.Updater.DeleteAtTimeAsync("t", [T0], default);
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [UpdateTests.V(T0, 1)], UpdateTests.Who, default);
await rig.Updater.DeleteAtTimeAsync("t", [T0], UpdateTests.Who, default);
(await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [UpdateTests.V(T0, 5)], default))
(await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [UpdateTests.V(T0, 5)], UpdateTests.Who, default))
.ShouldBe([HistorianStatusCodes.BadNoEntryExists]); // a tombstone is no entry for Replace
(await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [UpdateTests.V(T0, 9)], default))
(await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [UpdateTests.V(T0, 9)], UpdateTests.Who, default))
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
var live = await UpdateTests.RowsAsync(rig, "t", liveOnly: true);
live.Count.ShouldBe(1);
@@ -24,6 +24,9 @@ public sealed class UpdateTests(QuestDbFixture fx)
return new Rig(c, new QuestDbHistorianDataUpdater(c, tags, wal), tags);
}
/// <summary>The HistoryUpdate caller these tests pass to the updater (recorded in the modification trail).</summary>
internal static readonly HistoryUpdateContext Who = new("questdb-test", null);
internal static HistorizationValue V(DateTime? t, double v, uint status = Good) =>
new(t, HistorianValue.FromDouble(v), status);
@@ -57,10 +60,10 @@ public sealed class UpdateTests(QuestDbFixture fx)
{
fx.SkipIfUnavailable();
var rig = MakeRig(fx);
(await rig.Updater.UpdateDataAsync("t1", HistoryUpdateMode.Insert, [V(T0, 1)], default))
(await rig.Updater.UpdateDataAsync("t1", HistoryUpdateMode.Insert, [V(T0, 1)], UpdateTests.Who, default))
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
var codes = await rig.Updater.UpdateDataAsync("t1", HistoryUpdateMode.Insert,
[V(T0, 99), V(T0.AddSeconds(1), 2)], default);
[V(T0, 99), V(T0.AddSeconds(1), 2)], UpdateTests.Who, default);
codes.ShouldBe([HistorianStatusCodes.BadEntryExists, HistorianStatusCodes.GoodEntryInserted]);
var rows = await RowsAsync(rig, "t1"); // read right after return: read-your-writes
rows.Select(r => r.Num).ShouldBe([1d, 2d]);
@@ -72,14 +75,14 @@ public sealed class UpdateTests(QuestDbFixture fx)
{
fx.SkipIfUnavailable();
var rig = MakeRig(fx);
(await rig.Updater.UpdateDataAsync("nope", HistoryUpdateMode.Replace, [V(T0, 1)], default))
(await rig.Updater.UpdateDataAsync("nope", HistoryUpdateMode.Replace, [V(T0, 1)], UpdateTests.Who, default))
.ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await TagRowsAsync(rig, "nope")).ShouldBe(0);
(await RowsAsync(rig, "nope")).ShouldBeEmpty();
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1)], default);
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1)], UpdateTests.Who, default);
var codes = await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace,
[V(T0, 5), V(T0.AddSeconds(1), 6)], default);
[V(T0, 5), V(T0.AddSeconds(1), 6)], UpdateTests.Who, default);
codes.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.BadNoEntryExists]);
(await RowsAsync(rig, "t")).Select(r => r.Num).ShouldBe([5d]);
}
@@ -89,9 +92,9 @@ public sealed class UpdateTests(QuestDbFixture fx)
{
fx.SkipIfUnavailable();
var rig = MakeRig(fx);
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1)], default);
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1)], UpdateTests.Who, default);
var codes = await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Update,
[V(T0, 2), V(T0.AddSeconds(1), 3), V(T0.AddSeconds(1).AddTicks(3), 4)], default);
[V(T0, 2), V(T0.AddSeconds(1), 3), V(T0.AddSeconds(1).AddTicks(3), 4)], UpdateTests.Who, default);
codes.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.GoodEntryInserted,
HistorianStatusCodes.GoodEntryInserted]);
(await RowsAsync(rig, "t")).Select(r => r.Num).ShouldBe([2d, 4d]);
@@ -104,7 +107,7 @@ public sealed class UpdateTests(QuestDbFixture fx)
var rig = MakeRig(fx);
// offsets 0 and 3 ticks sit inside one 10-tick window => the same stored µs
var codes = await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert,
[V(T0, 1), V(T0.AddTicks(3), 2)], default);
[V(T0, 1), V(T0.AddTicks(3), 2)], UpdateTests.Who, default);
codes.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadEntryExists]);
(await RowsAsync(rig, "t")).Select(r => r.Num).ShouldBe([1d]);
}
@@ -119,7 +122,7 @@ public sealed class UpdateTests(QuestDbFixture fx)
V(null, 1), V(DateTime.MinValue, 1), V(T0, double.NaN),
new HistorizationValue(T0.AddSeconds(1), HistorianValue.Empty, Good),
V(T0.AddSeconds(2), 7),
], default);
], UpdateTests.Who, default);
codes.ShouldBe([HistorianStatusCodes.BadInvalidTimestamp, HistorianStatusCodes.BadInvalidTimestamp,
HistorianStatusCodes.BadInvalidArgument, HistorianStatusCodes.BadInvalidArgument,
HistorianStatusCodes.GoodEntryInserted]);
@@ -132,10 +135,10 @@ public sealed class UpdateTests(QuestDbFixture fx)
fx.SkipIfUnavailable();
var rig = MakeRig(fx);
var tag = new string('x', HistorianTagNames.MaxLength + 1);
(await rig.Updater.UpdateDataAsync(tag, HistoryUpdateMode.Insert, [V(T0, 1)], default))
(await rig.Updater.UpdateDataAsync(tag, HistoryUpdateMode.Insert, [V(T0, 1)], UpdateTests.Who, default))
.ShouldBe([HistorianStatusCodes.BadInvalidArgument]);
(await rig.Updater.DeleteAtTimeAsync(tag, [T0], default)).ShouldBe([HistorianStatusCodes.BadInvalidArgument]);
(await rig.Updater.DeleteRawAsync(tag, T0, T0.AddHours(1), default)).ShouldBe(HistorianStatusCodes.BadInvalidArgument);
(await rig.Updater.DeleteAtTimeAsync(tag, [T0], UpdateTests.Who, default)).ShouldBe([HistorianStatusCodes.BadInvalidArgument]);
(await rig.Updater.DeleteRawAsync(tag, T0, T0.AddHours(1), UpdateTests.Who, default)).ShouldBe(HistorianStatusCodes.BadInvalidArgument);
}
[Fact]
@@ -146,15 +149,15 @@ public sealed class UpdateTests(QuestDbFixture fx)
var pre = new DateTime(1969, 12, 31, 23, 59, 59, DateTimeKind.Utc);
foreach (var mode in new[] { HistoryUpdateMode.Insert, HistoryUpdateMode.Replace, HistoryUpdateMode.Update })
{
var codes = await rig.Updater.UpdateDataAsync("t", mode, [V(pre, 1), V(DateTime.MinValue, 1), V(T0, 2)], default);
var codes = await rig.Updater.UpdateDataAsync("t", mode, [V(pre, 1), V(DateTime.MinValue, 1), V(T0, 2)], UpdateTests.Who, default);
codes[0].ShouldBe(HistorianStatusCodes.BadOutOfRange);
codes[1].ShouldBe(HistorianStatusCodes.BadInvalidTimestamp);
}
// Pre-1970 only: the whole batch is refused, no tag row is created, no INSERT would succeed anyway.
(await rig.Updater.UpdateDataAsync("only-pre", HistoryUpdateMode.Update, [V(pre, 1)], default))
(await rig.Updater.UpdateDataAsync("only-pre", HistoryUpdateMode.Update, [V(pre, 1)], UpdateTests.Who, default))
.ShouldBe([HistorianStatusCodes.BadOutOfRange]);
(await TagRowsAsync(rig, "only-pre")).ShouldBe(0);
(await rig.Updater.DeleteAtTimeAsync("t", [pre], default)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await rig.Updater.DeleteAtTimeAsync("t", [pre], UpdateTests.Who, default)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
}
[Fact]
@@ -162,15 +165,15 @@ public sealed class UpdateTests(QuestDbFixture fx)
{
fx.SkipIfUnavailable();
var rig = MakeRig(fx);
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1), V(T0.AddSeconds(1), 2)], default);
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1), V(T0.AddSeconds(1), 2)], UpdateTests.Who, default);
var codes = await rig.Updater.DeleteAtTimeAsync("t",
[T0, T0.AddSeconds(5), T0, DateTime.MinValue], default);
[T0, T0.AddSeconds(5), T0, DateTime.MinValue], UpdateTests.Who, default);
codes.ShouldBe([Good, HistorianStatusCodes.BadNoEntryExists, HistorianStatusCodes.BadNoEntryExists,
HistorianStatusCodes.BadInvalidTimestamp]);
(await RowsAsync(rig, "t", liveOnly: true)).Select(r => r.Num).ShouldBe([2d]);
// a tombstone is not a live row: deleting it again finds nothing
(await rig.Updater.DeleteAtTimeAsync("t", [T0], default)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await rig.Updater.DeleteAtTimeAsync("unknown", [T0], default)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await rig.Updater.DeleteAtTimeAsync("t", [T0], UpdateTests.Who, default)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await rig.Updater.DeleteAtTimeAsync("unknown", [T0], UpdateTests.Who, default)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
}
[Fact]
@@ -178,9 +181,9 @@ public sealed class UpdateTests(QuestDbFixture fx)
{
fx.SkipIfUnavailable();
var rig = MakeRig(fx);
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1)], default);
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1)], UpdateTests.Who, default);
// +7 ticks is inside the same µs; the row lives at T0.
(await rig.Updater.DeleteAtTimeAsync("t", [T0.AddTicks(7)], default)).ShouldBe([Good]);
(await rig.Updater.DeleteAtTimeAsync("t", [T0.AddTicks(7)], UpdateTests.Who, default)).ShouldBe([Good]);
(await RowsAsync(rig, "t", liveOnly: true)).ShouldBeEmpty();
}
@@ -189,18 +192,18 @@ public sealed class UpdateTests(QuestDbFixture fx)
{
fx.SkipIfUnavailable();
var rig = MakeRig(fx);
(await rig.Updater.DeleteRawAsync("unknown", T0, T0.AddHours(1), default)).ShouldBe(HistorianStatusCodes.BadNoData);
(await rig.Updater.DeleteRawAsync("unknown", T0, T0.AddHours(1), UpdateTests.Who, default)).ShouldBe(HistorianStatusCodes.BadNoData);
// 2,500 rows => 3 pages of at most 1,000; plus one row exactly at the (exclusive) end.
var values = Enumerable.Range(0, 2500).Select(i => V(T0.AddSeconds(i), i)).ToList();
values.Add(V(T0.AddSeconds(2500), -1));
(await rig.Updater.UpdateDataAsync("big", HistoryUpdateMode.Insert, values, default))
(await rig.Updater.UpdateDataAsync("big", HistoryUpdateMode.Insert, values, UpdateTests.Who, default))
.ShouldAllBe(c => c == HistorianStatusCodes.GoodEntryInserted);
(await rig.Updater.DeleteRawAsync("big", T0, T0.AddSeconds(2500), default)).ShouldBe(Good);
(await rig.Updater.DeleteRawAsync("big", T0, T0.AddSeconds(2500), UpdateTests.Who, default)).ShouldBe(Good);
var live = await RowsAsync(rig, "big", liveOnly: true);
live.Count.ShouldBe(1);
live[0].Num.ShouldBe(-1d);
(await rig.Updater.DeleteRawAsync("big", T0, T0.AddSeconds(2500), default)).ShouldBe(HistorianStatusCodes.BadNoData);
(await rig.Updater.DeleteRawAsync("big", T0, T0.AddSeconds(2500), UpdateTests.Who, default)).ShouldBe(HistorianStatusCodes.BadNoData);
}
[Fact]
@@ -208,12 +211,12 @@ public sealed class UpdateTests(QuestDbFixture fx)
{
fx.SkipIfUnavailable();
var rig = MakeRig(fx);
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1), V(T0.AddSeconds(1), 2)], default);
(await rig.Updater.DeleteRawAsync("t", new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc), T0.AddSeconds(1), default))
await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T0, 1), V(T0.AddSeconds(1), 2)], UpdateTests.Who, default);
(await rig.Updater.DeleteRawAsync("t", new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc), T0.AddSeconds(1), UpdateTests.Who, default))
.ShouldBe(Good);
(await RowsAsync(rig, "t", liveOnly: true)).Select(r => r.Num).ShouldBe([2d]);
// DateTime.MinValue as the start is nudged inside, not bound as -infinity
(await rig.Updater.DeleteRawAsync("t", DateTime.MinValue, DateTime.MaxValue, default)).ShouldBe(Good);
(await rig.Updater.DeleteRawAsync("t", DateTime.MinValue, DateTime.MaxValue, UpdateTests.Who, default)).ShouldBe(Good);
(await RowsAsync(rig, "t", liveOnly: true)).ShouldBeEmpty();
}
@@ -226,9 +229,9 @@ public sealed class UpdateTests(QuestDbFixture fx)
for (var i = 0; i < 40; i++)
{
var t = T0.AddSeconds(i);
(await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(t, i)], default))
(await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(t, i)], UpdateTests.Who, default))
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
(await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(t, i + 1000)], default))
(await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(t, i + 1000)], UpdateTests.Who, default))
.ShouldBe([HistorianStatusCodes.GoodEntryReplaced], $"iteration {i}: the insert was not visible");
}
}
@@ -242,8 +245,8 @@ public sealed class UpdateTests(QuestDbFixture fx)
{
var t = T0.AddSeconds(i);
var codes = await Task.WhenAll(
Task.Run(() => rig.Updater.UpdateDataAsync("race", HistoryUpdateMode.Insert, [V(t, 1)], default)),
Task.Run(() => rig.Updater.UpdateDataAsync("race", HistoryUpdateMode.Insert, [V(t, 2)], default)));
Task.Run(() => rig.Updater.UpdateDataAsync("race", HistoryUpdateMode.Insert, [V(t, 1)], UpdateTests.Who, default)),
Task.Run(() => rig.Updater.UpdateDataAsync("race", HistoryUpdateMode.Insert, [V(t, 2)], UpdateTests.Who, default)));
codes.Select(c => c[0]).OrderBy(c => c).ToArray().ShouldBe(
new[] { HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.BadEntryExists }, $"iteration {i}");
}
@@ -256,10 +259,10 @@ public sealed class UpdateTests(QuestDbFixture fx)
var rig = MakeRig(fx);
// 1 Hz seed across 2 h = 7,200 live rows; the request touches 3 instants 1 h apart.
var seed = Enumerable.Range(0, 7200).Select(i => V(T0.AddSeconds(i), i)).ToList();
await rig.Updater.UpdateDataAsync("sparse", HistoryUpdateMode.Insert, seed, default);
await rig.Updater.UpdateDataAsync("sparse", HistoryUpdateMode.Insert, seed, UpdateTests.Who, default);
var before = rig.Updater.LiveRowsRead;
var codes = await rig.Updater.UpdateDataAsync("sparse", HistoryUpdateMode.Replace,
[V(T0, 1), V(T0.AddHours(1), 2), V(T0.AddHours(2).AddSeconds(-1), 3), V(T0.AddHours(1).AddMilliseconds(500), 4)], default);
[V(T0, 1), V(T0.AddHours(1), 2), V(T0.AddHours(2).AddSeconds(-1), 3), V(T0.AddHours(1).AddMilliseconds(500), 4)], UpdateTests.Who, default);
codes.ShouldBe([HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.GoodEntryReplaced,
HistorianStatusCodes.GoodEntryReplaced, HistorianStatusCodes.BadNoEntryExists]);
// 3 clusters of a single instant each (T0+1h and +1h.5s are one cluster of 2 instants, 1 live row)
@@ -39,10 +39,22 @@ public sealed class QuestDbTablesTests
[Fact]
public void The_prefix_is_applied_to_every_table_name()
{
QuestDbTables.All("t1_").ShouldBe(["t1_sample", "t1_event", "t1_tag"]);
QuestDbTables.All("t1_").ShouldBe(["t1_sample", "t1_event", "t1_tag", "t1_sample_modified"]);
QuestDbTables.Expiring("t1_").ShouldBe(["t1_sample", "t1_event", "t1_sample_modified"]);
QuestDbTables.CreateAll("t1_", 3).ShouldAllBe(s => s.Contains("t1_"));
}
[Fact]
public void Sample_modified_ddl_is_pinned_append_only_and_carries_the_ttl()
{
QuestDbTables.CreateSampleModified("hist_", null).ShouldBe(
"CREATE TABLE IF NOT EXISTS hist_sample_modified (ts TIMESTAMP, tag SYMBOL, modified_id LONG, " +
"modification_ts TIMESTAMP, update_type BYTE, principal VARCHAR, api_key_id VARCHAR, kind BYTE, num_value DOUBLE, " +
"int_value LONG, str_value VARCHAR, status LONG, server_ts TIMESTAMP) TIMESTAMP(ts) PARTITION BY DAY WAL");
QuestDbTables.CreateSampleModified("hist_", 30).ShouldEndWith("PARTITION BY DAY TTL 30 DAYS WAL");
QuestDbTables.CreateSampleModified("hist_", null).ShouldNotContain("DEDUP");
}
[Fact]
public void An_invalid_prefix_is_refused() =>
Should.Throw<ArgumentException>(() => QuestDbTables.CreateSample("a b", null));
@@ -0,0 +1,112 @@
using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
/// <summary>
/// #668, SQL Server only: the modification-trail row is written by the SAME statement that changes
/// <c>hist.Sample</c> (<c>OUTPUT … INTO</c>), so a change and its trail row commit or roll back together. The failure
/// is forced INSIDE the audit insert (a unique index on the trail that the second modification of one timestamp
/// violates), i.e. after the sample row was already changed within the statement — the strongest place to prove it.
/// Each test has a positive control: the same operation succeeds, with its trail row, once the index is dropped.
/// </summary>
[Collection(SqlHistorianCollection.Name)]
public sealed class ModifiedHistoryAtomicityTests(SqlHistorianFixture fx)
{
private static readonly DateTime T1 = new(2026, 1, 1, 0, 0, 1, DateTimeKind.Utc);
private static readonly DateTime T2 = T1.AddSeconds(1);
private static readonly HistoryUpdateContext Who = ConformanceData.Editor;
private static HistorizationValue V(DateTime t, double d) => new(t, HistorianValue.FromDouble(d), 0u);
private async Task ExecAsync(ReadTestDb db, string sql)
{
await using var c = await db.Conn.OpenAsync(CancellationToken.None);
await using var cmd = db.Conn.CreateCommand(c, sql);
await cmd.ExecuteNonQueryAsync();
}
private async Task<long> CountTrailAsync(ReadTestDb db)
{
await using var c = await db.Conn.OpenAsync(CancellationToken.None);
await using var cmd = db.Conn.CreateCommand(c, $"SELECT COUNT(*) FROM {db.Conn.Q("SampleModified")}");
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
}
private Task BreakTheTrailAsync(ReadTestDb db) => ExecAsync(db,
$"CREATE UNIQUE INDEX UX_Break ON {db.Conn.Q("SampleModified")} (TagId, SourceTimeUtc)");
private Task RepairTheTrailAsync(ReadTestDb db) => ExecAsync(db,
$"DROP INDEX UX_Break ON {db.Conn.Q("SampleModified")}");
private static async Task<double> ValueAtAsync(ReadTestDb db, string tag, DateTime t)
{
using var src = db.Source();
var r = await src.ReadRawAsync(tag, t, t, 0, CancellationToken.None);
return (double)r.Samples.ShouldHaveSingleItem().Value!;
}
[Fact]
public async Task A_replace_whose_trail_insert_fails_changes_nothing()
{
var db = await ReadTestDb.CreateAsync(fx);
var u = new SqlServerHistorianDataUpdater(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot));
(await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1)], Who, CancellationToken.None))
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
(await CountTrailAsync(db)).ShouldBe(1);
await BreakTheTrailAsync(db);
await Should.ThrowAsync<SqlException>(
() => u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T1, 99)], Who, CancellationToken.None));
(await ValueAtAsync(db, "t", T1)).ShouldBe(1d, "the sample change rolled back with its trail row");
(await CountTrailAsync(db)).ShouldBe(1, "a failed update leaves no trail row");
// Positive control: the identical replace succeeds, with its trail row, once the trail accepts it.
await RepairTheTrailAsync(db);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T1, 99)], Who, CancellationToken.None))
.ShouldBe([HistorianStatusCodes.GoodEntryReplaced]);
(await ValueAtAsync(db, "t", T1)).ShouldBe(99d);
(await CountTrailAsync(db)).ShouldBe(2);
}
[Fact]
public async Task Deletes_whose_trail_insert_fails_delete_nothing()
{
var db = await ReadTestDb.CreateAsync(fx);
var u = new SqlServerHistorianDataUpdater(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot));
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1), V(T2, 2)], Who, CancellationToken.None);
await BreakTheTrailAsync(db);
await Should.ThrowAsync<SqlException>(() => u.DeleteAtTimeAsync("t", [T1], Who, CancellationToken.None));
await Should.ThrowAsync<SqlException>(() => u.DeleteRawAsync("t", T1, T2.AddSeconds(1), Who, CancellationToken.None));
(await ValueAtAsync(db, "t", T1)).ShouldBe(1d);
(await ValueAtAsync(db, "t", T2)).ShouldBe(2d);
(await CountTrailAsync(db)).ShouldBe(2, "only the two inserts");
await RepairTheTrailAsync(db);
(await u.DeleteAtTimeAsync("t", [T1], Who, CancellationToken.None)).ShouldBe([HistorianStatusCodes.Good]);
(await u.DeleteRawAsync("t", T1, T2.AddSeconds(1), Who, CancellationToken.None)).ShouldBe(HistorianStatusCodes.Good);
(await CountTrailAsync(db)).ShouldBe(4);
}
[Fact]
public async Task An_over_long_principal_is_truncated_never_refused()
{
var db = await ReadTestDb.CreateAsync(fx);
var u = new SqlServerHistorianDataUpdater(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot));
var who = new HistoryUpdateContext(new string('p', 500), new string('k', 500));
(await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1)], who, CancellationToken.None))
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
using var src = db.Source();
var trail = await src.ReadModifiedAsync("t", T1.AddHours(-1), T1.AddHours(1), 0, null, CancellationToken.None);
trail.Values.ShouldHaveSingleItem().UserName.ShouldBe(new string('p', HistoryUpdateContext.MaxIdentityLength));
}
}
@@ -30,7 +30,7 @@ public sealed class RawReadTests(SqlHistorianFixture fx)
var h = src.GetHealthSnapshot();
h.TotalFailures.ShouldBe(2);
h.ConsecutiveFailures.ShouldBe(2);
h.LastError!.ShouldContain("missing version 1");
h.LastError!.ShouldContain($"missing version {SqlServerSchemaBootstrapper.RequiredVersion}");
h.ProcessConnectionOpen.ShouldBeFalse();
// Another node creates the schema; the same data source then succeeds (bootstrap failure is not latched).
@@ -56,6 +56,9 @@ public sealed class RetentionTests(SqlHistorianFixture fx)
sql.Append($"SET @i = 0; WHILE @i < 2 BEGIN INSERT [{s}].Sample (TagId, SourceTimeUtc, ValueKind, NumValue, StatusCode, ServerTimeUtc) SELECT TagId, DATEADD(minute, @i, '{fresh}'), 1, 1.0, 0, SYSUTCDATETIME() FROM [{s}].Tag; SET @i += 1; END;");
sql.Append($"SET @i = 0; WHILE @i < 4 BEGIN INSERT [{s}].Event (EventId, SourceName, AlarmId, AlarmName, AlarmTypeName, Severity, EventKind, EventTimeUtc, ReceivedTimeUtc) VALUES (CONCAT('old', @i), 's', 'a', 'n', 't', 1, 'Activated', DATEADD(minute, @i, '{old}'), SYSUTCDATETIME()); SET @i += 1; END;");
sql.Append($"INSERT [{s}].Event (EventId, SourceName, AlarmId, AlarmName, AlarmTypeName, Severity, EventKind, EventTimeUtc, ReceivedTimeUtc) VALUES ('new0', 's', 'a', 'n', 't', 1, 'Activated', '{fresh}', SYSUTCDATETIME());");
// #668 modification trail: 2 old + 1 fresh row per tag (by SOURCE time — the modification time is "now" on all).
sql.Append($"SET @i = 0; WHILE @i < 2 BEGIN INSERT [{s}].SampleModified (TagId, SourceTimeUtc, ModificationTimeUtc, UpdateType, Principal, ValueKind, NumValue, StatusCode, ServerTimeUtc) SELECT TagId, DATEADD(minute, @i, '{old}'), SYSUTCDATETIME(), 2, N'p', 1, 1.0, 0, SYSUTCDATETIME() FROM [{s}].Tag; SET @i += 1; END;");
sql.Append($"INSERT [{s}].SampleModified (TagId, SourceTimeUtc, ModificationTimeUtc, UpdateType, Principal) SELECT TagId, '{fresh}', SYSUTCDATETIME(), 1, N'p' FROM [{s}].Tag;");
await ExecAsync(sql.ToString());
}
@@ -67,11 +70,22 @@ public sealed class RetentionTests(SqlHistorianFixture fx)
fx.SkipIfUnavailable();
var (c, b, _, s) = Make();
await SeedAsync(b, s);
var identBefore = await CountAsync($"SELECT IDENT_CURRENT('[{s}].SampleModified')");
var purger = new SqlServerHistorianRetention(c, b, TimeSpan.FromHours(1), 30, batch, time: new FixedTime(Now));
var results = await purger.PurgeAsync(CancellationToken.None);
results.ShouldBe([new RetentionPurgeResult("Sample", 15), new RetentionPurgeResult("Event", 4)]);
// #668: a purge never WRITES to the modification trail. The identity seed proves no row was ever inserted —
// a row-count check could not, since the trail purge would delete an old-source-time audit row again.
(await CountAsync($"SELECT IDENT_CURRENT('[{s}].SampleModified')")).ShouldBe(identBefore);
results.ShouldBe([
new RetentionPurgeResult("Sample", 15),
new RetentionPurgeResult("SampleModified", 6),
new RetentionPurgeResult("Event", 4),
]);
(await CountAsync($"SELECT COUNT(*) FROM [{s}].SampleModified")).ShouldBe(3);
(await CountAsync($"SELECT COUNT(*) FROM [{s}].SampleModified WHERE SourceTimeUtc < '{Now.AddDays(-30):yyyy-MM-ddTHH:mm:ss}'")).ShouldBe(0);
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Sample")).ShouldBe(6);
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Sample WHERE SourceTimeUtc < '{Now.AddDays(-30):yyyy-MM-ddTHH:mm:ss}'")).ShouldBe(0);
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Event")).ShouldBe(1);
@@ -87,7 +101,11 @@ public sealed class RetentionTests(SqlHistorianFixture fx)
var purger = new SqlServerHistorianRetention(c, b, TimeSpan.FromHours(1), 30, 4000, time: new FixedTime(Now));
await purger.PurgeAsync(CancellationToken.None);
var again = await purger.PurgeAsync(CancellationToken.None);
again.ShouldBe([new RetentionPurgeResult("Sample", 0), new RetentionPurgeResult("Event", 0)]);
again.ShouldBe([
new RetentionPurgeResult("Sample", 0),
new RetentionPurgeResult("SampleModified", 0),
new RetentionPurgeResult("Event", 0),
]);
}
[Fact]
@@ -32,7 +32,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
var s = SqlHistorianFixture.UniqueSchema();
await Boot(Opts(s)).EnsureAsync(CancellationToken.None);
await Boot(Opts(s)).EnsureAsync(CancellationToken.None); // fresh instance => re-runs the guarded path
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(1);
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.RequiredVersion);
(await ScalarAsync($"SELECT COUNT(*) FROM sys.indexes WHERE name='CX_Event_Time' AND object_id=OBJECT_ID('[{s}].Event')")).ShouldBe(1);
}
@@ -51,7 +51,47 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
cmd.CommandText = b;
await cmd.ExecuteNonQueryAsync();
}
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(1);
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.RequiredVersion);
}
/// <summary>
/// #668 upgrade path: a schema left at version 1 (only <c>001_initial.sql</c> applied, as every pre-#668 install is)
/// is upgraded in place by an auto-creating bootstrap — <c>SampleModified</c> appears, both version rows exist, and
/// existing samples are untouched. With <c>AutoCreateSchema=false</c> the same v1 schema is refused.
/// </summary>
[Fact]
public async Task A_version_1_schema_is_upgraded_to_version_2_in_place()
{
fx.SkipIfUnavailable();
var s = SqlHistorianFixture.UniqueSchema();
var v1 = new StreamReader(typeof(SqlServerSchemaBootstrapper).Assembly.GetManifestResourceStream("Schema.001_initial.sql")!).ReadToEnd();
await using (var c = new SqlConnection(fx.ConnectionString))
{
await c.OpenAsync();
foreach (var b in SqlServerSchemaBootstrapper.SplitBatches(v1.Replace("$(Schema)", s)))
{
await using var cmd = c.CreateCommand();
cmd.CommandText = b;
await cmd.ExecuteNonQueryAsync();
}
await using var seed = c.CreateCommand();
seed.CommandText = $"INSERT [{s}].Tag (Name, DataType, CreatedUtc, UpdatedUtc) VALUES (N'old', 1, SYSUTCDATETIME(), SYSUTCDATETIME()); " +
$"INSERT [{s}].Sample (TagId, SourceTimeUtc, ValueKind, NumValue, StatusCode, ServerTimeUtc) SELECT TagId, '2026-01-01', 1, 4.0, 0, SYSUTCDATETIME() FROM [{s}].Tag";
await seed.ExecuteNonQueryAsync();
}
(await ScalarAsync($"SELECT COUNT(*) FROM sys.tables WHERE name = 'SampleModified' AND schema_id = SCHEMA_ID('{s}')")).ShouldBe(0);
var refused = await Should.ThrowAsync<HistorianSchemaMissingException>(
() => Boot(Opts(s, auto: false)).EnsureAsync(CancellationToken.None));
refused.Message.ShouldContain("missing version 2 (found 1)");
await Boot(Opts(s)).EnsureAsync(CancellationToken.None);
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(2);
(await ScalarAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(2);
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SampleModified")).ShouldBe(0);
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].Sample")).ShouldBe(1);
await Boot(Opts(s, auto: false)).EnsureAsync(CancellationToken.None); // now satisfies the verify-only path
}
[Fact]
@@ -61,7 +101,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
var s = SqlHistorianFixture.UniqueSchema();
var tasks = Enumerable.Range(0, 4).Select(_ => Task.Run(() => Boot(Opts(s)).EnsureAsync(CancellationToken.None))).ToArray();
await Task.WhenAll(tasks);
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(1);
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.RequiredVersion);
}
[Fact]
@@ -70,7 +110,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
fx.SkipIfUnavailable();
var ex = await Should.ThrowAsync<HistorianSchemaMissingException>(
() => Boot(Opts(SqlHistorianFixture.UniqueSchema(), auto: false)).EnsureAsync(CancellationToken.None));
ex.Message.ShouldContain("missing version 1");
ex.Message.ShouldContain("missing version 2");
}
[Fact]
@@ -53,3 +53,10 @@ public sealed class SqlServerTagNameConformance(SqlHistorianFixture fx) : TagNam
/// <inheritdoc/>
protected override Task<IConformanceBackend> CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
}
[Collection(SqlHistorianCollection.Name)]
public sealed class SqlServerModifiedValueConformance(SqlHistorianFixture fx) : ModifiedValueConformance
{
/// <inheritdoc/>
protected override Task<IConformanceBackend> CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
}
@@ -29,4 +29,30 @@ public sealed class SchemaScriptTests
dir.ShouldNotBeNull();
File.ReadAllText(Path.Combine(dir!.FullName, "docs/historian/sqlserver/001_initial.sql")).ShouldBe(text);
}
/// <summary>
/// #668: schema v2 (the Part 11 modification trail) is embedded, matches its docs copy, records version 2 (which
/// must equal <see cref="SqlServerSchemaBootstrapper.RequiredVersion"/>), and carries none of the constructs an
/// <c>OUTPUT … INTO</c> target forbids (FOREIGN KEY / CHECK / trigger) — any of them would fail every HistoryUpdate.
/// </summary>
[Fact]
public void Schema_v2_sample_modified_is_embedded_matches_the_docs_copy_and_is_a_legal_output_into_target()
{
var asm = typeof(SqlServerSchemaBootstrapper).Assembly;
using var s = asm.GetManifestResourceStream("Schema.002_sample_modified.sql");
s.ShouldNotBeNull();
var text = new StreamReader(s!).ReadToEnd();
text.ShouldContain("[SampleModified]");
text.ShouldContain("PRIMARY KEY CLUSTERED (TagId, SourceTimeUtc, ModifiedId)");
text.ShouldContain("VALUES (2, SYSUTCDATETIME())");
SqlServerSchemaBootstrapper.RequiredVersion.ShouldBe(2);
var ddl = string.Join('\n', text.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
ddl.ShouldNotContain("FOREIGN KEY", Case.Insensitive);
ddl.ShouldNotContain("CHECK", Case.Insensitive);
ddl.ShouldNotContain("TRIGGER", Case.Insensitive);
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) dir = dir.Parent;
File.ReadAllText(Path.Combine(dir!.FullName, "docs/historian/sqlserver/002_sample_modified.sql")).ShouldBe(text);
}
}
@@ -72,6 +72,50 @@ public sealed class HistoryUpdateOverTheWireTests
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// Part 11 modified-value history over the wire (#668): two inserts and a delete by the key principal, then a
/// HistoryReadRaw with <c>IsReadModified = true</c> decodes as a <see cref="HistoryModifiedData"/> whose
/// <c>ModificationInfos</c> carry the update type and the principal, and whose values carry the PRIOR value.
/// </summary>
[Fact]
public async Task A_modified_read_returns_the_trail_with_principal_and_update_type_over_the_wire()
{
var ct = TestContext.Current.CancellationToken;
var store = new SharedStore();
var rig = await StartAsync("HuWireModified", encrypted: false, ct);
try
{
rig.Nm.HistorianDataUpdater = store;
rig.Nm.HistorianDataSource = store;
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor));
var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var node = RawNode(session, id);
await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.5), (T2, 2.5));
await DeleteAtTimeAsync(session, node, ct, T1);
var details = new ReadRawModifiedDetails
{
StartTime = T1.AddHours(-1), EndTime = T1.AddHours(1), NumValuesPerNode = 100, IsReadModified = true,
};
var response = await session.HistoryReadAsync(
null, new ExtensionObject(details), TimestampsToReturn.Both, false,
new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }, ct);
var result = response.Results.ShouldHaveSingleItem();
result.StatusCode.Code.ShouldBe(StatusCodes.Good);
var data = ExtensionObject.ToEncodeable(result.HistoryData).ShouldBeOfType<HistoryModifiedData>();
data.DataValues.Select(v => v.SourceTimestamp).ShouldBe([T1, T1, T2]);
data.ModificationInfos.Select(m => m.UpdateType)
.ShouldBe([HistoryUpdateType.Insert, HistoryUpdateType.Delete, HistoryUpdateType.Insert]);
data.ModificationInfos.ShouldAllBe(m => m.UserName == KeyPrincipal);
data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData); // an insert had no prior value
((double)data.DataValues[1].Value).ShouldBe(1.5); // the deleted sample's prior value
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// ALLOW beside DENY: the same server, same node — the editor session is admitted, the session
/// without the HistoryUpdate grant gets BadUserAccessDenied and the updater is never called.
@@ -263,14 +307,21 @@ public sealed class HistoryUpdateOverTheWireTests
{
private readonly object _gate = new();
private readonly SortedDictionary<DateTime, double> _samples = [];
private readonly List<HistoryModifiedValue> _trail = [];
private int _updateCalls;
private void Record(DateTime t, double? prior, HistoryModificationType type, HistoryUpdateContext who) =>
_trail.Add(new HistoryModifiedValue(
new DataValueSnapshot(prior, prior is null ? StatusCodes.BadNoData : StatusCodes.Good, t, t),
DateTime.UtcNow, type, who.Principal, _trail.Count + 1));
public int UpdateCalls => Volatile.Read(ref _updateCalls);
public int Count { get { lock (_gate) return _samples.Count; } }
public async Task<IReadOnlyList<uint>> UpdateDataAsync(
string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, CancellationToken ct)
string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context,
CancellationToken ct)
{
Interlocked.Increment(ref _updateCalls);
await Task.Delay(1, ct).ConfigureAwait(false);
@@ -285,21 +336,43 @@ public sealed class HistoryUpdateOverTheWireTests
if (_samples.ContainsKey(t)) { codes.Add(StatusCodes.BadEntryExists); continue; }
v.Value.TryGetDouble(out var d).ShouldBeTrue();
_samples[t] = d;
Record(t, null, HistoryModificationType.Insert, context);
codes.Add(StatusCodes.GoodEntryInserted);
}
}
return codes;
}
public Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
public Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context,
CancellationToken ct) =>
throw new NotSupportedException();
public async Task<IReadOnlyList<uint>> DeleteAtTimeAsync(
string tag, IReadOnlyList<DateTime> timesUtc, CancellationToken ct)
string tag, IReadOnlyList<DateTime> timesUtc, HistoryUpdateContext context, CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
lock (_gate)
return timesUtc.Select(t => _samples.Remove(t) ? StatusCodes.Good : StatusCodes.BadNoEntryExists).ToList();
return timesUtc.Select(t =>
{
if (!_samples.Remove(t, out var prior)) return StatusCodes.BadNoEntryExists;
Record(t, prior, HistoryModificationType.Delete, context);
return StatusCodes.Good;
}).ToList();
}
public async Task<HistoryModifiedReadResult> ReadModifiedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValues, HistoryModifiedCursor? resumeAfter,
CancellationToken cancellationToken)
{
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
fullReference.ShouldBe(Tagname);
lock (_gate)
{
var rows = _trail
.Where(m => m.Value.SourceTimestampUtc >= startUtc && m.Value.SourceTimestampUtc < endUtc)
.OrderBy(m => m.Value.SourceTimestampUtc).ThenBy(m => m.ModifiedId).ToList();
return new HistoryModifiedReadResult(rows, false);
}
}
public async Task<CoreHistoryReadResult> ReadRawAsync(
@@ -463,6 +463,56 @@ public sealed class HistoryUpdateTests : IDisposable
finally { await host.DisposeAsync(); }
}
/// <summary>
/// #668: the session's API-key principal and key id reach EVERY updater call (they become the Part 11
/// ModificationInfo.UserName of the trail rows the backend records).
/// </summary>
[Fact]
public async Task The_key_principal_and_key_id_are_threaded_to_every_updater_call()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var updater = new FakeUpdater();
nm.HistorianDataUpdater = updater;
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
Update(nm, identity, nodeId, PerformUpdateType.Update, Value(T1, 1d));
Call(nm, identity, typeof(DeleteRawModifiedDetails),
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false });
Call(nm, identity, typeof(DeleteAtTimeDetails),
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } });
updater.Callers.Count.ShouldBe(3);
updater.Callers.ShouldAllBe(c => c == new HistoryUpdateContext(Principal, KeyId));
}
finally { await host.DisposeAsync(); }
}
/// <summary>#668: an admitted anonymous session is recorded as the reserved <c>anonymous</c> principal, no key id.</summary>
[Fact]
public async Task An_anonymous_session_is_recorded_as_the_anonymous_principal()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var updater = new FakeUpdater();
nm.HistorianDataUpdater = updater;
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
var (_, errors) = Insert(nm, AnonymousIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
updater.Callers.ShouldHaveSingleItem().ShouldBe(new HistoryUpdateContext(OpcUaPrincipals.Anonymous, null));
}
finally { await host.DisposeAsync(); }
}
/// <summary>DeleteAtTime returns the updater's per-time codes in order.</summary>
[Fact]
public async Task Delete_at_time_passes_per_time_codes_through()
@@ -593,6 +643,7 @@ public sealed class HistoryUpdateTests : IDisposable
private sealed class FakeUpdater : IHistorianDataUpdater
{
public List<(string Tag, HistoryUpdateMode Mode, IReadOnlyList<HistorizationValue> Values)> UpdateCalls { get; } = [];
public List<HistoryUpdateContext> Callers { get; } = [];
public List<(string Tag, DateTime Start, DateTime End)> DeleteRawCalls { get; } = [];
public List<IReadOnlyList<DateTime>> DeleteAtTimeCalls { get; } = [];
public uint[]? UpdateCodes { get; init; }
@@ -601,26 +652,28 @@ public sealed class HistoryUpdateTests : IDisposable
public Exception? Throw { get; init; }
public async Task<IReadOnlyList<uint>> UpdateDataAsync(
string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, CancellationToken ct)
string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context,
CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
if (Throw is not null) throw Throw;
lock (UpdateCalls) UpdateCalls.Add((tag, mode, values.ToList()));
lock (UpdateCalls) { UpdateCalls.Add((tag, mode, values.ToList())); Callers.Add(context); }
return UpdateCodes ?? Enumerable.Repeat(StatusCodes.GoodEntryInserted, values.Count).ToArray();
}
public async Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct)
public async Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context,
CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
lock (DeleteRawCalls) DeleteRawCalls.Add((tag, startUtc, endUtc));
lock (DeleteRawCalls) { DeleteRawCalls.Add((tag, startUtc, endUtc)); lock (UpdateCalls) Callers.Add(context); }
return DeleteRawCode;
}
public async Task<IReadOnlyList<uint>> DeleteAtTimeAsync(
string tag, IReadOnlyList<DateTime> timesUtc, CancellationToken ct)
string tag, IReadOnlyList<DateTime> timesUtc, HistoryUpdateContext context, CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
lock (DeleteAtTimeCalls) DeleteAtTimeCalls.Add(timesUtc.ToList());
lock (DeleteAtTimeCalls) { DeleteAtTimeCalls.Add(timesUtc.ToList()); lock (UpdateCalls) Callers.Add(context); }
return DeleteAtTimeCodes ?? Enumerable.Repeat(StatusCodes.Good, timesUtc.Count).ToArray();
}
}
@@ -89,6 +89,33 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
finally { await host.DisposeAsync(); }
}
/// <summary>
/// #668: a modified-value read (<c>IsReadModified = true</c>) is gated by the SAME HistoryRead decision — no new
/// operation. Denied without the bit; the granted principal reaches the backend (Null ⇒ unsupported, which is
/// "allowed but no trail", a different fact from the denial).
/// </summary>
[Fact]
public async Task Modified_history_read_is_gated_by_the_history_read_grant()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.HistorianDataSource = NullHistorianDataSource.Instance;
var nodeId = Historized(nm, "Plant/Modbus/dev1/Speed");
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.Read));
var (deniedResults, deniedErrors) = ReadRaw(nm, Identity(), modified: true, nodeId);
deniedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
deniedResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.HistoryRead));
var (_, allowedErrors) = ReadRaw(nm, Identity(), modified: true, nodeId);
allowedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
}
finally { await host.DisposeAsync(); }
}
/// <summary>A granted principal reads normally — the gate is not simply denying everything.</summary>
[Fact]
public async Task History_read_succeeds_for_a_granted_principal()
@@ -308,7 +335,11 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
}
private static (List<SdkHistoryReadResult> Results, List<ServiceResult> Errors) ReadRaw(
OtOpcUaNodeManager nm, IUserIdentity? identity, params NodeId[] nodeIds)
OtOpcUaNodeManager nm, IUserIdentity? identity, params NodeId[] nodeIds) =>
ReadRaw(nm, identity, modified: false, nodeIds);
private static (List<SdkHistoryReadResult> Results, List<ServiceResult> Errors) ReadRaw(
OtOpcUaNodeManager nm, IUserIdentity? identity, bool modified, params NodeId[] nodeIds)
{
var context = new OperationContext(
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity);
@@ -324,7 +355,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
NumValuesPerNode = 10,
IsReadModified = false,
IsReadModified = modified,
},
TimestampsToReturn.Both,
releaseContinuationPoints: false,
@@ -0,0 +1,308 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Gitea #668 — OPC UA Part 11 modified-value history: HistoryReadRaw with <c>IsReadModified = true</c> dispatches
/// to <see cref="IHistorianDataSource.ReadModifiedAsync"/>, projects a <see cref="HistoryModifiedData"/> with one
/// <see cref="ModificationInfo"/> per value, and pages with continuation points keyed on
/// <c>(SourceTimeUtc, ModifiedId)</c>. Same harness as <see cref="NodeManagerHistoryReadEventsPagingTests"/>: a real
/// SDK server, a session-less in-process HistoryRead and an <see cref="InMemoryHistoryContinuationStore"/>, over a
/// fake that behaves like the SQL backends (ordered by key, half-open window, cap + exact truncation flag).
/// </summary>
public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
{
private const string Tag = "WW.Mod";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static readonly DateTime Epoch = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-modified-{Guid.NewGuid():N}");
[Fact]
public async Task A_modified_read_dispatches_to_the_trail_and_projects_modification_infos()
{
var modifiedAt = Epoch.AddDays(2);
var trail = new List<HistoryModifiedValue>
{
Mod(1, seconds: 10, prior: null, HistoryModificationType.Insert, "alice", modifiedAt),
Mod(2, seconds: 10, prior: 1.5, HistoryModificationType.Replace, "bob", modifiedAt.AddMinutes(1)),
Mod(3, seconds: 20, prior: 7.0, HistoryModificationType.Delete, "carol", modifiedAt.AddMinutes(2)),
};
var (host, nm, node, src) = await SetupAsync(trail);
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 100, cp: null);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
r.Result.StatusCode.Code.ShouldBe(StatusCodes.Good);
r.Cp.ShouldBeNull();
src.LastTag.ShouldBe(Tag);
src.LastStart.ShouldBe(Epoch);
src.LastEnd.ShouldBe(Epoch.AddHours(1));
src.LastMax.ShouldBe(100u);
src.LastCursor.ShouldBeNull();
var data = r.Data!;
data.DataValues.Count.ShouldBe(3);
data.ModificationInfos.Count.ShouldBe(3);
data.ModificationInfos.Select(m => m.UpdateType)
.ShouldBe([HistoryUpdateType.Insert, HistoryUpdateType.Replace, HistoryUpdateType.Delete]);
data.ModificationInfos.Select(m => m.UserName).ShouldBe(["alice", "bob", "carol"]);
data.ModificationInfos.Select(m => m.ModificationTime)
.ShouldBe([modifiedAt, modifiedAt.AddMinutes(1), modifiedAt.AddMinutes(2)]);
data.DataValues[0].Value.ShouldBeNull();
data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData);
data.DataValues[1].Value.ShouldBe(1.5);
data.DataValues[1].SourceTimestamp.ShouldBe(Epoch.AddSeconds(10));
data.DataValues[2].Value.ShouldBe(7.0);
await host.DisposeAsync();
}
[Fact]
public async Task An_empty_trail_is_GoodNoData()
{
var (host, nm, node, _) = await SetupAsync([]);
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
r.Result.StatusCode.Code.ShouldBe(StatusCodes.GoodNoData);
r.Data!.ModificationInfos.ShouldBeEmpty();
await host.DisposeAsync();
}
[Fact]
public async Task Modified_reads_page_exactly_once_in_order_even_through_same_time_runs()
{
// 30 modifications; ids 5..9 all share one source time, so a page boundary cuts through the run.
var trail = Enumerable.Range(1, 30)
.Select(i => Mod(i, seconds: i is >= 5 and <= 9 ? 5 : i, prior: i, HistoryModificationType.Replace, "u", Epoch))
.ToList();
var (host, nm, node, src) = await SetupAsync(trail);
var ids = Drain(nm, node, Epoch, Epoch.AddHours(1), max: 4);
ids.ShouldBe(src.Ordered(reverse: false).Select(m => m.ModifiedId));
ids.Count.ShouldBe(30);
src.Calls.ShouldBe(8); // 7 full pages + a 2-value tail; the exact HasMore flag means no trailing empty page
await host.DisposeAsync();
}
[Fact]
public async Task A_reverse_modified_read_pages_descending()
{
var trail = Enumerable.Range(1, 11).Select(i => Mod(i, seconds: i / 2, prior: i, HistoryModificationType.Delete, "u", Epoch)).ToList();
var (host, nm, node, src) = await SetupAsync(trail);
var ids = Drain(nm, node, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 3);
ids.ShouldBe(src.Ordered(reverse: true).Select(m => m.ModifiedId));
ids.First().ShouldBe(11);
await host.DisposeAsync();
}
[Fact]
public async Task An_open_ended_forward_modified_read_pages_to_the_end()
{
var trail = Enumerable.Range(1, 9).Select(i => Mod(i, seconds: i, prior: i, HistoryModificationType.Update, "u", Epoch)).ToList();
var (host, nm, node, src) = await SetupAsync(trail);
Drain(nm, node, Epoch, DateTime.MinValue, max: 4).ShouldBe(src.Ordered(reverse: false).Select(m => m.ModifiedId));
src.LastEnd.ShouldBe(new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc));
await host.DisposeAsync();
}
[Fact]
public async Task An_unbounded_modified_read_never_pages()
{
var trail = Enumerable.Range(1, 50).Select(i => Mod(i, seconds: i, prior: i, HistoryModificationType.Update, "u", Epoch)).ToList();
var (host, nm, node, _) = await SetupAsync(trail);
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 0, cp: null);
r.Data!.DataValues.Count.ShouldBe(50);
r.Cp.ShouldBeNull();
await host.DisposeAsync();
}
[Fact]
public async Task Cursors_are_bound_to_the_read_kind_and_the_node()
{
var trail = Enumerable.Range(1, 10).Select(i => Mod(i, seconds: i, prior: i, HistoryModificationType.Update, "u", Epoch)).ToList();
var (host, nm, node, _) = await SetupAsync(trail);
// A RAW cursor for the same tag is not a modified cursor.
var raw = nm.HistoryContinuationStore.Save(null, new HistoryContinuationState(Tag, Epoch, Epoch.AddDays(1), 0, 4));
Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: raw).Error.StatusCode.Code
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
// A MODIFIED cursor presented to a raw read is invalid too.
var p1 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
p1.Cp.ShouldNotBeNull();
Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: p1.Cp, modified: false).Error.StatusCode.Code
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
// A modified cursor issued for one node is invalid on another node's tag.
var p2 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
nm.EnsureVariable("eq-1/other", null, "Other", "Double", false, historianTagname: "WW.Other", realm: AddressSpaceRealm.Uns);
var other = nm.TryGetVariable("eq-1/other")!.NodeId;
Read(nm, other, Epoch, Epoch.AddHours(1), max: 4, cp: p2.Cp).Error.StatusCode.Code
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
await host.DisposeAsync();
}
[Fact]
public async Task A_backend_failure_is_isolated_to_its_node()
{
var (host, nm, node, src) = await SetupAsync([Mod(1, 1, 1, HistoryModificationType.Update, "u", Epoch)]);
src.Throw = new InvalidOperationException("boom");
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
await host.DisposeAsync();
}
// --- helpers ---------------------------------------------------------------------------------
private static HistoryModifiedValue Mod(
long id, int seconds, double? prior, HistoryModificationType type, string user, DateTime modifiedAt)
{
var t = Epoch.AddSeconds(seconds);
var value = prior is null
? new DataValueSnapshot(null, StatusCodes.BadNoData, t, modifiedAt)
: new DataValueSnapshot(prior.Value, StatusCodes.Good, t, t);
return new HistoryModifiedValue(value, modifiedAt, type, user, id);
}
private static List<long> Drain(OtOpcUaNodeManager nm, NodeId node, DateTime start, DateTime end, uint max)
{
var ids = new List<long>();
byte[]? cp = null;
var pages = 0;
do
{
var r = Read(nm, node, start, end, max, cp);
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, r.Error.ToString());
ids.AddRange(r.Ids);
cp = r.Cp;
(++pages).ShouldBeLessThan(60, "paging must terminate");
}
while (cp is not null);
return ids;
}
private static (ServiceResult Error, SdkHistoryReadResult Result, HistoryModifiedData? Data, byte[]? Cp, List<long> Ids) Read(
OtOpcUaNodeManager nm, NodeId node, DateTime start, DateTime end, uint max, byte[]? cp, bool modified = true)
{
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
var results = new List<SdkHistoryReadResult> { null! };
var errors = new List<ServiceResult> { null! };
nm.HistoryRead(context,
new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = modified },
TimestampsToReturn.Both, false,
new List<HistoryReadValueId> { new() { NodeId = node, ContinuationPoint = cp } }, results, errors);
HistoryModifiedData? data = null;
var ids = new List<long>();
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)
&& ExtensionObject.ToEncodeable(ho) is HistoryModifiedData hm)
{
data = hm;
// The fake stores each modification's id in its (unique) server timestamp offset — see SqlLikeTrail.
ids.AddRange(hm.DataValues.Select(v => SqlLikeTrail.IdOf(v)));
}
return (errors[0], results[0], data, results[0]?.ContinuationPoint, ids);
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId Node, SqlLikeTrail Src)> SetupAsync(
List<HistoryModifiedValue> trail)
{
var started = await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.ModifiedHistoryTest",
ApplicationUri = $"urn:OtOpcUa.ModifiedHistoryTest:{Guid.NewGuid():N}",
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
var nm = started.Server.NodeManager!;
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
var src = new SqlLikeTrail(trail);
nm.HistorianDataSource = src;
nm.EnsureVariable("eq-1/mod", null, "Mod", "Double", false, historianTagname: Tag, realm: AddressSpaceRealm.Uns);
return (started.Host, nm, nm.TryGetVariable("eq-1/mod")!.NodeId, src);
}
/// <summary>
/// Behaves like the SqlServer/QuestDB modified reads: ordered by <c>(SourceTimeUtc, ModifiedId)</c> (descending
/// when reversed), half-open window, exclusive cursor, cap + exact <see cref="HistoryModifiedReadResult.HasMore"/>.
/// To let the test recover each projected value's id, the server timestamp handed out is
/// <c>Epoch + id ticks</c> (an otherwise unused field in these tests).
/// </summary>
private sealed class SqlLikeTrail(List<HistoryModifiedValue> trail) : IHistorianDataSource
{
public string? LastTag { get; private set; }
public DateTime LastStart { get; private set; }
public DateTime LastEnd { get; private set; }
public uint LastMax { get; private set; }
public HistoryModifiedCursor? LastCursor { get; private set; }
public int Calls { get; private set; }
public Exception? Throw { get; set; }
public static long IdOf(DataValue v) => v.ServerTimestamp.Ticks - Epoch.Ticks;
public IEnumerable<HistoryModifiedValue> Ordered(bool reverse) => reverse
? trail.OrderByDescending(m => m.Value.SourceTimestampUtc).ThenByDescending(m => m.ModifiedId)
: trail.OrderBy(m => m.Value.SourceTimestampUtc).ThenBy(m => m.ModifiedId);
public async Task<HistoryModifiedReadResult> ReadModifiedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValues, HistoryModifiedCursor? resumeAfter,
CancellationToken cancellationToken)
{
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
if (Throw is not null) throw Throw;
Calls++;
(LastTag, LastStart, LastEnd, LastMax, LastCursor) = (fullReference, startUtc, endUtc, maxValues, resumeAfter);
var reverse = startUtc > endUtc;
var (lo, hi) = reverse ? (endUtc, startUtc) : (startUtc, endUtc);
var rows = Ordered(reverse).Where(m =>
{
var t = m.Value.SourceTimestampUtc!.Value;
var inWindow = startUtc == endUtc ? t == lo : reverse ? t > lo && t <= hi : t >= lo && t < hi;
if (!inWindow) return false;
if (resumeAfter is null) return true;
var c = t.CompareTo(resumeAfter.SourceTimeUtc);
return reverse ? c < 0 || (c == 0 && m.ModifiedId < resumeAfter.ModifiedId)
: c > 0 || (c == 0 && m.ModifiedId > resumeAfter.ModifiedId);
}).Select(m => m with { Value = m.Value with { ServerTimestampUtc = Epoch.AddTicks(m.ModifiedId) } }).ToList();
var take = maxValues == 0 ? rows.Count : (int)Math.Min(maxValues, (uint)rows.Count);
return new HistoryModifiedReadResult(rows.Take(take).ToList(), rows.Count > take);
}
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
public Task<HistorianRead> ReadProcessedAsync(
string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) =>
NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, ct);
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
public void Dispose() { }
}
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best effort */ }
}
}
}
@@ -175,10 +175,13 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
await host.DisposeAsync();
}
/// <summary>Raw with IsReadModified=true ⇒ BadHistoryOperationUnsupported (we don't serve modified
/// history); the source is never invoked.</summary>
/// <summary>
/// #668: Raw with IsReadModified=true on a backend that keeps no modification trail (the recording fake has no
/// ReadModifiedAsync override, like Gateway) ⇒ BadHistoryOperationUnsupported, and the RAW read is never
/// invoked in its place.
/// </summary>
[Fact]
public async Task Raw_read_modified_yields_BadHistoryOperationUnsupported()
public async Task Raw_read_modified_on_a_backend_without_a_trail_yields_BadHistoryOperationUnsupported()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
@@ -197,10 +200,16 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
IsReadModified = true,
};
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
fake.LastCall.ShouldBeNull();
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
fake.LastCall.ShouldBeNull(); // the raw read is never served in place of a modified one
// The Null default (no historian configured) answers the same.
nm.HistorianDataSource = NullHistorianDataSource.Instance;
InvokeHistoryRead(server, nm, details, nodeId).Item2[0].StatusCode.Code
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
await host.DisposeAsync();
}