feat(wire): attribute updates carry source_execution_id/trace; PullTraceEvents RPC + site read-by-execution-ids
Flow Debugger (design 2026-09-05 §5.3/§5.4): AttributeValueUpdate fields 7/8 stamped by StreamRelayActor and parsed by SiteStreamGrpcClient (older frames => null/false). New PullTraceEvents unary RPC on SiteStreamService (same PSK-gated prefix as PullAuditEvents): a point read of the site audit SQLite by ExecutionId OR ParentExecutionId (ISiteAuditQueue.ReadByExecutionIdsAsync, json_extract on the codec's camelCase keys, new IX_audit_event_occurred index, 64-id / 2000-row caps) that moves no cursor and flips no ForwardState. Central IPullTraceEventsClient/GrpcPullTraceEventsClient mirror the audit pull client (endpoint resolution, NodeB failover, fault => empty) and register beside it. Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
This commit is contained in:
+83
@@ -0,0 +1,83 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
||||
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Flow Debugger (design 2026-09-05 §5.4): <see cref="SqliteAuditWriter.ReadByExecutionIdsAsync"/>
|
||||
/// matches on ExecutionId OR ParentExecutionId, honours the since bound, orders by
|
||||
/// (OccurredAtUtc, EventId), caps the row count, and is independent of forwarding state.
|
||||
/// </summary>
|
||||
public class SqliteAuditWriterReadByExecutionTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"audit-readbyexec-{Guid.NewGuid():N}.db");
|
||||
private static readonly DateTime T0 = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_dbPath)) { try { File.Delete(_dbPath); } catch { /* best-effort */ } }
|
||||
}
|
||||
|
||||
private SqliteAuditWriter CreateWriter() => new(
|
||||
Options.Create(new SqliteAuditWriterOptions { DatabasePath = _dbPath }),
|
||||
NullLogger<SqliteAuditWriter>.Instance,
|
||||
new FakeNodeIdentityProvider());
|
||||
|
||||
private static AuditEvent Row(DateTime at, Guid? exec, Guid? parent, AuditKind kind = AuditKind.ScriptRun) =>
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, kind, AuditStatus.Delivered,
|
||||
occurredAtUtc: at, executionId: exec, parentExecutionId: parent, sourceSiteId: "site-a");
|
||||
|
||||
[Fact]
|
||||
public async Task MatchesExecutionId_OrParentExecutionId_OrderedAndBounded()
|
||||
{
|
||||
var root = Guid.NewGuid();
|
||||
var child = Guid.NewGuid();
|
||||
var unrelated = Guid.NewGuid();
|
||||
var rows = new[]
|
||||
{
|
||||
Row(T0.AddSeconds(3), child, root, AuditKind.AttributeWrite), // parent match
|
||||
Row(T0.AddSeconds(1), root, null), // exec match
|
||||
Row(T0.AddSeconds(2), child, root), // parent match
|
||||
Row(T0.AddSeconds(4), unrelated, null), // no match
|
||||
Row(T0.AddSeconds(-60), root, null), // before since
|
||||
};
|
||||
|
||||
await using (var writer = CreateWriter())
|
||||
{
|
||||
foreach (var r in rows) await writer.WriteAsync(r);
|
||||
|
||||
var hits = await writer.ReadByExecutionIdsAsync([root], T0, limit: 100);
|
||||
Assert.Equal(3, hits.Count);
|
||||
Assert.Equal(rows[1].EventId, hits[0].EventId);
|
||||
Assert.Equal(rows[2].EventId, hits[1].EventId);
|
||||
Assert.Equal(rows[0].EventId, hits[2].EventId);
|
||||
Assert.Equal(rows[1], hits[0]);
|
||||
|
||||
// Bounded.
|
||||
var two = await writer.ReadByExecutionIdsAsync([root], T0, limit: 2);
|
||||
Assert.Equal(2, two.Count);
|
||||
|
||||
// Forwarding state is irrelevant: mark everything Reconciled, still served.
|
||||
await writer.MarkReconciledAsync(rows.Select(r => r.EventId).ToList());
|
||||
Assert.Equal(3, (await writer.ReadByExecutionIdsAsync([root], T0, limit: 100)).Count);
|
||||
|
||||
// Empty id set → empty; multiple ids union.
|
||||
Assert.Empty(await writer.ReadByExecutionIdsAsync(Array.Empty<Guid>(), T0, 10));
|
||||
Assert.Equal(4, (await writer.ReadByExecutionIdsAsync([root, unrelated], T0, 100)).Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Guards_LimitAndIdCap()
|
||||
{
|
||||
await using var writer = CreateWriter();
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => writer.ReadByExecutionIdsAsync([Guid.NewGuid()], T0, 0));
|
||||
var tooMany = Enumerable.Range(0, SqliteAuditWriter.MaxExecutionIdsPerRead + 1).Select(_ => Guid.NewGuid()).ToList();
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => writer.ReadByExecutionIdsAsync(tooMany, T0, 10));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user