Signal Forge ADR-010: gRPC server-streaming via AsAsyncEnumerable (not ToListAsync)
Status: Accepted
Decision: [[projects/app-signal-forge/api/grpc|GetOrdersByProject]] streams rows directly from
the PostgreSQL cursor using AsAsyncEnumerable(), writing each row to the gRPC stream as it is
fetched.
Code pattern:
await foreach (var order in _db.Orders
.Where(o => o.ProjectId == request.ProjectId)
.OrderByDescending(o => o.CreatedAt)
.AsAsyncEnumerable()
.WithCancellation(context.CancellationToken))
{
await responseStream.WriteAsync(MapToResponse(order), context.CancellationToken);
}
Rationale:
ToListAsync()loads all matching rows into aList<Order>in application memory before streaming begins. For large result sets this causes OOM.AsAsyncEnumerable()uses the database cursor: one row is fetched, sent over gRPC, then the next is fetched. Memory usage is O(1) regardless of result set size.- CancellationToken is threaded through so the DB query is cancelled if the gRPC client disconnects.
Alternative considered: ToListAsync() — rejected due to unbounded memory growth.
Local graph
Linked from 1 note
Related notes
Signal Forge ADR-005: Separate collector configmaps per deployment mode
Keeps cloud and local Alloy collector configs in separate files rather than one conditional configmap, so each mode's exporters stay explicit and uncoupled.
Signal Forge ADR-003: Span metrics generated before tail sampling
Places the spanmetrics connector ahead of tail_sampling so RED metrics reflect all traffic instead of only the ~25% of traces that survive sampling.
Signal Forge ADR-002: SpanLink for async RabbitMQ propagation (not parent-child)
Uses a SpanLink, not a parent-child span relationship, to connect RabbitMQ consumer spans back to the producer span across async, retry-prone delivery.
Architecture Overview
Signal Forge's topology, service communication, trace propagation, and per-signal pipeline flow across local and Grafana Cloud deployment modes.