Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/TtlWallClockTests.cs
T
Joseph Doherty 0658d244d0 feat(historian): #674 HistoryUpdate posture — future-skew bound, Primary gate, SqlServer v1 accepted, QuestDB TTL pin
- HistoryUpdate items with SourceTimestamp > now + ServerHistorian:HistoryUpdateMaxFutureSkew
  (default 1 day, <=0 disables) => BadOutOfRange in the node manager (every backend, never sent);
  clock injected via OtOpcUaNodeManager.Clock (host TimeProvider).
- HistoryUpdate is Primary-only: off-Primary => BadInvalidState per node, after authorization,
  same IDriverPrimaryRoleSource verdict as the native-ack gate; HistoryRead ungated.
- SqlServer: base schema requirement back to v1; SupportsModifiedTrail (v>=2). On v1 with
  AutoCreateSchema=false: writes/reads/events/retention work, modified reads + HistoryUpdate =>
  BadHistoryOperationUnsupported with nothing sent, retention skips SampleModified, one Warning per boot.
- QuestDB integration test pinning wall-clock TTL (far-future row evicts only partitions older
  than the TTL by the wall clock), with a positive control.
- Docs: Historian.md, Configuration.md, security.md, CLAUDE.md (incl. QuestDB retry duplicates,
  #674.4 anonymous-readable trail with ACL off, #674.5 not implemented).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-11 11:18:44 -04:00

81 lines
4.7 KiB
C#

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;
/// <summary>
/// Gitea #674.1 — pins the QuestDB TTL behaviour the historian's retention relies on: a table TTL is judged against
/// the wall clock (<c>min(max timestamp, now)</c>), so one far-future row — a 2066 typo that reached the backend
/// without going through the node manager's <c>HistoryUpdateMaxFutureSkew</c> bound — cannot age out every older
/// partition, including the Part 11 modification trail (the record of who changed history).
/// </summary>
/// <remarks>
/// <para>
/// <b>Positive control.</b> 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.
/// </para>
/// <para>
/// ⚠️ Observed on QuestDB 10.0.1 (2026-10-11): the in-TTL partitions also survived with
/// <c>cairo.ttl.use.wall.clock=false</c> (verified effective via <c>SHOW PARAMETERS</c>), so this test does not
/// isolate that flag — it pins the outcome. Keep the flag at its default (<c>true</c>) regardless; the server-side
/// future-skew bound is the primary guard.
/// </para>
/// </remarks>
[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);
/// <summary>The distinct UTC days holding rows, ascending.</summary>
private static async Task<List<DateTime>> 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<DateTime>();
while (await r.ReadAsync(Ct)) days.Add(QuestDbConnection.ReadUtc(r, 0).Date);
return days.ToList();
}
}