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:
@@ -557,7 +557,11 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
|
||||
evt.AttributeChanged.AttributeName,
|
||||
evt.AttributeChanged.Value,
|
||||
MapQuality(evt.AttributeChanged.Quality),
|
||||
evt.AttributeChanged.Timestamp.ToDateTimeOffset()),
|
||||
evt.AttributeChanged.Timestamp.ToDateTimeOffset(),
|
||||
// Flow Debugger (design §5.4): fields 7/8 — an older site leaves them at the
|
||||
// proto3 defaults, which map to null / false.
|
||||
SourceExecutionId: Guid.TryParse(evt.AttributeChanged.SourceExecutionId, out var traceSource) ? traceSource : null,
|
||||
Trace: evt.AttributeChanged.Trace),
|
||||
SiteStreamEvent.EventOneofCase.AlarmChanged => new AlarmStateChanged(
|
||||
evt.AlarmChanged.InstanceUniqueName,
|
||||
evt.AlarmChanged.AlarmName,
|
||||
|
||||
@@ -641,6 +641,77 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
|
||||
return ack;
|
||||
}
|
||||
|
||||
/// <summary>Flow Debugger PullTraceEvents: server-side row cap (design 2026-09-05 §5.4).</summary>
|
||||
public const int MaxTraceEventsPerPull = 2000;
|
||||
|
||||
/// <summary>Flow Debugger PullTraceEvents: server-side id cap, matching the site queue's.</summary>
|
||||
public const int MaxTraceExecutionIdsPerPull = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Flow Debugger (design 2026-09-05 §5.4): point read of this site's audit rows whose
|
||||
/// <c>ExecutionId</c> or <c>ParentExecutionId</c> is one of the requested ids. NOT a
|
||||
/// reconciliation path — moves no cursor, flips no ForwardState. Gated by the same
|
||||
/// <c>ControlPlaneAuthInterceptor</c> prefix as <see cref="PullAuditEvents"/>.
|
||||
/// </summary>
|
||||
public override async Task<PullTraceEventsResponse> PullTraceEvents(
|
||||
PullTraceEventsRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var queue = _siteAuditQueue;
|
||||
if (queue is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"PullTraceEvents invoked before SetSiteAuditQueue was called; returning empty response.");
|
||||
return new PullTraceEventsResponse();
|
||||
}
|
||||
|
||||
if (request.MaxEvents <= 0 || request.MaxEvents > MaxTraceEventsPerPull)
|
||||
{
|
||||
throw new RpcException(new GrpcStatus(
|
||||
StatusCode.InvalidArgument, $"max_events must be 1..{MaxTraceEventsPerPull}"));
|
||||
}
|
||||
if (request.ExecutionIds.Count == 0 || request.ExecutionIds.Count > MaxTraceExecutionIdsPerPull)
|
||||
{
|
||||
throw new RpcException(new GrpcStatus(
|
||||
StatusCode.InvalidArgument, $"execution_ids must hold 1..{MaxTraceExecutionIdsPerPull} ids"));
|
||||
}
|
||||
|
||||
var ids = new List<Guid>(request.ExecutionIds.Count);
|
||||
foreach (var raw in request.ExecutionIds)
|
||||
{
|
||||
if (!Guid.TryParse(raw, out var id))
|
||||
{
|
||||
throw new RpcException(new GrpcStatus(
|
||||
StatusCode.InvalidArgument, $"execution_ids entry '{raw}' is not a GUID"));
|
||||
}
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
var since = request.SinceUtc is not null
|
||||
? DateTime.SpecifyKind(request.SinceUtc.ToDateTime(), DateTimeKind.Utc)
|
||||
: DateTime.MinValue;
|
||||
|
||||
IReadOnlyList<AuditEvent> events;
|
||||
try
|
||||
{
|
||||
events = await queue.ReadByExecutionIdsAsync(ids, since, request.MaxEvents, context.CancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"ReadByExecutionIdsAsync failed for {Count} ids since={Since}; returning empty response.",
|
||||
ids.Count, since);
|
||||
return new PullTraceEventsResponse();
|
||||
}
|
||||
|
||||
var response = new PullTraceEventsResponse();
|
||||
foreach (var evt in events)
|
||||
{
|
||||
response.Events.Add(AuditEventDtoMapper.ToDto(evt));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<PullAuditEventsResponse> PullAuditEvents(
|
||||
PullAuditEventsRequest request,
|
||||
|
||||
Reference in New Issue
Block a user