Merge feat/668-modified-values: Part 11 modified-value history (IsReadModified) on SqlServer + QuestDB (#668)

This commit is contained in:
Joseph Doherty
2026-10-11 09:32:13 -04:00
51 changed files with 2173 additions and 195 deletions
@@ -72,6 +72,50 @@ public sealed class HistoryUpdateOverTheWireTests
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// Part 11 modified-value history over the wire (#668): two inserts and a delete by the key principal, then a
/// HistoryReadRaw with <c>IsReadModified = true</c> decodes as a <see cref="HistoryModifiedData"/> whose
/// <c>ModificationInfos</c> carry the update type and the principal, and whose values carry the PRIOR value.
/// </summary>
[Fact]
public async Task A_modified_read_returns_the_trail_with_principal_and_update_type_over_the_wire()
{
var ct = TestContext.Current.CancellationToken;
var store = new SharedStore();
var rig = await StartAsync("HuWireModified", encrypted: false, ct);
try
{
rig.Nm.HistorianDataUpdater = store;
rig.Nm.HistorianDataSource = store;
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor));
var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var node = RawNode(session, id);
await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.5), (T2, 2.5));
await DeleteAtTimeAsync(session, node, ct, T1);
var details = new ReadRawModifiedDetails
{
StartTime = T1.AddHours(-1), EndTime = T1.AddHours(1), NumValuesPerNode = 100, IsReadModified = true,
};
var response = await session.HistoryReadAsync(
null, new ExtensionObject(details), TimestampsToReturn.Both, false,
new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }, ct);
var result = response.Results.ShouldHaveSingleItem();
result.StatusCode.Code.ShouldBe(StatusCodes.Good);
var data = ExtensionObject.ToEncodeable(result.HistoryData).ShouldBeOfType<HistoryModifiedData>();
data.DataValues.Select(v => v.SourceTimestamp).ShouldBe([T1, T1, T2]);
data.ModificationInfos.Select(m => m.UpdateType)
.ShouldBe([HistoryUpdateType.Insert, HistoryUpdateType.Delete, HistoryUpdateType.Insert]);
data.ModificationInfos.ShouldAllBe(m => m.UserName == KeyPrincipal);
data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData); // an insert had no prior value
((double)data.DataValues[1].Value).ShouldBe(1.5); // the deleted sample's prior value
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// ALLOW beside DENY: the same server, same node — the editor session is admitted, the session
/// without the HistoryUpdate grant gets BadUserAccessDenied and the updater is never called.
@@ -263,14 +307,21 @@ public sealed class HistoryUpdateOverTheWireTests
{
private readonly object _gate = new();
private readonly SortedDictionary<DateTime, double> _samples = [];
private readonly List<HistoryModifiedValue> _trail = [];
private int _updateCalls;
private void Record(DateTime t, double? prior, HistoryModificationType type, HistoryUpdateContext who) =>
_trail.Add(new HistoryModifiedValue(
new DataValueSnapshot(prior, prior is null ? StatusCodes.BadNoData : StatusCodes.Good, t, t),
DateTime.UtcNow, type, who.Principal, _trail.Count + 1));
public int UpdateCalls => Volatile.Read(ref _updateCalls);
public int Count { get { lock (_gate) return _samples.Count; } }
public async Task<IReadOnlyList<uint>> UpdateDataAsync(
string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, CancellationToken ct)
string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context,
CancellationToken ct)
{
Interlocked.Increment(ref _updateCalls);
await Task.Delay(1, ct).ConfigureAwait(false);
@@ -285,21 +336,43 @@ public sealed class HistoryUpdateOverTheWireTests
if (_samples.ContainsKey(t)) { codes.Add(StatusCodes.BadEntryExists); continue; }
v.Value.TryGetDouble(out var d).ShouldBeTrue();
_samples[t] = d;
Record(t, null, HistoryModificationType.Insert, context);
codes.Add(StatusCodes.GoodEntryInserted);
}
}
return codes;
}
public Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
public Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context,
CancellationToken ct) =>
throw new NotSupportedException();
public async Task<IReadOnlyList<uint>> DeleteAtTimeAsync(
string tag, IReadOnlyList<DateTime> timesUtc, CancellationToken ct)
string tag, IReadOnlyList<DateTime> timesUtc, HistoryUpdateContext context, CancellationToken ct)
{
await Task.Delay(1, ct).ConfigureAwait(false);
lock (_gate)
return timesUtc.Select(t => _samples.Remove(t) ? StatusCodes.Good : StatusCodes.BadNoEntryExists).ToList();
return timesUtc.Select(t =>
{
if (!_samples.Remove(t, out var prior)) return StatusCodes.BadNoEntryExists;
Record(t, prior, HistoryModificationType.Delete, context);
return StatusCodes.Good;
}).ToList();
}
public async Task<HistoryModifiedReadResult> ReadModifiedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValues, HistoryModifiedCursor? resumeAfter,
CancellationToken cancellationToken)
{
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
fullReference.ShouldBe(Tagname);
lock (_gate)
{
var rows = _trail
.Where(m => m.Value.SourceTimestampUtc >= startUtc && m.Value.SourceTimestampUtc < endUtc)
.OrderBy(m => m.Value.SourceTimestampUtc).ThenBy(m => m.ModifiedId).ToList();
return new HistoryModifiedReadResult(rows, false);
}
}
public async Task<CoreHistoryReadResult> ReadRawAsync(