fix(historian): #668 review follow-ups (explicit modified source time, paging tests, ACL test, QuestDB orphan warning, docs)
- HistoryModifiedValue gains explicit SourceTimeUtc (SqlServer + QuestDB populate it); the node manager's resume cursor uses it instead of the prior value's nullable source timestamp (MinValue for inserts). - ACL test now uses a trail-returning data source and asserts Good + data on the allow leg. - Node-manager tests: end-only backward open-ended paging, reverse resume cursor, null-prior cursor. - QuestDB updater logs a Warning (tag + time range) when a sample write fails after its trail rows landed. - docs/Historian.md: rolling-upgrade window, IDENTITY-vs-commit ordering, QuestDB cross-node ModifiedId clock ordering.
This commit is contained in:
+54
-4
@@ -105,6 +105,56 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_end_only_modified_read_pages_backward_from_the_end()
|
||||
{
|
||||
var trail = Enumerable.Range(1, 9).Select(i => Mod(i, seconds: i, prior: i, HistoryModificationType.Update, "u", Epoch)).ToList();
|
||||
var (host, nm, node, src) = await SetupAsync(trail);
|
||||
|
||||
// StartTime unset + EndTime set + a cap = "the newest N, walking backward".
|
||||
Drain(nm, node, DateTime.MinValue, Epoch.AddHours(1), max: 4).ShouldBe(src.Ordered(reverse: true).Select(m => m.ModifiedId));
|
||||
src.LastStart.ShouldBe(Epoch.AddHours(1));
|
||||
src.LastEnd.ShouldBe(new DateTime(0, DateTimeKind.Utc));
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reverse_resume_hands_the_backend_the_last_returned_source_time_and_id()
|
||||
{
|
||||
// Ids 3 and 4 share a source time; page 1 (reverse, cap 3) ends on id 4, then resumes mid-run.
|
||||
var trail = new List<HistoryModifiedValue>
|
||||
{
|
||||
Mod(1, 1, 1, HistoryModificationType.Update, "u", Epoch), Mod(2, 2, 2, HistoryModificationType.Update, "u", Epoch),
|
||||
Mod(3, 3, 3, HistoryModificationType.Update, "u", Epoch), Mod(4, 3, 4, HistoryModificationType.Update, "u", Epoch),
|
||||
Mod(5, 5, 5, HistoryModificationType.Update, "u", Epoch), Mod(6, 6, 6, HistoryModificationType.Update, "u", Epoch),
|
||||
};
|
||||
var (host, nm, node, src) = await SetupAsync(trail);
|
||||
|
||||
var p1 = Read(nm, node, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 3, cp: null);
|
||||
p1.Ids.ShouldBe([6, 5, 4]);
|
||||
p1.Cp.ShouldNotBeNull();
|
||||
var p2 = Read(nm, node, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 3, cp: p1.Cp);
|
||||
src.LastCursor.ShouldBe(new HistoryModifiedCursor(Epoch.AddSeconds(3), 4));
|
||||
p2.Ids.ShouldBe([3, 2, 1]);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_resume_cursor_uses_the_explicit_source_time_even_when_the_prior_value_carries_none()
|
||||
{
|
||||
// Inserts have no prior value; their snapshot has no source timestamp. The cursor must not fall back to MinValue.
|
||||
var trail = Enumerable.Range(1, 6).Select(i => new HistoryModifiedValue(
|
||||
new DataValueSnapshot(null, StatusCodes.BadNoData, null, Epoch), Epoch, HistoryModificationType.Insert, "u", i,
|
||||
Epoch.AddSeconds(i))).ToList();
|
||||
var (host, nm, node, src) = await SetupAsync(trail);
|
||||
|
||||
var p1 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 3, cp: null);
|
||||
p1.Cp.ShouldNotBeNull();
|
||||
Read(nm, node, Epoch, Epoch.AddHours(1), max: 3, cp: p1.Cp);
|
||||
src.LastCursor.ShouldBe(new HistoryModifiedCursor(Epoch.AddSeconds(3), 3));
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_open_ended_forward_modified_read_pages_to_the_end()
|
||||
{
|
||||
@@ -172,7 +222,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
var value = prior is null
|
||||
? new DataValueSnapshot(null, StatusCodes.BadNoData, t, modifiedAt)
|
||||
: new DataValueSnapshot(prior.Value, StatusCodes.Good, t, t);
|
||||
return new HistoryModifiedValue(value, modifiedAt, type, user, id);
|
||||
return new HistoryModifiedValue(value, modifiedAt, type, user, id, t);
|
||||
}
|
||||
|
||||
private static List<long> Drain(OtOpcUaNodeManager nm, NodeId node, DateTime start, DateTime end, uint max)
|
||||
@@ -258,8 +308,8 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
public static long IdOf(DataValue v) => v.ServerTimestamp.Ticks - Epoch.Ticks;
|
||||
|
||||
public IEnumerable<HistoryModifiedValue> Ordered(bool reverse) => reverse
|
||||
? trail.OrderByDescending(m => m.Value.SourceTimestampUtc).ThenByDescending(m => m.ModifiedId)
|
||||
: trail.OrderBy(m => m.Value.SourceTimestampUtc).ThenBy(m => m.ModifiedId);
|
||||
? trail.OrderByDescending(m => m.SourceTimeUtc).ThenByDescending(m => m.ModifiedId)
|
||||
: trail.OrderBy(m => m.SourceTimeUtc).ThenBy(m => m.ModifiedId);
|
||||
|
||||
public async Task<HistoryModifiedReadResult> ReadModifiedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValues, HistoryModifiedCursor? resumeAfter,
|
||||
@@ -273,7 +323,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
var (lo, hi) = reverse ? (endUtc, startUtc) : (startUtc, endUtc);
|
||||
var rows = Ordered(reverse).Where(m =>
|
||||
{
|
||||
var t = m.Value.SourceTimestampUtc!.Value;
|
||||
var t = m.SourceTimeUtc;
|
||||
var inWindow = startUtc == endUtc ? t == lo : reverse ? t > lo && t <= hi : t >= lo && t < hi;
|
||||
if (!inWindow) return false;
|
||||
if (resumeAfter is null) return true;
|
||||
|
||||
Reference in New Issue
Block a user