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; /// /// Gitea #674.1 — pins the QuestDB TTL behaviour the historian's retention relies on: a table TTL is judged against /// the wall clock (min(max timestamp, now)), so one far-future row — a 2066 typo that reached the backend /// without going through the node manager's HistoryUpdateMaxFutureSkew bound — cannot age out every older /// partition, including the Part 11 modification trail (the record of who changed history). /// /// /// /// Positive control. A partition genuinely older than the TTL (40 days, against a 30-day TTL) is kept until /// the far-future commit and evicted BY it — proving TTL housekeeping ran on exactly the commit that could have /// dropped the in-TTL partitions. Without it the "nothing evicted" assertion would also pass against a table /// whose TTL never ran. /// /// /// ⚠️ Observed on QuestDB 10.0.1 (2026-10-11): the in-TTL partitions also survived with /// cairo.ttl.use.wall.clock=false (verified effective via SHOW PARAMETERS), so this test does not /// isolate that flag — it pins the outcome. Keep the flag at its default (true) regardless; the server-side /// future-skew bound is the primary guard. /// /// [Collection(QuestDbCollection.Name)] public sealed class TtlWallClockTests(QuestDbFixture fx) { private static CancellationToken Ct => TestContext.Current.CancellationToken; private static readonly HistoryUpdateContext Who = new("ttl-test", null); [Fact] public async Task A_far_future_row_evicts_only_partitions_older_than_the_ttl_by_the_wall_clock() { fx.SkipIfUnavailable(); var o = fx.Options(fx.UniquePrefix(), retentionDays: 30); var c = new QuestDbConnection(o); var wal = new QuestDbWalVisibility(c, o.VisibilityTimeout); var tags = new QuestDbTagCatalog(c, new QuestDbSchemaBootstrapper(c, o), wal); var updater = new QuestDbHistorianDataUpdater(c, tags, wal); // Whole seconds (µs-aligned), one row per DAY partition. With the newest row 10 days old, the 40-day partition is // still inside the TTL measured from the table's max timestamp, so this commit evicts nothing. var now = DateTime.UtcNow; var today = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, DateTimeKind.Utc); DateTime[] older = [today.AddDays(-40), today.AddDays(-20), today.AddDays(-10)]; (await updater.UpdateDataAsync("ttl.tag", HistoryUpdateMode.Insert, older.Select((t, i) => V(t, i)).ToList(), Who, Ct)) .ShouldAllBe(code => code == HistorianStatusCodes.GoodEntryInserted); (await DaysAsync(c, c.SampleTable)).ShouldBe(older.Select(t => t.Date)); (await DaysAsync(c, c.SampleModifiedTable)).ShouldBe(older.Select(t => t.Date)); // The far-future row, then one more ordinary commit (TTL housekeeping runs on commits). (await updater.UpdateDataAsync("ttl.tag", HistoryUpdateMode.Insert, [V(new DateTime(2066, 1, 1, 0, 0, 0, DateTimeKind.Utc), 9)], Who, Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]); (await updater.UpdateDataAsync("ttl.tag", HistoryUpdateMode.Insert, [V(today, 10)], Who, Ct)) .ShouldBe([HistorianStatusCodes.GoodEntryInserted]); (await wal.WaitAsync(c.SampleTable, Ct)).ShouldBeTrue(); (await wal.WaitAsync(c.SampleModifiedTable, Ct)).ShouldBeTrue(); // Only the genuinely expired 40-day partition is gone; the 20- and 10-day partitions survive in both tables. DateTime[] expected = [today.AddDays(-20).Date, today.AddDays(-10).Date, today.Date, new(2066, 1, 1)]; (await DaysAsync(c, c.SampleTable)).ShouldBe(expected); (await DaysAsync(c, c.SampleModifiedTable)).ShouldBe(expected); } private static HistorizationValue V(DateTime t, double v) => new(t, HistorianValue.FromDouble(v), HistorianStatusCodes.Good); /// The distinct UTC days holding rows, ascending. private static async Task> DaysAsync(QuestDbConnection c, string table) { await using var conn = await c.OpenAsync(Ct); await using var cmd = c.CreateCommand(conn, $"SELECT ts FROM {table} ORDER BY ts"); await using var r = await cmd.ExecuteReaderAsync(Ct); var days = new SortedSet(); while (await r.ReadAsync(Ct)) days.Add(QuestDbConnection.ReadUtc(r, 0).Date); return days.ToList(); } }