refactor(historian): one QuestDbTimeClusters helper for writer + updater existence reads; redelivery tombstone test
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+38
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
|
||||
public sealed partial class TombstoneTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The live writer's outbox redelivers batches (at-least-once). A batch replayed after an operator's
|
||||
/// DeleteAtTime must not bring the deleted sample back: the writer's existence check counts a tombstone as
|
||||
/// present (it deliberately does not filter <c>NOT deleted</c>), so the redelivered row is skipped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Tombstone_is_not_resurrected_by_a_writer_redelivery()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var rig = UpdateTests.MakeRig(fx);
|
||||
var writer = new QuestDbHistorianValueWriter(rig.Conn, rig.Tags,
|
||||
new QuestDbWalVisibility(rig.Conn, TimeSpan.FromSeconds(5)));
|
||||
HistorizationValue[] batch =
|
||||
[
|
||||
new(T0, HistorianValue.FromDouble(1), HistorianStatusCodes.Good),
|
||||
new(T0.AddSeconds(1), HistorianValue.FromDouble(2), HistorianStatusCodes.Good),
|
||||
];
|
||||
|
||||
(await writer.WriteLiveValuesAsync("r", batch, default)).ShouldBeTrue();
|
||||
(await rig.Updater.DeleteAtTimeAsync("r", [T0], 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]);
|
||||
var all = await UpdateTests.RowsAsync(rig, "r");
|
||||
all.Count.ShouldBe(2);
|
||||
all[0].Deleted.ShouldBeTrue();
|
||||
all[0].Num.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
|
||||
|
||||
/// <summary>Soft-delete behaviour. The writer-redelivery case lives in T7 (<c>TombstoneTests.Redelivery.cs</c>).</summary>
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class TombstoneTests(QuestDbFixture fx)
|
||||
public sealed partial class TombstoneTests(QuestDbFixture fx)
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
|
||||
-10
@@ -249,16 +249,6 @@ public sealed class UpdateTests(QuestDbFixture fx)
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_splits_where_the_gap_exceeds_the_limit()
|
||||
{
|
||||
var gap = QuestDbHistorianDataUpdater.ExistenceClusterGap;
|
||||
var c = QuestDbHistorianDataUpdater.Cluster(
|
||||
[T0.AddHours(2), T0, T0.AddSeconds(30), T0.AddHours(1)], gap);
|
||||
c.ShouldBe([(T0, T0.AddSeconds(30)), (T0.AddHours(1), T0.AddHours(1)), (T0.AddHours(2), T0.AddHours(2))]);
|
||||
QuestDbHistorianDataUpdater.Cluster([], gap).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Existence_reads_are_bounded_when_request_times_are_far_apart()
|
||||
{
|
||||
|
||||
-19
@@ -274,23 +274,4 @@ public sealed class WriteTests(QuestDbFixture fx)
|
||||
(await w.WriteLiveValuesAsync("tag.far", vals, CancellationToken.None)).ShouldBeTrue();
|
||||
(await RowsAsync(c, "tag.far")).Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_splits_on_a_gap_over_the_limit_and_at_the_per_cluster_cap()
|
||||
{
|
||||
var g = TimeSpan.FromMinutes(1);
|
||||
QuestDbHistorianValueWriter.Cluster([], g, 10).ShouldBeEmpty();
|
||||
QuestDbHistorianValueWriter.Cluster([T0], g, 10).ShouldBe([(T0, T0)]);
|
||||
// exactly the gap stays together; one tick over splits
|
||||
QuestDbHistorianValueWriter.Cluster([T0, T0 + g], g, 10).ShouldBe([(T0, T0 + g)]);
|
||||
QuestDbHistorianValueWriter.Cluster([T0, T0 + g + TimeSpan.FromTicks(10)], g, 10)
|
||||
.ShouldBe([(T0, T0), (T0 + g + TimeSpan.FromTicks(10), T0 + g + TimeSpan.FromTicks(10))]);
|
||||
// outlier gets its own range
|
||||
var far = T0.AddYears(1);
|
||||
QuestDbHistorianValueWriter.Cluster([T0, T0.AddSeconds(1), far], g, 10).ShouldBe([(T0, T0.AddSeconds(1)), (far, far)]);
|
||||
// cap: 5 close times, max 2 per cluster
|
||||
var five = Enumerable.Range(0, 5).Select(i => T0.AddSeconds(i)).ToList();
|
||||
QuestDbHistorianValueWriter.Cluster(five, g, 2)
|
||||
.ShouldBe([(five[0], five[1]), (five[2], five[3]), (five[4], five[4])]);
|
||||
}
|
||||
}
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The shared existence-query clustering (moved here, names unchanged, from the writer's <c>WriteTests</c> and the
|
||||
/// updater's <c>UpdateTests</c> when the two private copies were consolidated into <see cref="QuestDbTimeClusters"/>).
|
||||
/// </summary>
|
||||
public sealed class QuestDbTimeClustersTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Cluster_splits_on_a_gap_over_the_limit_and_at_the_per_cluster_cap()
|
||||
{
|
||||
var g = TimeSpan.FromMinutes(1);
|
||||
QuestDbTimeClusters.Cluster([], g, 10).ShouldBeEmpty();
|
||||
QuestDbTimeClusters.Cluster([T0], g, 10).ShouldBe([(T0, T0)]);
|
||||
// exactly the gap stays together; one tick over splits
|
||||
QuestDbTimeClusters.Cluster([T0, T0 + g], g, 10).ShouldBe([(T0, T0 + g)]);
|
||||
QuestDbTimeClusters.Cluster([T0, T0 + g + TimeSpan.FromTicks(10)], g, 10)
|
||||
.ShouldBe([(T0, T0), (T0 + g + TimeSpan.FromTicks(10), T0 + g + TimeSpan.FromTicks(10))]);
|
||||
// outlier gets its own range
|
||||
var far = T0.AddYears(1);
|
||||
QuestDbTimeClusters.Cluster([T0, T0.AddSeconds(1), far], g, 10).ShouldBe([(T0, T0.AddSeconds(1)), (far, far)]);
|
||||
// cap: 5 close times, max 2 per cluster
|
||||
var five = Enumerable.Range(0, 5).Select(i => T0.AddSeconds(i)).ToList();
|
||||
QuestDbTimeClusters.Cluster(five, g, 2)
|
||||
.ShouldBe([(five[0], five[1]), (five[2], five[3]), (five[4], five[4])]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_splits_where_the_gap_exceeds_the_limit()
|
||||
{
|
||||
var gap = QuestDbTimeClusters.ExistenceGap;
|
||||
var c = QuestDbTimeClusters.Cluster(
|
||||
[T0.AddHours(2), T0, T0.AddSeconds(30), T0.AddHours(1)], gap);
|
||||
c.ShouldBe([(T0, T0.AddSeconds(30)), (T0.AddHours(1), T0.AddHours(1)), (T0.AddHours(2), T0.AddHours(2))]);
|
||||
QuestDbTimeClusters.Cluster([], gap).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_existence_gap_is_one_minute_and_the_default_cap_is_unbounded()
|
||||
{
|
||||
QuestDbTimeClusters.ExistenceGap.ShouldBe(TimeSpan.FromMinutes(1));
|
||||
var many = Enumerable.Range(0, 5000).Select(i => T0.AddMilliseconds(i)).ToList();
|
||||
QuestDbTimeClusters.Cluster(many, QuestDbTimeClusters.ExistenceGap).ShouldBe([(many[0], many[^1])]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unsorted_input_is_sorted_before_capping()
|
||||
{
|
||||
var times = new[] { T0.AddSeconds(3), T0, T0.AddSeconds(2), T0.AddSeconds(1) };
|
||||
QuestDbTimeClusters.Cluster(times, TimeSpan.FromMinutes(1), 2)
|
||||
.ShouldBe([(T0, T0.AddSeconds(1)), (T0.AddSeconds(2), T0.AddSeconds(3))]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_cap_below_one_is_rejected() =>
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => QuestDbTimeClusters.Cluster([T0], TimeSpan.FromMinutes(1), 0));
|
||||
}
|
||||
Reference in New Issue
Block a user