feat(historian-gateway): ReadEventsAsync alarm-history via gateway ReadEvents (+ truncation signal)

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
Joseph Doherty
2026-06-26 16:47:04 -04:00
parent 0a540d9f09
commit 35aace7fdf
2 changed files with 120 additions and 4 deletions
@@ -133,23 +133,58 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
}
/// <inheritdoc />
/// <remarks>
/// Depends on the target gateway running with <c>RuntimeDb:EventReadsEnabled=true</c> (the
/// SQL alarm-history path). The <paramref name="sourceName"/> is passed through to the
/// gateway, but its SQL <c>ReadEvents</c> source filter may not be present yet — so this
/// adapter also filters the mapped events by <see cref="HistoricalEvent.SourceName"/>
/// client-side (defensive; remove once the server filter is confirmed). The
/// <paramref name="maxEvents"/> cap is enforced client-side by early stream termination:
/// a non-positive value applies no client cap (the gateway may still apply its
/// <c>EventReadMaxRows</c>); a positive cap stops at N and sets a non-null
/// <see cref="HistoricalEventsResult.ContinuationPoint"/> iff at least one further matching
/// event existed (the Core.Abstractions-009 truncation signal).
/// </remarks>
public async Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken)
{
try
{
var events = new List<HistorianEvent>();
var hasCap = maxEvents > 0;
var collected = new List<HistoricalEvent>(hasCap ? maxEvents : 0);
var truncated = false;
await foreach (var wireEvent in _client
.ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken)
.ConfigureAwait(false))
{
events.Add(wireEvent);
var mapped = EventMapper.ToHistoricalEvent(wireEvent);
// Defensive client-side source filter: the gateway's SQL ReadEvents source filter
// may not be present, so drop any event whose source does not match the request.
if (sourceName is not null && !string.Equals(mapped.SourceName, sourceName, StringComparison.Ordinal))
{
continue;
}
// One more matching event arriving once the cap is full means the result is
// truncated — stop draining and flag it (Core.Abstractions-009).
if (hasCap && collected.Count == maxEvents)
{
truncated = true;
break;
}
collected.Add(mapped);
}
var mapped = EventMapper.ToHistoricalEvents(events);
RecordOutcome(success: true, error: null);
return new HistoricalEventsResult(mapped, ContinuationPoint: null);
// A non-null, opaque token signals truncation to the caller (Core.Abstractions-009).
// The gateway has no resumable cursor, so the token's contents carry no paging state —
// its presence alone is the "more events exist" signal. A fresh array per call keeps it
// from being shared/mutated.
return new HistoricalEventsResult(collected, truncated ? new byte[] { 0x01 } : null);
}
catch (Exception ex)
{