fix(driver-historian-wonderware-client): resolve High code-review finding (Driver.Historian.Wonderware.Client-001)
WonderwareHistorianClient.ReadAtTimeAsync passed the sidecar's reply.Samples straight through ToSnapshots, which violated the IHistorianDataSource contract: the result MUST be the same length and order as the requested timestampsUtc, with gaps returned as Bad-quality snapshots. If the sidecar dropped or reordered samples, OPC UA HistoryReadAtTime would silently misalign values with timestamps. Add an AlignAtTimeSnapshots helper that indexes the returned samples by timestamp ticks, builds the result array at timestampsUtc.Count in request order, and emits a Bad-quality (0x80000000) snapshot for any requested timestamp the sidecar did not return. Add the ReadAtTimeAsync_PartialAndReorderedReply_AlignsByTimestamp_AndFillsGapsAsBad regression test where the fake returns a partial, reordered sample set. Update code-reviews/Driver.Historian.Wonderware.Client/findings.md: -001 Resolved, open-finding count 10 -> 9. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -109,7 +109,51 @@ public sealed class WonderwareHistorianClient : IHistorianDataSource, IAlarmHist
|
||||
};
|
||||
var reply = await Invoke<ReadAtTimeRequest, ReadAtTimeReply>(MessageKind.ReadAtTimeRequest, MessageKind.ReadAtTimeReply, req, cancellationToken).ConfigureAwait(false);
|
||||
ThrowIfFailed(reply.Success, reply.Error, "ReadAtTime");
|
||||
return new HistoryReadResult(ToSnapshots(reply.Samples), ContinuationPoint: null);
|
||||
return new HistoryReadResult(AlignAtTimeSnapshots(timestampsUtc, reply.Samples), ContinuationPoint: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles a <c>ReadAtTime</c> sidecar reply against the requested timestamps to
|
||||
/// honour the <see cref="IHistorianDataSource.ReadAtTimeAsync"/> contract: the result
|
||||
/// MUST have exactly one snapshot per requested timestamp, in request order. The sidecar
|
||||
/// is not required to return a sample for every timestamp (e.g. it may drop
|
||||
/// boundary-less timestamps) nor to preserve order, so each requested timestamp is
|
||||
/// matched by ticks; any timestamp the sidecar did not return is filled with a
|
||||
/// Bad-quality (<c>0x80000000</c>) snapshot rather than positionally misaligning values.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<DataValueSnapshot> AlignAtTimeSnapshots(
|
||||
IReadOnlyList<DateTime> timestampsUtc, HistorianSampleDto[] samples)
|
||||
{
|
||||
// Index returned samples by timestamp ticks. Duplicate timestamps keep the first.
|
||||
var byTicks = new Dictionary<long, HistorianSampleDto>(samples.Length);
|
||||
foreach (var sample in samples)
|
||||
byTicks.TryAdd(sample.TimestampUtcTicks, sample);
|
||||
|
||||
var result = new DataValueSnapshot[timestampsUtc.Count];
|
||||
for (var i = 0; i < timestampsUtc.Count; i++)
|
||||
{
|
||||
var requested = DateTime.SpecifyKind(timestampsUtc[i], DateTimeKind.Utc);
|
||||
if (byTicks.TryGetValue(requested.Ticks, out var dto))
|
||||
{
|
||||
var value = dto.ValueBytes is null ? null : MessagePackSerializer.Deserialize<object>(dto.ValueBytes);
|
||||
result[i] = new DataValueSnapshot(
|
||||
Value: value,
|
||||
StatusCode: QualityMapper.Map(dto.Quality),
|
||||
SourceTimestampUtc: requested,
|
||||
ServerTimestampUtc: DateTime.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Gap — sidecar returned no sample for this timestamp. Per the contract this
|
||||
// is a Bad-quality snapshot stamped at the requested time, not a dropped row.
|
||||
result[i] = new DataValueSnapshot(
|
||||
Value: null,
|
||||
StatusCode: 0x80000000u, // Bad
|
||||
SourceTimestampUtc: requested,
|
||||
ServerTimestampUtc: DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
|
||||
Reference in New Issue
Block a user