feat(historian): #674 HistoryUpdate posture — future-skew bound, Primary gate, SqlServer v1 accepted, QuestDB TTL pin

- 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>
This commit is contained in:
Joseph Doherty
2026-10-11 11:18:44 -04:00
parent 825393299e
commit 0658d244d0
25 changed files with 802 additions and 55 deletions
@@ -11,7 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
/// <c>DELETE TOP(n)</c> batches (under the lock-escalation threshold). Samples are purged per tag so each
/// delete is a seek on the clustered key <c>(TagId, SourceTimeUtc)</c>; events seek on <c>CX_Event_Time</c>.
/// A session <c>sp_getapplock</c> (timeout 0) serialises purgers that share one database; a loser skips.
/// To purge another table later, add it to <see cref="PurgeAsync"/>.
/// To purge another table later, add it to <see cref="PurgeAsync"/>. On a v1 schema (no Part 11 trail, Gitea #674)
/// the <c>SampleModified</c> purge is skipped and absent from the results.
/// </summary>
internal sealed class SqlServerHistorianRetention : IHistorianRetention
{
@@ -69,6 +70,8 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
return [];
}
// #674: a v1 schema has no SampleModified table — skip its purge rather than fail the whole pass.
var trail = _bootstrap.SupportsModifiedTrail;
long samples = 0, modified = 0;
var tagIds = new List<int>();
await using (var cmd = _conn.CreateCommand(conn, $"SELECT TagId FROM {_conn.Q("Tag")}"))
@@ -82,21 +85,30 @@ internal sealed class SqlServerHistorianRetention : IHistorianRetention
cutoff, id, ct).ConfigureAwait(false);
// The Part 11 modification trail (#668) ages out on the SAME source-time cutoff as the samples it
// describes; a purge never writes to it (no trigger, no OUTPUT INTO).
modified += await DeleteLoopAsync(conn,
$"DELETE TOP (@n) FROM {_conn.Q("SampleModified")} WHERE TagId = @id AND SourceTimeUtc < @cutoff",
cutoff, id, ct).ConfigureAwait(false);
if (trail)
{
modified += await DeleteLoopAsync(conn,
$"DELETE TOP (@n) FROM {_conn.Q("SampleModified")} WHERE TagId = @id AND SourceTimeUtc < @cutoff",
cutoff, id, ct).ConfigureAwait(false);
}
}
var events = await DeleteLoopAsync(conn,
$"DELETE TOP (@n) FROM {_conn.Q("Event")} WHERE EventTimeUtc < @cutoff",
cutoff, null, ct).ConfigureAwait(false);
return
[
new RetentionPurgeResult("Sample", samples),
new RetentionPurgeResult("SampleModified", modified),
new RetentionPurgeResult("Event", events),
];
return trail
?
[
new RetentionPurgeResult("Sample", samples),
new RetentionPurgeResult("SampleModified", modified),
new RetentionPurgeResult("Event", events),
]
:
[
new RetentionPurgeResult("Sample", samples),
new RetentionPurgeResult("Event", events),
];
}
finally
{