feat(historian): Part 11 modified-value history on SqlServer + QuestDB (#668)

Every HistoryUpdate a SQL backend applies now leaves a modification trail
(prior value, principal, Part 11 update type, modification time), and
HistoryReadRaw with IsReadModified=true serves it as HistoryModifiedData.

- Core: HistoryUpdateContext threaded into all three IHistorianDataUpdater
  methods; IHistorianDataSource.ReadModifiedAsync (default NotSupported =>
  BadHistoryOperationUnsupported on Gateway/Null); HistoryModified* contracts.
- SqlServer: schema v2 (002_sample_modified.sql, RequiredVersion=2, docs copy);
  trail written by OUTPUT ... INTO on the same MERGE/DELETE statement, so a
  failed update leaves no audit row; retention purges SampleModified per tag on
  the source-time cutoff; ReadModifiedAsync keyed on (SourceTimeUtc, ModifiedId).
- QuestDB: append-only {prefix}sample_modified (TTL like samples), trail row
  written BEFORE the change (orphan-on-crash, never an untracked change);
  existence reads now carry the prior value; ReadModifiedAsync.
- Node manager: modified reads dispatch + page via the #669 continuation store
  (HistoryContinuationState gains HistoryCursorKind + LastModifiedId), gated by
  the existing HistoryRead decision; session principal / key id passed to the
  updater. IsDeleteModified stays unsupported.
- Client.CLI: historyread --modified.
- Tests: node-manager dispatch/paging/cursor binding/principal threading,
  over-the-wire HistoryModifiedData, shared ModifiedValueConformance on both
  backends, SqlServer atomicity (forced trail failure => no change) + v1->v2
  upgrade + retention trail purge.
- Docs: Historian.md (Modified-value history section, schema v2 upgrade note),
  security.md, Client.CLI.md, CLAUDE.md.
This commit is contained in:
Joseph Doherty
2026-10-11 09:26:36 -04:00
parent d803e0eab4
commit cfd403710e
51 changed files with 2173 additions and 195 deletions
@@ -137,6 +137,101 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
}
}
// ------------------------------------------------------------------------------------------ Modified (Part 11, #668)
/// <inheritdoc />
/// <remarks>
/// Reads <c>{prefix}sample_modified</c>, the append-only trail <see cref="QuestDbHistorianDataUpdater"/> writes
/// BEFORE each change. Windows follow <see cref="ReadRawAsync"/> (bounds rounded to µs); rows are ordered by
/// <c>(ts, modified_id)</c>; <c>maxValues + 1</c> rows are fetched to report
/// <see cref="HistoryModifiedReadResult.HasMore"/> exactly. A row whose <c>status</c> is NULL had no prior value
/// (an insert) and is served <c>null</c> / <c>BadNoData</c>. Because the trail row lands first, a crash between it and
/// the sample write leaves an orphan row describing a change that never applied (documented).
/// </remarks>
public async Task<HistoryModifiedReadResult> ReadModifiedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValues, HistoryModifiedCursor? resumeAfter,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReference);
var bounded = maxValues > 0 && maxValues < int.MaxValue;
try
{
var values = new List<HistoryModifiedValue>();
var hasMore = false;
if (await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false))
{
var start = QuestDbConnection.Round(startUtc);
var end = QuestDbConnection.Round(endUtc);
var reverse = start > end;
string where;
if (start == end) where = "ts = $2 AND ts = $3";
else if (!reverse) where = "ts >= $2 AND ts < $3";
else where = "ts > $2 AND ts <= $3";
var next = 4;
if (resumeAfter is not null)
{
where += reverse
? " AND (ts < $4 OR (ts = $4 AND modified_id < $5))"
: " AND (ts > $4 OR (ts = $4 AND modified_id > $5))";
next = 6;
}
var order = reverse ? "DESC" : "ASC";
var (lo, hi) = start <= end ? (start, end) : (end, start);
await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = _conn.CreateCommand(conn,
$"SELECT {SampleColumns}, modified_id, modification_ts, update_type, principal FROM {_conn.SampleModifiedTable} " +
$"WHERE tag = $1 AND {where} ORDER BY ts {order}, modified_id {order}" + (bounded ? $" LIMIT ${next}" : ""));
QuestDbConnection.BindText(cmd, fullReference);
QuestDbConnection.BindTimestamp(cmd, lo);
QuestDbConnection.BindTimestamp(cmd, hi);
if (resumeAfter is not null)
{
QuestDbConnection.BindTimestamp(cmd, resumeAfter.SourceTimeUtc);
QuestDbConnection.BindLong(cmd, resumeAfter.ModifiedId);
}
if (bounded)
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Integer, Value = (int)maxValues + 1 });
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (bounded && values.Count == maxValues)
{
hasMore = true;
break;
}
var sourceTime = QuestDbConnection.ReadUtc(reader, 0);
var modifiedAt = QuestDbConnection.ReadUtc(reader, 8);
DataValueSnapshot prior;
if (reader.IsDBNull(5))
{
prior = new DataValueSnapshot(null, HistorianStatusCodes.BadNoData, sourceTime, modifiedAt);
}
else
{
var s = ReadStored(reader, 0);
prior = new DataValueSnapshot(s.Value.ToObject(), s.Status, s.TimeUtc, ReadServerTime(reader, 6, modifiedAt));
}
values.Add(new HistoryModifiedValue(prior, modifiedAt,
(HistoryModificationType)QuestDbConnection.ReadKind(reader, 9),
reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
reader.GetInt64(7)));
}
}
RecordSuccess();
return new HistoryModifiedReadResult(values, hasMore);
}
catch (Exception ex)
{
RecordFailure(ex);
throw;
}
}
// ------------------------------------------------------------------------------------------ AtTime
/// <inheritdoc />