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>
381 lines
19 KiB
C#
381 lines
19 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Server;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
|
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #668 — OPC UA Part 11 modified-value history: HistoryReadRaw with <c>IsReadModified = true</c> dispatches
|
|
/// to <see cref="IHistorianDataSource.ReadModifiedAsync"/>, projects a <see cref="HistoryModifiedData"/> with one
|
|
/// <see cref="ModificationInfo"/> per value, and pages with continuation points keyed on
|
|
/// <c>(SourceTimeUtc, ModifiedId)</c>. Same harness as <see cref="NodeManagerHistoryReadEventsPagingTests"/>: a real
|
|
/// SDK server, a session-less in-process HistoryRead and an <see cref="InMemoryHistoryContinuationStore"/>, over a
|
|
/// fake that behaves like the SQL backends (ordered by key, half-open window, cap + exact truncation flag).
|
|
/// </summary>
|
|
public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
|
{
|
|
private const string Tag = "WW.Mod";
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
private static readonly DateTime Epoch = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-modified-{Guid.NewGuid():N}");
|
|
|
|
[Fact]
|
|
public async Task A_modified_read_dispatches_to_the_trail_and_projects_modification_infos()
|
|
{
|
|
var modifiedAt = Epoch.AddDays(2);
|
|
var trail = new List<HistoryModifiedValue>
|
|
{
|
|
Mod(1, seconds: 10, prior: null, HistoryModificationType.Insert, "alice", modifiedAt),
|
|
Mod(2, seconds: 10, prior: 1.5, HistoryModificationType.Replace, "bob", modifiedAt.AddMinutes(1)),
|
|
Mod(3, seconds: 20, prior: 7.0, HistoryModificationType.Delete, "carol", modifiedAt.AddMinutes(2)),
|
|
};
|
|
var (host, nm, node, src) = await SetupAsync(trail);
|
|
|
|
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 100, cp: null);
|
|
|
|
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
r.Result.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
r.Cp.ShouldBeNull();
|
|
src.LastTag.ShouldBe(Tag);
|
|
src.LastStart.ShouldBe(Epoch);
|
|
src.LastEnd.ShouldBe(Epoch.AddHours(1));
|
|
src.LastMax.ShouldBe(100u);
|
|
src.LastCursor.ShouldBeNull();
|
|
|
|
var data = r.Data!;
|
|
data.DataValues.Count.ShouldBe(3);
|
|
data.ModificationInfos.Count.ShouldBe(3);
|
|
data.ModificationInfos.Select(m => m.UpdateType)
|
|
.ShouldBe([HistoryUpdateType.Insert, HistoryUpdateType.Replace, HistoryUpdateType.Delete]);
|
|
data.ModificationInfos.Select(m => m.UserName).ShouldBe(["alice", "bob", "carol"]);
|
|
data.ModificationInfos.Select(m => m.ModificationTime)
|
|
.ShouldBe([modifiedAt, modifiedAt.AddMinutes(1), modifiedAt.AddMinutes(2)]);
|
|
data.DataValues[0].Value.ShouldBeNull();
|
|
data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData);
|
|
data.DataValues[1].Value.ShouldBe(1.5);
|
|
data.DataValues[1].SourceTimestamp.ShouldBe(Epoch.AddSeconds(10));
|
|
data.DataValues[2].Value.ShouldBe(7.0);
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_empty_trail_is_GoodNoData()
|
|
{
|
|
var (host, nm, node, _) = await SetupAsync([]);
|
|
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null);
|
|
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
r.Result.StatusCode.Code.ShouldBe(StatusCodes.GoodNoData);
|
|
r.Data!.ModificationInfos.ShouldBeEmpty();
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Modified_reads_page_exactly_once_in_order_even_through_same_time_runs()
|
|
{
|
|
// 30 modifications; ids 5..9 all share one source time, so a page boundary cuts through the run.
|
|
var trail = Enumerable.Range(1, 30)
|
|
.Select(i => Mod(i, seconds: i is >= 5 and <= 9 ? 5 : i, prior: i, HistoryModificationType.Replace, "u", Epoch))
|
|
.ToList();
|
|
var (host, nm, node, src) = await SetupAsync(trail);
|
|
|
|
var ids = Drain(nm, node, Epoch, Epoch.AddHours(1), max: 4);
|
|
|
|
ids.ShouldBe(src.Ordered(reverse: false).Select(m => m.ModifiedId));
|
|
ids.Count.ShouldBe(30);
|
|
src.Calls.ShouldBe(8); // 7 full pages + a 2-value tail; the exact HasMore flag means no trailing empty page
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_reverse_modified_read_pages_descending()
|
|
{
|
|
var trail = Enumerable.Range(1, 11).Select(i => Mod(i, seconds: i / 2, prior: i, HistoryModificationType.Delete, "u", Epoch)).ToList();
|
|
var (host, nm, node, src) = await SetupAsync(trail);
|
|
|
|
var ids = Drain(nm, node, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 3);
|
|
|
|
ids.ShouldBe(src.Ordered(reverse: true).Select(m => m.ModifiedId));
|
|
ids.First().ShouldBe(11);
|
|
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()
|
|
{
|
|
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);
|
|
|
|
Drain(nm, node, Epoch, DateTime.MinValue, max: 4).ShouldBe(src.Ordered(reverse: false).Select(m => m.ModifiedId));
|
|
src.LastEnd.ShouldBe(new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc));
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_unbounded_modified_read_never_pages()
|
|
{
|
|
var trail = Enumerable.Range(1, 50).Select(i => Mod(i, seconds: i, prior: i, HistoryModificationType.Update, "u", Epoch)).ToList();
|
|
var (host, nm, node, _) = await SetupAsync(trail);
|
|
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 0, cp: null);
|
|
r.Data!.DataValues.Count.ShouldBe(50);
|
|
r.Cp.ShouldBeNull();
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cursors_are_bound_to_the_read_kind_and_the_node()
|
|
{
|
|
var trail = Enumerable.Range(1, 10).Select(i => Mod(i, seconds: i, prior: i, HistoryModificationType.Update, "u", Epoch)).ToList();
|
|
var (host, nm, node, _) = await SetupAsync(trail);
|
|
|
|
// A RAW cursor for the same tag is not a modified cursor.
|
|
var raw = nm.HistoryContinuationStore.Save(null, new HistoryContinuationState(Tag, Epoch, Epoch.AddDays(1), 0, 4));
|
|
Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: raw).Error.StatusCode.Code
|
|
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
|
|
|
// A MODIFIED cursor presented to a raw read is invalid too.
|
|
var p1 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
|
|
p1.Cp.ShouldNotBeNull();
|
|
Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: p1.Cp, modified: false).Error.StatusCode.Code
|
|
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
|
|
|
// A modified cursor issued for one node is invalid on another node's tag.
|
|
var p2 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
|
|
nm.EnsureVariable("eq-1/other", null, "Other", "Double", false, historianTagname: "WW.Other", realm: AddressSpaceRealm.Uns);
|
|
var other = nm.TryGetVariable("eq-1/other")!.NodeId;
|
|
Read(nm, other, Epoch, Epoch.AddHours(1), max: 4, cp: p2.Cp).Error.StatusCode.Code
|
|
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// #674: the HistoryUpdate Primary gate does not reach HistoryRead — a closed gate neither refuses a read nor is
|
|
/// consulted by one (reads are served from any redundancy node), for modified and raw reads alike.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task The_history_update_primary_gate_is_not_consulted_for_reads()
|
|
{
|
|
var (host, nm, node, src) = await SetupAsync([Mod(1, 1, 1, HistoryModificationType.Update, "u", Epoch)]);
|
|
var consulted = 0;
|
|
nm.HistoryUpdatePrimaryGate = () => { Interlocked.Increment(ref consulted); return false; };
|
|
|
|
var modified = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null);
|
|
var raw = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null, modified: false);
|
|
|
|
modified.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
|
modified.Data!.DataValues.Count.ShouldBe(1);
|
|
raw.Error.StatusCode.Code.ShouldNotBe(StatusCodes.BadInvalidState);
|
|
src.Calls.ShouldBe(1);
|
|
consulted.ShouldBe(0);
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_backend_failure_is_isolated_to_its_node()
|
|
{
|
|
var (host, nm, node, src) = await SetupAsync([Mod(1, 1, 1, HistoryModificationType.Update, "u", Epoch)]);
|
|
src.Throw = new InvalidOperationException("boom");
|
|
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
|
|
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
|
await host.DisposeAsync();
|
|
}
|
|
|
|
// --- helpers ---------------------------------------------------------------------------------
|
|
|
|
private static HistoryModifiedValue Mod(
|
|
long id, int seconds, double? prior, HistoryModificationType type, string user, DateTime modifiedAt)
|
|
{
|
|
var t = Epoch.AddSeconds(seconds);
|
|
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, t);
|
|
}
|
|
|
|
private static List<long> Drain(OtOpcUaNodeManager nm, NodeId node, DateTime start, DateTime end, uint max)
|
|
{
|
|
var ids = new List<long>();
|
|
byte[]? cp = null;
|
|
var pages = 0;
|
|
do
|
|
{
|
|
var r = Read(nm, node, start, end, max, cp);
|
|
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, r.Error.ToString());
|
|
ids.AddRange(r.Ids);
|
|
cp = r.Cp;
|
|
(++pages).ShouldBeLessThan(60, "paging must terminate");
|
|
}
|
|
while (cp is not null);
|
|
return ids;
|
|
}
|
|
|
|
private static (ServiceResult Error, SdkHistoryReadResult Result, HistoryModifiedData? Data, byte[]? Cp, List<long> Ids) Read(
|
|
OtOpcUaNodeManager nm, NodeId node, DateTime start, DateTime end, uint max, byte[]? cp, bool modified = true)
|
|
{
|
|
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
|
var results = new List<SdkHistoryReadResult> { null! };
|
|
var errors = new List<ServiceResult> { null! };
|
|
nm.HistoryRead(context,
|
|
new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = modified },
|
|
TimestampsToReturn.Both, false,
|
|
new List<HistoryReadValueId> { new() { NodeId = node, ContinuationPoint = cp } }, results, errors);
|
|
|
|
HistoryModifiedData? data = null;
|
|
var ids = new List<long>();
|
|
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)
|
|
&& ExtensionObject.ToEncodeable(ho) is HistoryModifiedData hm)
|
|
{
|
|
data = hm;
|
|
// The fake stores each modification's id in its (unique) server timestamp offset — see SqlLikeTrail.
|
|
ids.AddRange(hm.DataValues.Select(v => SqlLikeTrail.IdOf(v)));
|
|
}
|
|
|
|
return (errors[0], results[0], data, results[0]?.ContinuationPoint, ids);
|
|
}
|
|
|
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId Node, SqlLikeTrail Src)> SetupAsync(
|
|
List<HistoryModifiedValue> trail)
|
|
{
|
|
var started = await OpcUaHostStart.StartAsync(
|
|
port => new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.ModifiedHistoryTest",
|
|
ApplicationUri = $"urn:OtOpcUa.ModifiedHistoryTest:{Guid.NewGuid():N}",
|
|
OpcUaPort = port,
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance),
|
|
() => new OtOpcUaSdkServer(), Ct);
|
|
var nm = started.Server.NodeManager!;
|
|
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
|
var src = new SqlLikeTrail(trail);
|
|
nm.HistorianDataSource = src;
|
|
nm.EnsureVariable("eq-1/mod", null, "Mod", "Double", false, historianTagname: Tag, realm: AddressSpaceRealm.Uns);
|
|
return (started.Host, nm, nm.TryGetVariable("eq-1/mod")!.NodeId, src);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Behaves like the SqlServer/QuestDB modified reads: ordered by <c>(SourceTimeUtc, ModifiedId)</c> (descending
|
|
/// when reversed), half-open window, exclusive cursor, cap + exact <see cref="HistoryModifiedReadResult.HasMore"/>.
|
|
/// To let the test recover each projected value's id, the server timestamp handed out is
|
|
/// <c>Epoch + id ticks</c> (an otherwise unused field in these tests).
|
|
/// </summary>
|
|
private sealed class SqlLikeTrail(List<HistoryModifiedValue> trail) : IHistorianDataSource
|
|
{
|
|
public string? LastTag { get; private set; }
|
|
public DateTime LastStart { get; private set; }
|
|
public DateTime LastEnd { get; private set; }
|
|
public uint LastMax { get; private set; }
|
|
public HistoryModifiedCursor? LastCursor { get; private set; }
|
|
public int Calls { get; private set; }
|
|
public Exception? Throw { get; set; }
|
|
|
|
public static long IdOf(DataValue v) => v.ServerTimestamp.Ticks - Epoch.Ticks;
|
|
|
|
public IEnumerable<HistoryModifiedValue> Ordered(bool reverse) => reverse
|
|
? 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,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
|
|
if (Throw is not null) throw Throw;
|
|
Calls++;
|
|
(LastTag, LastStart, LastEnd, LastMax, LastCursor) = (fullReference, startUtc, endUtc, maxValues, resumeAfter);
|
|
var reverse = startUtc > endUtc;
|
|
var (lo, hi) = reverse ? (endUtc, startUtc) : (startUtc, endUtc);
|
|
var rows = Ordered(reverse).Where(m =>
|
|
{
|
|
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;
|
|
var c = t.CompareTo(resumeAfter.SourceTimeUtc);
|
|
return reverse ? c < 0 || (c == 0 && m.ModifiedId < resumeAfter.ModifiedId)
|
|
: c > 0 || (c == 0 && m.ModifiedId > resumeAfter.ModifiedId);
|
|
}).Select(m => m with { Value = m.Value with { ServerTimestampUtc = Epoch.AddTicks(m.ModifiedId) } }).ToList();
|
|
var take = maxValues == 0 ? rows.Count : (int)Math.Min(maxValues, (uint)rows.Count);
|
|
return new HistoryModifiedReadResult(rows.Take(take).ToList(), rows.Count > take);
|
|
}
|
|
|
|
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
|
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
|
|
public Task<HistorianRead> ReadProcessedAsync(
|
|
string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
|
|
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
|
|
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
|
|
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
|
|
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) =>
|
|
NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, ct);
|
|
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
|
public void Dispose() { }
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best effort */ }
|
|
}
|
|
}
|
|
}
|