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();
}
}