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.QuestDb.IntegrationTests; /// /// The QuestDB modification trail's applied marker and dedup key. QuestDB has no multi-table transaction, so the trail /// row is written BEFORE its change: a failed change left an orphan row that modified reads served as a change that /// happened, and a trail INSERT re-sent after a client timeout duplicated. Now the row is written pending, /// marked applied after the change, keyed (ts, tag, modified_id); a modified read drops a pending row unless the /// sample (latest row) or the next row's recorded prior value (any earlier row, lmxopcua#778) shows the change landed. /// [Collection(QuestDbCollection.Name)] public sealed class TrailMarkerTests(QuestDbFixture fx) { private static readonly DateTime T0 = new(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc); private static readonly CancellationToken Ct = CancellationToken.None; private static QuestDbHistorianDataSource Source(UpdateTests.Rig rig) => new(rig.Conn, rig.Tags.Bootstrap, rig.Tags); private static async Task> ModifiedAsync(UpdateTests.Rig rig, string tag) => (await Source(rig).ReadModifiedAsync(tag, T0.AddMinutes(-1), T0.AddMinutes(10), 0, null, Ct)).Values; private static async Task TrailRowsAsync(UpdateTests.Rig rig, string tag) { await using var conn = await rig.Conn.OpenAsync(Ct); await using var cmd = rig.Conn.CreateCommand(conn, $"SELECT count() FROM {rig.Conn.SampleModifiedTable} WHERE tag = $1"); QuestDbConnection.BindText(cmd, tag); return Convert.ToInt64(await cmd.ExecuteScalarAsync()); } [Fact] public async Task A_new_trail_table_has_the_marker_format_and_an_applied_change_reads_back() { fx.SkipIfUnavailable(); var rig = UpdateTests.MakeRig(fx); (await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [UpdateTests.V(T0, 1)], UpdateTests.Who, Ct)) .ShouldBe([HistorianStatusCodes.GoodEntryInserted]); rig.Tags.Bootstrap.SupportsTrailMarker.ShouldBeTrue(); var trail = await ModifiedAsync(rig, "t"); trail.ShouldHaveSingleItem().UpdateType.ShouldBe(HistoryModificationType.Insert); (await TrailRowsAsync(rig, "t")).ShouldBe(1, "the applied marker re-upserts the row, it does not add one"); } [Fact] public async Task The_orphan_of_a_change_that_never_landed_is_not_served() { fx.SkipIfUnavailable(); var rig = UpdateTests.MakeRig(fx); await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [UpdateTests.V(T0, 1)], UpdateTests.Who, Ct); rig.Updater.BeforeSampleWrite = () => throw new InvalidOperationException("the sample INSERT failed"); await Should.ThrowAsync( () => rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [UpdateTests.V(T0, 2)], UpdateTests.Who, Ct)); (await TrailRowsAsync(rig, "t")).ShouldBe(2, "precondition: the orphan trail row is stored"); var trail = await ModifiedAsync(rig, "t"); trail.ShouldHaveSingleItem("only the insert that happened").UpdateType.ShouldBe(HistoryModificationType.Insert); // Positive control: the same replace, landing, is served. rig.Updater.BeforeSampleWrite = null; await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [UpdateTests.V(T0, 2)], UpdateTests.Who, Ct); (await ModifiedAsync(rig, "t")).Select(v => v.UpdateType) .ShouldBe([HistoryModificationType.Insert, HistoryModificationType.Replace]); } [Theory] [InlineData(false)] [InlineData(true)] public async Task A_change_that_landed_without_its_marker_is_still_served(bool delete) { // A crash between the sample change and the marker: the row stays pending, but the sample shows the change. fx.SkipIfUnavailable(); var rig = UpdateTests.MakeRig(fx); await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [UpdateTests.V(T0, 1)], UpdateTests.Who, Ct); rig.Updater.SkipAppliedMarker = true; if (delete) (await rig.Updater.DeleteAtTimeAsync("t", [T0], UpdateTests.Who, Ct)).ShouldBe([HistorianStatusCodes.Good]); else await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [UpdateTests.V(T0, 2)], UpdateTests.Who, Ct); (await ModifiedAsync(rig, "t")).Select(v => v.UpdateType).ShouldBe( [HistoryModificationType.Insert, delete ? HistoryModificationType.Delete : HistoryModificationType.Replace]); } [Theory] [InlineData(false)] [InlineData(true)] public async Task A_change_whose_marker_was_lost_stays_served_after_a_later_change_at_its_time(bool delete) { // lmxopcua#778: V0→V1 lands but its applied marker is never written; a later V1→V2 (or a delete) at the same time // used to hide it — the pending row was no longer the latest at its time, so it was dropped as an orphan. fx.SkipIfUnavailable(); var rig = UpdateTests.MakeRig(fx); await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [UpdateTests.V(T0, 0)], UpdateTests.Who, Ct); rig.Updater.SkipAppliedMarker = true; await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [UpdateTests.V(T0, 1)], UpdateTests.Who, Ct); rig.Updater.SkipAppliedMarker = false; (await ModifiedAsync(rig, "t")).Count.ShouldBe(2, "precondition: served while it is the latest row"); if (delete) (await rig.Updater.DeleteAtTimeAsync("t", [T0], UpdateTests.Who, Ct)).ShouldBe([HistorianStatusCodes.Good]); else await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [UpdateTests.V(T0, 2)], UpdateTests.Who, Ct); var trail = await ModifiedAsync(rig, "t"); trail.Select(v => v.UpdateType).ShouldBe( [HistoryModificationType.Insert, HistoryModificationType.Replace, delete ? HistoryModificationType.Delete : HistoryModificationType.Replace]); trail.Select(v => v.Value.Value).ShouldBe([null, 0.0, 1.0], "each row carries the value its change replaced"); } [Fact] public async Task An_orphan_followed_by_a_landed_change_whose_marker_was_lost_is_still_dropped() { // The other side of lmxopcua#778's rule: V0→V1 never lands (orphan), then the retry V0→V1 lands without its marker, // then V1→V2. The orphan's next row recorded the SAME prior value (V0) ⇒ dropped; the retry's next row recorded V1 // ⇒ served. fx.SkipIfUnavailable(); var rig = UpdateTests.MakeRig(fx); await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [UpdateTests.V(T0, 0)], UpdateTests.Who, Ct); rig.Updater.BeforeSampleWrite = () => throw new InvalidOperationException("the sample INSERT failed"); await Should.ThrowAsync( () => rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [UpdateTests.V(T0, 1)], UpdateTests.Who, Ct)); rig.Updater.BeforeSampleWrite = null; rig.Updater.SkipAppliedMarker = true; await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [UpdateTests.V(T0, 1)], UpdateTests.Who, Ct); rig.Updater.SkipAppliedMarker = false; await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [UpdateTests.V(T0, 2)], UpdateTests.Who, Ct); (await TrailRowsAsync(rig, "t")).ShouldBe(4, "precondition: the orphan trail row is stored"); var trail = await ModifiedAsync(rig, "t"); trail.Select(v => v.UpdateType) .ShouldBe([HistoryModificationType.Insert, HistoryModificationType.Replace, HistoryModificationType.Replace]); trail.Select(v => v.Value.Value).ShouldBe([null, 0.0, 1.0]); } [Fact] public async Task A_trail_insert_re_sent_after_a_transient_failure_does_not_duplicate() { fx.SkipIfUnavailable(); var rig = UpdateTests.MakeRig(fx); await rig.Tags.Bootstrap.EnsureAsync(Ct); var failedOnce = false; // The first attempt APPLIES and then fails as a client timeout would: the re-send carries the same ids. rig.Updater.AfterTrailInsert = attempt => { if (attempt == 0 && !failedOnce) { failedOnce = true; throw new TimeoutException("client timeout"); } return Task.CompletedTask; }; (await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [UpdateTests.V(T0, 1), UpdateTests.V(T0.AddSeconds(1), 2), UpdateTests.V(T0.AddSeconds(2), 3)], UpdateTests.Who, Ct)) .ShouldAllBe(s => s == HistorianStatusCodes.GoodEntryInserted); failedOnce.ShouldBeTrue(); rig.Updater.TrailInserts.ShouldBeGreaterThanOrEqualTo(3, "pending, the re-send, then the marker"); (await TrailRowsAsync(rig, "t")).ShouldBe(3, "the re-sent rows converge on their dedup key"); (await ModifiedAsync(rig, "t")).Count.ShouldBe(3); } /// /// The paged-vs-unpaged fuzz for the reconciled modified read: a random trail (several modifications per source /// time, a random mix of applied, orphaned and landed-without-marker rows) paged at random caps, forward and /// reverse, through the cursor, must concatenate to the unpaged read — the window function's partition must never be /// cut by the resume position — and the unpaged read must equal an independent oracle of the marker rule. /// [Fact] public async Task Paged_modified_reads_equal_the_unpaged_read_over_a_random_marker_mix() { fx.SkipIfUnavailable(); var rig = UpdateTests.MakeRig(fx); await rig.Tags.Bootstrap.EnsureAsync(Ct); await rig.Tags.EnsureAsync("t", HistorianDataTypes.FromKind(HistorianValueKind.Double), Ct); var rng = new Random(746); var expected = new List<(DateTime Ts, long Id)>(); var trailSql = new System.Text.StringBuilder(); var sampleSql = new System.Text.StringBuilder(); long id = 1_000; var mod0 = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); for (var t = 0; t < 40; t++) { var ts = T0.AddSeconds(t); // Each row's recorded prior value is 1.0 or 2.0: a pending row below the latest is served exactly when the // next row recorded a different prior (lmxopcua#778). var rows = new List<(long Id, bool Pending, DateTime Mod, double Prior)>(); for (var k = rng.Next(1, 5); k > 0; k--) rows.Add((id++, rng.Next(3) == 0, mod0.AddSeconds(id), rng.Next(2) + 1.0)); // The sample's write time: after every modification (the latest landed), or before the latest (it did not). var latest = rows[^1]; var landed = rng.Next(2) == 0; var sampleServer = landed ? latest.Mod.AddSeconds(1) : latest.Mod.AddSeconds(-1); for (var j = 0; j < rows.Count; j++) { var r = rows[j]; var served = !r.Pending || (r.Id == latest.Id ? landed : rows[j + 1].Prior != r.Prior); if (served) expected.Add((ts, r.Id)); if (trailSql.Length > 0) trailSql.Append(','); trailSql.Append(FormattableString.Invariant( $"('{ts:O}','t',{r.Id},'{r.Mod:O}',1,'p',NULL,1,{r.Prior:0.0},NULL,NULL,0,NULL,{(r.Pending ? "true" : "false")})")); } if (rng.Next(6) > 0) { if (sampleSql.Length > 0) sampleSql.Append(','); sampleSql.Append($"('{ts:O}','t',1,1.0,NULL,NULL,0,false,'{sampleServer:O}')"); } else { // No sample at this time at all: the latest row, if pending, is not served (earlier rows are unaffected). expected.RemoveAll(e => e.Ts == ts && e.Id == latest.Id && latest.Pending); } } await using (var conn = await rig.Conn.OpenAsync(Ct)) { await using (var cmd = rig.Conn.CreateCommand(conn, $"INSERT INTO {rig.Conn.SampleModifiedTable} (ts, tag, modified_id, modification_ts, " + $"update_type, principal, api_key_id, kind, num_value, int_value, str_value, status, server_ts, pending) VALUES {trailSql}")) await cmd.ExecuteNonQueryAsync(Ct); if (sampleSql.Length > 0) await using (var cmd = rig.Conn.CreateCommand(conn, $"INSERT INTO {rig.Conn.SampleTable} (ts, tag, kind, num_value, " + $"int_value, str_value, status, deleted, server_ts) VALUES {sampleSql}")) await cmd.ExecuteNonQueryAsync(Ct); } var wal = new QuestDbWalVisibility(rig.Conn, TimeSpan.FromSeconds(10)); await wal.WaitAsync(rig.Conn.SampleModifiedTable, Ct); await wal.WaitAsync(rig.Conn.SampleTable, Ct); var src = Source(rig); var (lo, hi) = (T0.AddSeconds(-1), T0.AddSeconds(41)); foreach (var reverse in new[] { false, true }) { var (start, end) = reverse ? (hi, lo) : (lo, hi); var truth = (await src.ReadModifiedAsync("t", start, end, 0, null, Ct)).Values.Select(v => (v.SourceTimeUtc, v.ModifiedId)).ToList(); var oracle = reverse ? expected.AsEnumerable().Reverse().ToList() : expected; truth.ShouldBe(oracle, $"unpaged vs the marker oracle (reverse {reverse})"); foreach (var cap in new uint[] { 1, 2, 3, 5, 7, 13, 50 }) { var paged = new List<(DateTime, long)>(); HistoryModifiedCursor? cursor = null; for (var page = 0; ; page++) { page.ShouldBeLessThan(500, "paging must terminate"); var r = await src.ReadModifiedAsync("t", start, end, cap, cursor, Ct); r.Values.Count.ShouldBeLessThanOrEqualTo((int)cap); paged.AddRange(r.Values.Select(v => (v.SourceTimeUtc, v.ModifiedId))); if (!r.HasMore) break; cursor = new HistoryModifiedCursor(r.Values[^1].SourceTimeUtc, r.Values[^1].ModifiedId); } paged.ShouldBe(truth, $"paged ≠ unpaged at cap {cap} (reverse {reverse})"); } } expected.Count.ShouldBeGreaterThan(0); ((long)expected.Count).ShouldBeLessThan(id - 1_000, "the mix must hide some rows, or the reconciliation is untested"); } [Fact] public async Task A_pre_marker_trail_table_is_migrated_on_boot_and_its_old_rows_stay_visible() { fx.SkipIfUnavailable(); var p = fx.UniquePrefix(); var o = fx.Options(p); var c = new QuestDbConnection(o); await using (var conn = await c.OpenAsync(Ct)) { foreach (var ddl in QuestDbTables.CreateAll(p, null)) { // The trail as it was: no pending column, no dedup key. var old = ddl.Replace(", pending BOOLEAN", "", StringComparison.Ordinal) .Replace(" DEDUP UPSERT KEYS(ts, tag, modified_id)", "", StringComparison.Ordinal); await using var cmd = c.CreateCommand(conn, old); await cmd.ExecuteNonQueryAsync(Ct); } } // An old-format update first (the bootstrap with AutoCreateSchema=false keeps the old format). var legacyOptions = fx.Options(p, autoCreate: false); var legacyConn = new QuestDbConnection(legacyOptions); var legacyWal = new QuestDbWalVisibility(legacyConn, legacyOptions.VisibilityTimeout); var legacyTags = new QuestDbTagCatalog(legacyConn, new QuestDbSchemaBootstrapper(legacyConn, legacyOptions), legacyWal); var legacy = new UpdateTests.Rig(legacyConn, new QuestDbHistorianDataUpdater(legacyConn, legacyTags, legacyWal), legacyTags); (await legacy.Updater.UpdateDataAsync("t", HistoryUpdateMode.Insert, [UpdateTests.V(T0, 1)], UpdateTests.Who, Ct)) .ShouldBe([HistorianStatusCodes.GoodEntryInserted]); legacyTags.Bootstrap.SupportsTrailMarker.ShouldBeFalse(); (await ModifiedAsync(legacy, "t")).Count.ShouldBe(1); // A boot with AutoCreateSchema=true migrates; the old row (pending reads false) is still served, a new one too. var wal = new QuestDbWalVisibility(c, o.VisibilityTimeout); var tags = new QuestDbTagCatalog(c, new QuestDbSchemaBootstrapper(c, o), wal); var rig = new UpdateTests.Rig(c, new QuestDbHistorianDataUpdater(c, tags, wal), tags); await tags.Bootstrap.EnsureAsync(Ct); tags.Bootstrap.SupportsTrailMarker.ShouldBeTrue(); await rig.Updater.UpdateDataAsync("t", HistoryUpdateMode.Replace, [UpdateTests.V(T0, 2)], UpdateTests.Who, Ct); (await ModifiedAsync(rig, "t")).Select(v => v.UpdateType) .ShouldBe([HistoryModificationType.Insert, HistoryModificationType.Replace]); } }