0658d244d0
- 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>
182 lines
9.3 KiB
C#
182 lines
9.3 KiB
C#
using Microsoft.Data.SqlClient;
|
|
using Microsoft.Extensions.Logging;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Gitea #674: a database left at schema version 1 (no Part 11 <c>SampleModified</c> trail) with
|
|
/// <c>AutoCreateSchema=false</c> keeps historizing. Before #674 the bootstrapper required v2 and refused EVERY call —
|
|
/// live writes, raw reads, alarm events, retention — until script 002 was applied by hand. Now only the features
|
|
/// that need the trail are refused (<c>BadHistoryOperationUnsupported</c>, nothing sent to SQL), and the
|
|
/// bootstrapper logs ONE warning for the whole provider. Built through <see cref="SqlServerHistorianProvider"/>, the
|
|
/// production wiring, so every seam shares the one bootstrapper the way a node does.
|
|
/// </summary>
|
|
[Collection(SqlHistorianCollection.Name)]
|
|
public sealed class SchemaV1PostureTests(SqlHistorianFixture fx)
|
|
{
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
private static readonly DateTime Recent = DateTime.UtcNow.AddHours(-1);
|
|
private static readonly HistoryUpdateContext Who = new("editor", "k1");
|
|
|
|
[Fact]
|
|
public async Task A_v1_schema_keeps_historizing_and_refuses_only_the_modified_trail_features()
|
|
{
|
|
fx.SkipIfUnavailable();
|
|
var s = SqlHistorianFixture.UniqueSchema();
|
|
await ApplyV1Async(s);
|
|
var old = DateTime.UtcNow.AddDays(-40);
|
|
var loggers = new CapturingLoggerFactory();
|
|
var provider = new SqlServerHistorianProvider(new SqlServerHistorianOptions
|
|
{
|
|
ConnectionString = fx.ConnectionString, Schema = s, AutoCreateSchema = false, RetentionDays = 30,
|
|
});
|
|
var sp = new LoggerServices(loggers);
|
|
|
|
// Live writes (the continuous-historization drain) and raw reads work.
|
|
var writer = provider.CreateValueWriter(sp);
|
|
(await writer.WriteLiveValuesAsync("Plant.Speed",
|
|
[new HistorizationValue(old, HistorianValue.FromDouble(1), 0), new HistorizationValue(Recent, HistorianValue.FromDouble(2), 0)],
|
|
Ct)).ShouldBeTrue();
|
|
var source = provider.CreateDataSource(sp);
|
|
var raw = await source.ReadRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, Ct);
|
|
raw.Samples.Select(v => v.Value).ShouldBe([2d]);
|
|
|
|
// Alarm events are written.
|
|
var alarms = provider.CreateAlarmWriter(sp);
|
|
(await alarms.WriteBatchAsync(
|
|
[new AlarmHistorianEvent("alarm-1", "Plant/Line/Eq", "HiTemp", "LimitAlarm", AlarmSeverity.High, "Activated", "m", "u", null, Recent)],
|
|
Ct)).ShouldBe([HistorianWriteOutcome.Ack]);
|
|
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Event")).ShouldBe(1);
|
|
|
|
// Modified reads: the interface's "no trail" signal (the node manager reports BadHistoryOperationUnsupported),
|
|
// not a backend failure — health stays clean.
|
|
await Should.ThrowAsync<NotSupportedException>(() =>
|
|
source.ReadModifiedAsync("Plant.Speed", Recent.AddDays(-1), Recent.AddDays(1), 10, null, Ct));
|
|
source.GetHealthSnapshot().LastError.ShouldBeNull();
|
|
|
|
// HistoryUpdate: every item refused, and NOTHING reaches SQL — a sent MERGE/DELETE would either change the
|
|
// samples or fail on the missing SampleModified target; an Insert on an unknown tag would create its Tag row.
|
|
var updater = provider.CreateUpdater(sp)!;
|
|
var tagsBefore = await CountAsync($"SELECT COUNT(*) FROM [{s}].Tag");
|
|
(await updater.UpdateDataAsync("Plant.Speed", HistoryUpdateMode.Update,
|
|
[new HistorizationValue(Recent, HistorianValue.FromDouble(99), 0), new HistorizationValue(Recent.AddSeconds(1), HistorianValue.FromDouble(98), 0)],
|
|
Who, Ct)).ShouldBe([HistorianStatusCodes.BadHistoryOperationUnsupported, HistorianStatusCodes.BadHistoryOperationUnsupported]);
|
|
(await updater.UpdateDataAsync("Plant.Brand.New", HistoryUpdateMode.Insert,
|
|
[new HistorizationValue(Recent, HistorianValue.FromDouble(1), 0)], Who, Ct))
|
|
.ShouldBe([HistorianStatusCodes.BadHistoryOperationUnsupported]);
|
|
(await updater.DeleteRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), Who, Ct))
|
|
.ShouldBe(HistorianStatusCodes.BadHistoryOperationUnsupported);
|
|
(await updater.DeleteAtTimeAsync("Plant.Speed", [Recent], Who, Ct))
|
|
.ShouldBe([HistorianStatusCodes.BadHistoryOperationUnsupported]);
|
|
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Tag")).ShouldBe(tagsBefore);
|
|
(await source.ReadRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, Ct))
|
|
.Samples.Select(v => v.Value).ShouldBe([2d]);
|
|
|
|
// Retention purges samples and skips the trail it does not have.
|
|
var purge = await provider.CreateRetention(sp)!.PurgeAsync(Ct);
|
|
purge.Select(r => r.Table).ShouldBe(["Sample", "Event"]);
|
|
purge.Single(r => r.Table == "Sample").Rows.ShouldBe(1);
|
|
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Sample")).ShouldBe(1);
|
|
|
|
// One warning for the whole provider (= per boot), however many seams and calls touched the bootstrapper.
|
|
loggers.Warnings.Count(w => w.Contains("Part 11 modification trail is unavailable")).ShouldBe(1);
|
|
(await CountAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(1); // verify-only: never upgraded
|
|
}
|
|
|
|
/// <summary>
|
|
/// #674: <c>AutoCreateSchema=true</c> still upgrades a v1 schema to v2 — the modification trail appears and a
|
|
/// HistoryUpdate is recorded in it — and logs no v1 warning.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Auto_create_still_upgrades_a_v1_schema_and_enables_the_trail()
|
|
{
|
|
fx.SkipIfUnavailable();
|
|
var s = SqlHistorianFixture.UniqueSchema();
|
|
await ApplyV1Async(s);
|
|
var loggers = new CapturingLoggerFactory();
|
|
var provider = new SqlServerHistorianProvider(new SqlServerHistorianOptions
|
|
{
|
|
ConnectionString = fx.ConnectionString, Schema = s, AutoCreateSchema = true,
|
|
});
|
|
var sp = new LoggerServices(loggers);
|
|
|
|
(await provider.CreateUpdater(sp)!.UpdateDataAsync("Plant.Speed", HistoryUpdateMode.Insert,
|
|
[new HistorizationValue(Recent, HistorianValue.FromDouble(5), 0)], Who, Ct))
|
|
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
|
|
|
(await CountAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.ModifiedTrailVersion);
|
|
var trail = await provider.CreateDataSource(sp)
|
|
.ReadModifiedAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, null, Ct);
|
|
trail.Values.ShouldHaveSingleItem().UpdateType.ShouldBe(HistoryModificationType.Insert);
|
|
loggers.Warnings.ShouldNotContain(w => w.Contains("Part 11 modification trail is unavailable"));
|
|
}
|
|
|
|
private async Task ApplyV1Async(string schema)
|
|
{
|
|
var v1 = new StreamReader(typeof(SqlServerSchemaBootstrapper).Assembly.GetManifestResourceStream("Schema.001_initial.sql")!).ReadToEnd();
|
|
await using var c = new SqlConnection(fx.ConnectionString);
|
|
await c.OpenAsync(Ct);
|
|
foreach (var b in SqlServerSchemaBootstrapper.SplitBatches(v1.Replace("$(Schema)", schema)))
|
|
{
|
|
await using var cmd = c.CreateCommand();
|
|
cmd.CommandText = b;
|
|
await cmd.ExecuteNonQueryAsync(Ct);
|
|
}
|
|
}
|
|
|
|
private async Task<long> CountAsync(string sql)
|
|
{
|
|
await using var c = new SqlConnection(fx.ConnectionString);
|
|
await c.OpenAsync(Ct);
|
|
await using var cmd = c.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
return Convert.ToInt64(await cmd.ExecuteScalarAsync(Ct));
|
|
}
|
|
|
|
/// <summary>The only service the provider resolves is <see cref="ILoggerFactory"/>.</summary>
|
|
private sealed class LoggerServices(ILoggerFactory loggers) : IServiceProvider
|
|
{
|
|
public object? GetService(Type serviceType) => serviceType == typeof(ILoggerFactory) ? loggers : null;
|
|
}
|
|
|
|
/// <summary>Captures every Warning-or-above message from every logger it creates.</summary>
|
|
private sealed class CapturingLoggerFactory : ILoggerFactory
|
|
{
|
|
private readonly List<string> _warnings = [];
|
|
|
|
public IReadOnlyList<string> Warnings
|
|
{
|
|
get { lock (_warnings) return _warnings.ToList(); }
|
|
}
|
|
|
|
public ILogger CreateLogger(string categoryName) => new Capturing(this);
|
|
|
|
public void AddProvider(ILoggerProvider provider)
|
|
{
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
|
|
private sealed class Capturing(CapturingLoggerFactory owner) : ILogger
|
|
{
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
if (logLevel < LogLevel.Warning) return;
|
|
lock (owner._warnings) owner._warnings.Add(formatter(state, exception));
|
|
}
|
|
}
|
|
}
|
|
}
|