0658d244d0
- 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>
81 lines
4.7 KiB
C#
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();
|
|
}
|
|
}
|