using Parquet; using Parquet.Schema; namespace ZB.MOM.WW.ScadaBridge.ManagementService; /// /// GET /api/audit/export?format=parquet (ScadaBridge#42): the same 24 columns as the /// csv / jsonl export (, in /// order) as one Parquet file, one row group per rows. /// /// /// /// Types: ids are D-format strings, timestamps are UTC Parquet timestamps /// (microseconds), enums are their names, HttpStatus/DurationMs are int32, /// PayloadTruncated is boolean, everything else is a UTF-8 string. /// /// /// Streaming. Kestrel forbids synchronous writes to the response body and the Parquet /// writer issues some, so each row group is encoded into a small in-memory buffer and then /// copied to the response asynchronously. Memory is bounded by one row group, and the client /// starts receiving bytes after the first one. A Parquet file's footer is written last, so a /// response cut off mid-stream is not a readable file — the CLI treats it as a failed export. /// /// /// This is the ad-hoc export. The retention archive (alog.md §15.2) is a different layout /// that carries the hash-chain columns; see AuditArchiveParquet. /// /// public static class AuditExportParquet { /// Rows per Parquet row group. public const int RowGroupRows = 10_000; private static readonly DataField EventId = new(nameof(AuditExportRow.EventId), false); private static readonly DateTimeDataField OccurredAtUtc = Timestamp(nameof(AuditExportRow.OccurredAtUtc), false); private static readonly DateTimeDataField IngestedAtUtc = Timestamp(nameof(AuditExportRow.IngestedAtUtc), true); private static readonly DataField Channel = new(nameof(AuditExportRow.Channel), false); private static readonly DataField Kind = new(nameof(AuditExportRow.Kind), false); private static readonly DataField CorrelationId = Text(nameof(AuditExportRow.CorrelationId)); private static readonly DataField ExecutionId = Text(nameof(AuditExportRow.ExecutionId)); private static readonly DataField ParentExecutionId = Text(nameof(AuditExportRow.ParentExecutionId)); private static readonly DataField SourceSiteId = Text(nameof(AuditExportRow.SourceSiteId)); private static readonly DataField SourceNode = Text(nameof(AuditExportRow.SourceNode)); private static readonly DataField SourceInstanceId = Text(nameof(AuditExportRow.SourceInstanceId)); private static readonly DataField SourceScript = Text(nameof(AuditExportRow.SourceScript)); private static readonly DataField Actor = Text(nameof(AuditExportRow.Actor)); private static readonly DataField Target = Text(nameof(AuditExportRow.Target)); private static readonly DataField Status = new(nameof(AuditExportRow.Status), false); private static readonly DataField HttpStatus = new(nameof(AuditExportRow.HttpStatus), true); private static readonly DataField DurationMs = new(nameof(AuditExportRow.DurationMs), true); private static readonly DataField ErrorMessage = Text(nameof(AuditExportRow.ErrorMessage)); private static readonly DataField ErrorDetail = Text(nameof(AuditExportRow.ErrorDetail)); private static readonly DataField RequestSummary = Text(nameof(AuditExportRow.RequestSummary)); private static readonly DataField ResponseSummary = Text(nameof(AuditExportRow.ResponseSummary)); private static readonly DataField PayloadTruncated = new(nameof(AuditExportRow.PayloadTruncated), false); private static readonly DataField Extra = Text(nameof(AuditExportRow.Extra)); private static readonly DataField ForwardState = Text(nameof(AuditExportRow.ForwardState)); /// The export schema; column names and order equal . public static ParquetSchema Schema { get; } = new( EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId, SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState); /// Writes every row of to as one Parquet file. /// Export rows, page by page. /// The destination (e.g. the HTTP response body); written asynchronously only. /// Cancellation token. /// The number of rows written. public static async Task WriteAsync( IAsyncEnumerable> pages, Stream output, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(pages); ArgumentNullException.ThrowIfNull(output); // Non-seekable on purpose: the writer then tracks file offsets itself, so emptying the // buffer after each drain cannot corrupt the column-chunk offsets in the footer. var staging = new StagingStream(); long total = 0; var buffer = new List(RowGroupRows); await using (var writer = await ParquetWriter.CreateAsync(Schema, staging, cancellationToken: ct).ConfigureAwait(false)) { await foreach (var page in pages.WithCancellation(ct).ConfigureAwait(false)) { foreach (var row in page) { buffer.Add(row); if (buffer.Count == RowGroupRows) { total += await WriteGroupAsync(writer, buffer, ct).ConfigureAwait(false); await DrainAsync(staging, output, ct).ConfigureAwait(false); } } } total += await WriteGroupAsync(writer, buffer, ct).ConfigureAwait(false); } // The footer is written when the writer is disposed. await DrainAsync(staging, output, ct).ConfigureAwait(false); return total; } private static async Task WriteGroupAsync(ParquetWriter writer, List rows, CancellationToken ct) { if (rows.Count == 0) { return 0; } using (var group = writer.CreateRowGroup()) { await group.WriteAsync(EventId, Map(rows, r => r.EventId.ToString("D"))).ConfigureAwait(false); await group.WriteAsync(OccurredAtUtc, rows.Select(r => DateTime.SpecifyKind(r.OccurredAtUtc, DateTimeKind.Utc)).ToArray(), cancellationToken: ct).ConfigureAwait(false); await group.WriteAsync(IngestedAtUtc, rows.Select(r => r.IngestedAtUtc is { } i ? DateTime.SpecifyKind(i, DateTimeKind.Utc) : (DateTime?)null).ToArray(), cancellationToken: ct).ConfigureAwait(false); await group.WriteAsync(Channel, Map(rows, r => r.Channel.ToString())).ConfigureAwait(false); await group.WriteAsync(Kind, Map(rows, r => r.Kind.ToString())).ConfigureAwait(false); await group.WriteAsync(CorrelationId, Map(rows, r => r.CorrelationId?.ToString("D"))!).ConfigureAwait(false); await group.WriteAsync(ExecutionId, Map(rows, r => r.ExecutionId?.ToString("D"))!).ConfigureAwait(false); await group.WriteAsync(ParentExecutionId, Map(rows, r => r.ParentExecutionId?.ToString("D"))!).ConfigureAwait(false); await group.WriteAsync(SourceSiteId, Map(rows, r => r.SourceSiteId)!).ConfigureAwait(false); await group.WriteAsync(SourceNode, Map(rows, r => r.SourceNode)!).ConfigureAwait(false); await group.WriteAsync(SourceInstanceId, Map(rows, r => r.SourceInstanceId)!).ConfigureAwait(false); await group.WriteAsync(SourceScript, Map(rows, r => r.SourceScript)!).ConfigureAwait(false); await group.WriteAsync(Actor, Map(rows, r => r.Actor)!).ConfigureAwait(false); await group.WriteAsync(Target, Map(rows, r => r.Target)!).ConfigureAwait(false); await group.WriteAsync(Status, Map(rows, r => r.Status.ToString())).ConfigureAwait(false); await group.WriteAsync(HttpStatus, rows.Select(r => r.HttpStatus).ToArray(), cancellationToken: ct).ConfigureAwait(false); await group.WriteAsync(DurationMs, rows.Select(r => r.DurationMs).ToArray(), cancellationToken: ct).ConfigureAwait(false); await group.WriteAsync(ErrorMessage, Map(rows, r => r.ErrorMessage)!).ConfigureAwait(false); await group.WriteAsync(ErrorDetail, Map(rows, r => r.ErrorDetail)!).ConfigureAwait(false); await group.WriteAsync(RequestSummary, Map(rows, r => r.RequestSummary)!).ConfigureAwait(false); await group.WriteAsync(ResponseSummary, Map(rows, r => r.ResponseSummary)!).ConfigureAwait(false); await group.WriteAsync(PayloadTruncated, rows.Select(r => r.PayloadTruncated).ToArray(), cancellationToken: ct).ConfigureAwait(false); await group.WriteAsync(Extra, Map(rows, r => r.Extra)!).ConfigureAwait(false); await group.WriteAsync(ForwardState, Map(rows, r => r.ForwardState?.ToString())!).ConfigureAwait(false); } var count = rows.Count; rows.Clear(); return count; } private static async Task DrainAsync(StagingStream staging, Stream output, CancellationToken ct) { var pending = staging.Pending; if (pending.Length == 0) { return; } await output.WriteAsync(pending, ct).ConfigureAwait(false); await output.FlushAsync(ct).ConfigureAwait(false); staging.Clear(); } private static string?[] Map(List rows, Func select) => rows.Select(select).ToArray(); private static DataField Text(string name) => new(name, true); private static DateTimeDataField Timestamp(string name, bool nullable) => new(name, DateTimeFormat.Timestamp, isAdjustedToUTC: true, unit: DateTimeTimeUnit.Micros, isNullable: nullable); /// /// Forward-only, non-seekable buffer the Parquet writer writes into (synchronously or /// not); moves its bytes to the real output asynchronously. /// private sealed class StagingStream : Stream { private readonly MemoryStream _buffer = new(); public ReadOnlyMemory Pending => _buffer.GetBuffer().AsMemory(0, (int)_buffer.Length); public override bool CanRead => false; public override bool CanSeek => false; public override bool CanWrite => true; public override long Length => throw new NotSupportedException(); public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public void Clear() => _buffer.SetLength(0); public override void Write(byte[] buffer, int offset, int count) => _buffer.Write(buffer, offset, count); public override void Write(ReadOnlySpan buffer) => _buffer.Write(buffer); public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { _buffer.Write(buffer.Span); return ValueTask.CompletedTask; } public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { _buffer.Write(buffer, offset, count); return Task.CompletedTask; } public override void Flush() { } public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); } }