diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditArchiveHelpers.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditArchiveHelpers.cs
new file mode 100644
index 00000000..20f053ef
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditArchiveHelpers.cs
@@ -0,0 +1,117 @@
+using System.Globalization;
+using System.Text.Json;
+
+namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
+
+///
+/// Helpers for the audit archive list / audit archive verify subcommands
+/// (AuditLog Parquet cold archive, ScadaBridge#42). Both send a management command
+/// (ListAuditArchives, VerifyAuditArchive); the archives live on the central
+/// nodes' archive directory, not on the operator's machine.
+///
+///
+/// verify exit codes: 0 verified, when the
+/// files, the chain or the purge anchor disagree, 1 on a request error, 2 on an
+/// authorization failure.
+///
+public static class AuditArchiveHelpers
+{
+ /// Process exit code when an archive failed verification (tamper evidence).
+ public const int NotVerifiedExitCode = 3;
+
+ ///
+ /// Client deadline for verify: the server hashes and re-chains a whole month under
+ /// the long-running management Ask (5 min); the client waits a little longer.
+ ///
+ public static readonly TimeSpan VerifyRequestTimeout = TimeSpan.FromMinutes(6);
+
+ /// Renders a ListAuditArchives result.
+ /// The success body (an AuditArchiveListResult, camelCase).
+ /// Resolved output format.
+ /// Writer for the report.
+ /// Always 0.
+ public static int RenderList(string json, string format, TextWriter output)
+ {
+ if (!string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
+ {
+ output.WriteLine(json);
+ output.Flush();
+ return 0;
+ }
+
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ var enabled = root.TryGetProperty("enabled", out var e) && e.ValueKind == JsonValueKind.True;
+ output.WriteLine($"Archiving: {(enabled ? "enabled" : "disabled")}");
+ if (!root.TryGetProperty("archives", out var archives) || archives.GetArrayLength() == 0)
+ {
+ output.WriteLine("No archives.");
+ output.Flush();
+ return 0;
+ }
+
+ output.WriteLine($"{"NAME",-42} {"PARTITION",-9} {"STATUS",-8} {"ROWS",12} {"CHAINED",12} {"BYTES",14}");
+ foreach (var a in archives.EnumerateArray())
+ {
+ output.WriteLine(string.Create(CultureInfo.InvariantCulture,
+ $"{Str(a, "name"),-42} {Str(a, "partition"),-9} {Str(a, "status"),-8} {Num(a, "rowCount"),12} {Num(a, "chainedRowCount"),12} {Num(a, "dataBytes"),14}"));
+ }
+
+ output.Flush();
+ return 0;
+ }
+
+ /// Renders a VerifyAuditArchive result and maps it to the exit code.
+ /// The success body (an AuditArchiveVerificationResult, camelCase).
+ /// Resolved output format.
+ /// Writer for the report.
+ /// 0 when verified, otherwise.
+ public static int RenderVerify(string json, string format, TextWriter output)
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ var verified = root.TryGetProperty("verified", out var v) && v.ValueKind == JsonValueKind.True;
+
+ if (!string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
+ {
+ output.WriteLine(json);
+ output.Flush();
+ return verified ? 0 : NotVerifiedExitCode;
+ }
+
+ output.WriteLine($"Archive : {Str(root, "name")}");
+ output.WriteLine($"Partition : {Str(root, "partition")}");
+ output.WriteLine($"Manifest : {Str(root, "manifestStatus")}");
+ output.WriteLine($"Verified : {(verified ? "yes" : "NO")}");
+ output.WriteLine($"Files intact: {(root.TryGetProperty("filesIntact", out var f) && f.ValueKind == JsonValueKind.True ? "yes" : "NO")}");
+ if (Str(root, "fileProblem") is { Length: > 0 } problem)
+ {
+ output.WriteLine($" problem : {problem}");
+ }
+
+ output.WriteLine($"Rows read : {Num(root, "rowsRead")}");
+ if (root.TryGetProperty("chains", out var chains) && chains.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var c in chains.EnumerateArray())
+ {
+ var chain = c.GetProperty("chain");
+ output.WriteLine($"Chain {Str(chain, "partition")}: {Str(chain, "status")}, {Num(chain, "rowsVerified")} rows verified, "
+ + $"{Num(chain, "tombstonesApplied")} tombstones, {Num(chain, "unchainedRows")} unchained; anchor {Str(c, "anchor")}");
+ output.WriteLine($" {Str(c, "anchorDetail")}");
+ if (chain.TryGetProperty("firstBreak", out var brk) && brk.ValueKind == JsonValueKind.Object)
+ {
+ output.WriteLine($" first break: chainSeq {Num(brk, "chainSeq")}, eventId {Str(brk, "eventId", "-")}, {Str(brk, "kind")} — {Str(brk, "detail")}");
+ }
+ }
+ }
+
+ output.Flush();
+ return verified ? 0 : NotVerifiedExitCode;
+ }
+
+ private static string Str(JsonElement e, string name, string missing = "")
+ => e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? missing : missing;
+
+ private static string Num(JsonElement e, string name, string missing = "0")
+ => e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt64().ToString(CultureInfo.InvariantCulture) : missing;
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs
index 8ec96f77..91976085 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs
@@ -9,7 +9,9 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// the centralized append-only Audit Log via the REST endpoints
/// (GET /api/audit/query, GET /api/audit/export,
/// GET /api/audit/tree), plus verify-chain, which sends the
-/// VerifyAuditChain management command (hash-chain tamper evidence, ScadaBridge#41).
+/// VerifyAuditChain management command (hash-chain tamper evidence, ScadaBridge#41),
+/// and archive list / archive verify, which send ListAuditArchives /
+/// VerifyAuditArchive (Parquet cold archive, ScadaBridge#42).
///
public static class AuditCommands
{
@@ -31,6 +33,7 @@ public static class AuditCommands
command.Add(BuildTree(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildVerifyChain(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildBackfillSourceNode(urlOption, formatOption, usernameOption, passwordOption));
+ command.Add(BuildArchive(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
@@ -316,6 +319,49 @@ public static class AuditCommands
return cmd;
}
+ ///
+ /// Builds the audit archive group (ScadaBridge#42): list and verify
+ /// over the AuditLog Parquet cold archives on the central archive directory.
+ ///
+ private static Command BuildArchive(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption)
+ {
+ var group = new Command("archive") { Description = "List and verify the AuditLog Parquet cold archives" };
+
+ var list = new Command("list") { Description = "List the AuditLog Parquet archives written by the retention purge" };
+ list.SetAction(async (ParseResult result) =>
+ {
+ var format = AuditCommandHelpers.ResolveFormat(result, formatOption);
+ return await CommandHelpers.ExecuteCommandAsync(
+ result, urlOption, formatOption, usernameOption, passwordOption,
+ new ListAuditArchivesCommand(),
+ onSuccess: json => AuditArchiveHelpers.RenderList(json, format, Console.Out));
+ });
+ group.Add(list);
+
+ var nameOption = new Option("--name")
+ {
+ Description = "Archive name, as 'audit archive list' prints it (auditlog-YYYY-MM-yyyyMMddTHHmmssfffZ)",
+ Required = true,
+ };
+ var verify = new Command("verify")
+ {
+ Description = "Verify an archive's files, its hash chain and its purge anchor; exit 3 if it does not verify",
+ };
+ verify.Add(nameOption);
+ verify.SetAction(async (ParseResult result) =>
+ {
+ var format = AuditCommandHelpers.ResolveFormat(result, formatOption);
+ return await CommandHelpers.ExecuteCommandAsync(
+ result, urlOption, formatOption, usernameOption, passwordOption,
+ new VerifyAuditArchiveCommand(result.GetValue(nameOption)!),
+ timeout: AuditArchiveHelpers.VerifyRequestTimeout,
+ onSuccess: json => AuditArchiveHelpers.RenderVerify(json, format, Console.Out));
+ });
+ group.Add(verify);
+
+ return group;
+ }
+
///
/// Builds the audit backfill-source-node sub-command.
/// Sets SourceNode on historical pre-feature rows whose SourceNode IS NULL
diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditExportHelpers.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditExportHelpers.cs
index b57a82c7..3a5e46e3 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditExportHelpers.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditExportHelpers.cs
@@ -134,8 +134,8 @@ public static class AuditExportHelpers
/// Executes the export: GETs /api/audit/export and copies the response body
/// stream directly to . The body is never fully
/// buffered — the copy streams in fixed-size chunks via .
- /// A 501 Not Implemented (parquet not yet supported server-side) prints the
- /// server message and returns a non-zero exit code.
+ /// A 501 Not Implemented (a central older than ScadaBridge#42 does not serve
+ /// format=parquet) prints the server message and returns a non-zero exit code.
///
/// The management HTTP client for API communication.
/// The export arguments containing filters and output file path.
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/AuditLogCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/AuditLogCommands.cs
index 8a024ae2..5fd94e17 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/AuditLogCommands.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/AuditLogCommands.cs
@@ -31,3 +31,19 @@ public record AuditLogQueryResult(IReadOnlyList Entries, int Tota
///
/// The month to verify, YYYY-MM.
public record VerifyAuditChainCommand(string Month);
+
+///
+/// Lists the AuditLog Parquet cold archives under the configured archive directory (audit
+/// roadmap T2, ScadaBridge#42). Read-only; OperationalAudit roles, refused for a site-scoped
+/// caller because an archive holds every site's rows.
+///
+public record ListAuditArchivesCommand;
+
+///
+/// Verifies one AuditLog Parquet archive (ScadaBridge#42): file integrity against its
+/// manifest, the hash chain recomputed over the archived rows, and the chain end against the
+/// checkpoint anchor the partition purge left. Read-only; same gate as
+/// .
+///
+/// The archive name, as audit archive list reports it.
+public record VerifyAuditArchiveCommand(string Name);
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs
index 1c5f983b..01c44dce 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs
@@ -22,8 +22,8 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService;
///
/// - GET /api/audit/query — keyset-paged JSON page, gated on the
/// permission.
-/// - GET /api/audit/export — streamed bulk export (csv / jsonl;
-/// parquet returns HTTP 501), gated on the
+///
- GET /api/audit/export — streamed bulk export (csv / jsonl /
+/// parquet), gated on the
/// permission.
/// - GET /api/audit/tree — execution-chain tree rooted at the
/// topmost ancestor of a given executionId, returned as a JSON array
@@ -47,11 +47,8 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService;
///
///
///
-/// Parquet. No Parquet library is referenced by the solution and
-/// Component-AuditLog.md explicitly defers Parquet archival to v1.x, so
-/// format=parquet returns 501 Not Implemented with a guidance
-/// message rather than a half-built binary stream. csv + jsonl are fully
-/// implemented.
+/// Parquet. format=parquet (ScadaBridge#42) streams the same 24 columns as
+/// csv / jsonl as one Parquet file (), a row group at a time.
///
///
public static class AuditEndpoints
@@ -65,6 +62,9 @@ public static class AuditEndpoints
/// Repository round-trip size used while streaming an export.
public const int ExportPageSize = 1000;
+ /// Content type of a format=parquet export (ScadaBridge#42).
+ public const string ParquetContentType = "application/vnd.apache.parquet";
+
///
/// Serializer for individual rows (query results +
/// jsonl export lines). Drops null columns so a sparse audit row stays
@@ -185,10 +185,10 @@ public static class AuditEndpoints
// ─────────────────────────────────────────────────────────────────────
///
- /// Handles GET /api/audit/export: authenticates, checks the AuditExport permission, and streams the matching rows as CSV or JSONL.
+ /// Handles GET /api/audit/export: authenticates, checks the AuditExport permission, and streams the matching rows as CSV, JSONL or Parquet.
///
/// The HTTP context for the current request.
- /// A task that resolves to the HTTP result (streaming CSV/JSONL, 400, 401, 403, or 501).
+ /// A task that resolves to the HTTP result (streaming CSV/JSONL/Parquet, 400, 401, or 403).
internal static async Task HandleExport(HttpContext context)
{
var auth = await AuthenticateAsync(context);
@@ -208,17 +208,7 @@ public static class AuditEndpoints
format = "csv";
}
- if (format == "parquet")
- {
- // Deferred to v1.x (Component-AuditLog.md §"Deferred"): no Parquet
- // library is in the solution. Return a 501 with actionable guidance
- // rather than a partial binary stream.
- return Results.Json(
- new { error = "Parquet export deferred to v1.x; use csv or jsonl.", code = "NOT_IMPLEMENTED" },
- statusCode: 501);
- }
-
- if (format != "csv" && format != "jsonl")
+ if (format != "csv" && format != "jsonl" && format != "parquet")
{
return Results.Json(
new { error = $"Unsupported export format '{format}'. Use csv, jsonl, or parquet.", code = "BAD_REQUEST" },
@@ -243,8 +233,12 @@ public static class AuditEndpoints
filter = restricted;
var repo = context.RequestServices.GetRequiredService();
- var contentType = format == "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson";
- var extension = format == "csv" ? "csv" : "jsonl";
+ var (contentType, extension) = format switch
+ {
+ "csv" => ("text/csv; charset=utf-8", "csv"),
+ "parquet" => (ParquetContentType, "parquet"),
+ _ => ("application/x-ndjson", "jsonl"),
+ };
var fileName = $"audit-log-{DateTime.UtcNow:yyyyMMdd-HHmmss}.{extension}";
context.Response.ContentType = contentType;
@@ -256,6 +250,12 @@ public static class AuditEndpoints
{
await StreamCsvAsync(repo, filter, context.Response.Body, context.RequestAborted);
}
+ else if (format == "parquet")
+ {
+ // ScadaBridge#42: same 24 columns as csv/jsonl, one row group per 10k rows.
+ await AuditExportParquet.WriteAsync(
+ ExportRowPagesAsync(repo, filter, context.RequestAborted), context.Response.Body, context.RequestAborted);
+ }
else
{
await StreamJsonlAsync(repo, filter, context.Response.Body, context.RequestAborted);
@@ -494,6 +494,18 @@ public static class AuditEndpoints
}
}
+ /// Projects pages onto the flat export row shape.
+ private static async IAsyncEnumerable> ExportRowPagesAsync(
+ IAuditLogRepository repo,
+ AuditLogQueryFilter filter,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
+ {
+ await foreach (var page in PageAsync(repo, filter, ct))
+ {
+ yield return page.Select(AuditExportRow.From).ToList();
+ }
+ }
+
///
/// Lazily yields full repository pages, advancing the keyset cursor until a
/// short page signals the end. Shared by the csv + jsonl streamers.
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditExportParquet.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditExportParquet.cs
new file mode 100644
index 00000000..1a829b82
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditExportParquet.cs
@@ -0,0 +1,219 @@
+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();
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
index cafd61c4..d4175567 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
@@ -393,6 +393,11 @@ public class ManagementActor : ReceiveActor
// spans every site.
VerifyAuditChainCommand => OperationalAuditReaders,
+ // AuditLog Parquet archives (ScadaBridge#42): audit reads with verify-chain's gate —
+ // OperationalAudit roles, site-scoped callers refused in the handler.
+ ListAuditArchivesCommand => OperationalAuditReaders,
+ VerifyAuditArchiveCommand => OperationalAuditReaders,
+
// Read-only queries -- any authenticated user
_ => null
};
@@ -564,6 +569,8 @@ public class ManagementActor : ReceiveActor
// Audit Log
QueryAuditLogCommand cmd => await HandleQueryAuditLog(sp, cmd),
VerifyAuditChainCommand cmd => await HandleVerifyAuditChain(sp, cmd, user),
+ ListAuditArchivesCommand => await HandleListAuditArchives(sp, user),
+ VerifyAuditArchiveCommand cmd => await HandleVerifyAuditArchive(sp, cmd, user),
// Health
GetHealthSummaryCommand => HandleGetHealthSummary(sp),
@@ -3256,6 +3263,59 @@ public class ManagementActor : ReceiveActor
}
}
+ ///
+ /// Lists the AuditLog Parquet archives (ScadaBridge#42). Refused for a site-scoped,
+ /// non-Administrator caller: an archive holds every site's rows.
+ ///
+ private static async Task