using GB5Shared.IceMap; using GB5Shared.DTO.Ice; using GB5Shared.DTO.Framework.Login; using GB5Shared.ExcelExport; using GB5Shared.Storage; using GB5Shared.Telemetry; using IceImportBLL.IceImportPipeline; using IceImportBLL.IceImportRunNotifier; using IceImportBLL.IceImportUploadStagingService; using IceImportDAL.DTO.IceImportPipeline; using IceImportDAL.DTO.IceImportRun; using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace IceImportBLL.IceImportRun; public class IceImportRunExecutionService : IIceImportRunExecutionService { private readonly IIceMapBLL _iceMapBLL; private readonly IIceImportPipeline _pipeline; private readonly IIceImportUploadStagingService _staging; private readonly IStorageProvider _storage; private readonly IIceImportRunBLL _runBll; private readonly IIceImportRunNotifier _notifier; private readonly IExcelExport _excelExport; private readonly IIceImportRunCancellationRegistry _cancellationRegistry; private readonly ILogger _logger; // Individual per-error SignalR pushes are capped — a bad file can produce thousands of // findings, and CLAUDE.md's backpressure guidance says throttle, not flood. The full list is // always persisted to TICEIMPORTRUNROWRESULT (and the ErrorReport artifact) regardless of this // cap; only the *live* push is capped. private const int MaxLiveRowErrorPushes = 50; public IceImportRunExecutionService( IIceMapBLL iceMapBLL, IIceImportPipeline pipeline, IIceImportUploadStagingService staging, IStorageProvider storage, IIceImportRunBLL runBll, IIceImportRunNotifier notifier, IExcelExport excelExport, IIceImportRunCancellationRegistry cancellationRegistry, ILogger logger) { _iceMapBLL = iceMapBLL; _pipeline = pipeline; _staging = staging; _storage = storage; _runBll = runBll; _notifier = notifier; _excelExport = excelExport; _cancellationRegistry = cancellationRegistry; _logger = logger; } public async Task ExecuteTrackedRunAsync( long runId, int iceMapId, string? uploadToken, string? sourceStorageKey, LoginDTO login, CancellationToken ct) { var token = _cancellationRegistry.Register(runId, ct); try { await _runBll.AppendLogAsync(runId, "Info", "Run started.", login, token).ConfigureAwait(false); var mapDefinition = await LoadMapDefinitionAsync(iceMapId, login).ConfigureAwait(false); // ── Stage: Parse ──────────────────────────────────────────────── await _runBll.UpdateStageAsync(runId, "Parse", login, token).ConfigureAwait(false); await _notifier.NotifyProgressAsync(runId, "Parse", 0, 0, token).ConfigureAwait(false); var (stream, fileName) = await OpenSourceAsync(uploadToken, sourceStorageKey, iceMapId, login, token) .ConfigureAwait(false); ParsedImportDataSetDTO parsed; await using (stream) { parsed = await _pipeline.ParseAsync(stream, fileName, iceMapId, login, token).ConfigureAwait(false); } await _runBll.AppendLogAsync(runId, "Info", "Parse stage complete.", login, token).ConfigureAwait(false); // ── Stage: MapAndTransform ─────────────────────────────────────── await _runBll.UpdateStageAsync(runId, "MapAndTransform", login, token).ConfigureAwait(false); await _notifier.NotifyProgressAsync(runId, "MapAndTransform", 0, 0, token).ConfigureAwait(false); var rows = await _pipeline.MapAndTransformAsync(parsed, mapDefinition, login, token).ConfigureAwait(false); // Two distinct counts, deliberately kept separate in the Summary log: // - totalExcelRecords: raw physical rows in the source file (IIceImportPipeline. // CountSourceRecords), BEFORE any object/List (BOM-style) dedup. // - totalHeaderRows: rows.Count — the POST-dedup header count, i.e. how many times the // header entity's own Save/Update service call was actually made. Equal to // totalExcelRecords only when this map has no dedup relationship. // CountRows(rows) below is neither of these — it recurses into every child/detail level // too, which is what totalRows (progress-bar tracking) needs. var totalExcelRecords = _pipeline.CountSourceRecords(parsed, mapDefinition); var totalHeaderRows = rows.Count; var totalRows = CountRows(rows); await _notifier.NotifyProgressAsync(runId, "MapAndTransform", totalRows, totalRows, token).ConfigureAwait(false); await _runBll.AppendLogAsync(runId, "Info", $"MapAndTransform stage complete — {totalRows} row(s) mapped.", login, token).ConfigureAwait(false); // ── Stage: Validate ────────────────────────────────────────────── await _runBll.UpdateStageAsync(runId, "Validate", login, token).ConfigureAwait(false); await _notifier.NotifyProgressAsync(runId, "Validate", 0, totalRows, token).ConfigureAwait(false); var validationResults = await _pipeline.ValidateAsync(rows, parsed, mapDefinition, login, token) .ConfigureAwait(false); var pushed = 0; foreach (var vr in validationResults) { foreach (var err in vr.Errors) { if (pushed >= MaxLiveRowErrorPushes) break; await _notifier.NotifyRowErrorAsync(runId, err, token).ConfigureAwait(false); pushed++; } if (pushed >= MaxLiveRowErrorPushes) break; } await _notifier.NotifyProgressAsync(runId, "Validate", totalRows, totalRows, token).ConfigureAwait(false); await _runBll.AppendLogAsync(runId, "Info", $"Validate stage complete — {validationResults.Count} row(s) have findings.", login, token) .ConfigureAwait(false); // One TICEIMPORTRUNLOG row per column (FieldName), not one per unresolved value — every // row is validated regardless of earlier findings, then all Code/Name -> Id "not found" // findings across the whole file are grouped by column and logged together. await LogGroupedLookupFindingsAsync(runId, validationResults, login, token).ConfigureAwait(false); // Any Error-severity finding aborts the whole run — no partial commits. A bad Code/Name // -> Id lookup (or any other Error-severity validator) means at least one row would be // committed with invalid/missing data; better to reject the whole batch up front and // let the user fix the source file than to silently commit a mix of good and bad rows. if (validationResults.Any(r => r.HasErrors)) { var erroredRowResults = validationResults .Where(r => r.HasErrors) .Select(r => new IceImportRunRowResultDTO { RowNumber = r.RowNumber, EntityCode = r.EntityCode, Status = "Error", ErrorMessage = string.Join(" | ", r.Errors.Select(e => $"{e.FieldName}: {e.Message}")) }) .ToList(); await _runBll.SaveRowResultsAsync(runId, erroredRowResults, login, token).ConfigureAwait(false); var abortedCommitResult = new IceImportCommitResultDTO { TotalRows = totalRows, ValidationErrors = validationResults.SelectMany(r => r.Errors).ToList() }; try { var storageKey = await GenerateErrorReportAsync( runId, iceMapId, abortedCommitResult, erroredRowResults, login, token).ConfigureAwait(false); await _runBll.SaveArtifactAsync(runId, "ErrorReport", storageKey, login, token).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.RecordError(ex, "ice-import-error-report-generation-failed"); _logger.LogWarning(ex, "IceImport: error-report generation failed for RunId {RunId}", runId); } var abortMessage = $"Import aborted — {erroredRowResults.Count} row(s) failed validation. No rows were committed."; await _runBll.AppendLogAsync(runId, "Error", abortMessage, login, token).ConfigureAwait(false); // Validation-abort is all-or-nothing — none of the parsed rows were actually // committed, so every header counts as a failure in this summary. await _runBll.AppendLogAsync(runId, "Info", $"Summary — Records in Excel: {totalExcelRecords}, Headers: {totalHeaderRows}, Success: 0, Failure: {totalHeaderRows}.", login, token).ConfigureAwait(false); await _runBll.CompleteRunAsync( runId, "Failed", totalRows, 0, erroredRowResults.Count, abortMessage, login, CancellationToken.None) .ConfigureAwait(false); return; } // ── Stage: Commit ──────────────────────────────────────────────── await _runBll.UpdateStageAsync(runId, "Commit", login, token).ConfigureAwait(false); await _notifier.NotifyProgressAsync(runId, "Commit", 0, totalRows, token).ConfigureAwait(false); var commitResult = await _pipeline.CommitAsync(rows, validationResults, mapDefinition, login, runId, token) .ConfigureAwait(false); await _notifier.NotifyProgressAsync( runId, "Commit", commitResult.SuccessCount + commitResult.FailedCount + commitResult.SkippedCount, totalRows, token).ConfigureAwait(false); var rowResults = BuildRowResults(commitResult, validationResults); if (rowResults.Count > 0) await _runBll.SaveRowResultsAsync(runId, rowResults, login, token).ConfigureAwait(false); var failedCount = commitResult.FailedCount + commitResult.SkippedCount; var finalStatus = failedCount > 0 ? "Failed" : "Success"; var errorMessage = failedCount > 0 ? $"{commitResult.FailedCount} failed, {commitResult.SkippedCount} skipped (validation errors) out of {commitResult.TotalRows} total row(s)." : null; var hasFindings = commitResult.ValidationErrors.Count > 0 || rowResults.Any(r => r.Status != "Success"); if (hasFindings) { try { var storageKey = await GenerateErrorReportAsync(runId, iceMapId, commitResult, rowResults, login, token) .ConfigureAwait(false); await _runBll.SaveArtifactAsync(runId, "ErrorReport", storageKey, login, token).ConfigureAwait(false); } catch (Exception ex) { // Error-report generation is a nice-to-have, not load-bearing — never let a // report-generation failure mask the run's real outcome. GB5Trace.RecordError(ex, "ice-import-error-report-generation-failed"); _logger.LogWarning(ex, "IceImport: error-report generation failed for RunId {RunId}", runId); } } await _runBll.AppendLogAsync(runId, "Info", $"Run finished with status {finalStatus}.", login, token) .ConfigureAwait(false); await _runBll.AppendLogAsync(runId, "Info", $"Summary — Records in Excel: {totalExcelRecords}, Headers: {totalHeaderRows}, Success: {commitResult.SuccessCount}, Failure: {failedCount}.", login, token).ConfigureAwait(false); await _runBll.CompleteRunAsync( runId, finalStatus, commitResult.TotalRows, commitResult.SuccessCount, failedCount, errorMessage, login, CancellationToken.None).ConfigureAwait(false); } catch (OperationCanceledException) { _logger.LogInformation("IceImport run {RunId} was cancelled.", runId); try { await _runBll.AppendLogAsync(runId, "Warning", "Run was cancelled.", login, CancellationToken.None) .ConfigureAwait(false); await _runBll.CompleteRunAsync(runId, "Cancelled", 0, 0, 0, "Run was cancelled.", login, CancellationToken.None) .ConfigureAwait(false); } catch (Exception completeEx) { _logger.LogError(completeEx, "IceImport: failed to record cancellation for RunId {RunId}", runId); } } catch (Exception ex) { GB5Trace.MarkFailed("ice-import-run-execution-failed", ex); _logger.LogError(ex, "IceImport run {RunId} failed.", runId); try { await _runBll.CompleteRunAsync(runId, "Failed", 0, 0, 0, ex.Message, login, CancellationToken.None) .ConfigureAwait(false); } catch (Exception completeEx) { // Never let the background task die silently — this is the last line of defense; // if even CompleteRunAsync throws, the run is stuck in Running/Queued until an // operator investigates, but it is logged loudly here rather than swallowed. _logger.LogError(completeEx, "IceImport: failed to record failure for RunId {RunId} (original error: {OriginalError})", runId, ex.Message); } } finally { _cancellationRegistry.Complete(runId); } } public async Task ExecuteTrackedUpdateRunAsync( long runId, int iceMapId, string uploadToken, LoginDTO login, CancellationToken ct) { var token = _cancellationRegistry.Register(runId, ct); try { await _runBll.AppendLogAsync(runId, "Info", "Update run started.", login, token).ConfigureAwait(false); var mapDefinition = await LoadMapDefinitionAsync(iceMapId, login).ConfigureAwait(false); // ── Stage: Parse ──────────────────────────────────────────────── await _runBll.UpdateStageAsync(runId, "Parse", login, token).ConfigureAwait(false); await _notifier.NotifyProgressAsync(runId, "Parse", 0, 0, token).ConfigureAwait(false); var (stream, fileName) = await OpenSourceAsync(uploadToken, null, iceMapId, login, token) .ConfigureAwait(false); ParsedImportDataSetDTO parsed; await using (stream) { parsed = await _pipeline.ParseAsync(stream, fileName, iceMapId, login, token).ConfigureAwait(false); } await _runBll.AppendLogAsync(runId, "Info", "Parse stage complete.", login, token).ConfigureAwait(false); // ── Stage: MapAndTransform ─────────────────────────────────────── await _runBll.UpdateStageAsync(runId, "MapAndTransform", login, token).ConfigureAwait(false); await _notifier.NotifyProgressAsync(runId, "MapAndTransform", 0, 0, token).ConfigureAwait(false); // isUpdateMode: true — the one behavioral difference from ExecuteTrackedRunAsync's own // MapAndTransformAsync call — makes ValueType 8/9 (FixedDuringUpdateResolver/ // GlobalDuringUpdateResolver) actually resolve instead of null. var rows = await _pipeline .MapAndTransformAsync(parsed, mapDefinition, login, token, isUpdateMode: true) .ConfigureAwait(false); // Two distinct counts — see ExecuteTrackedRunAsync's identical note. var totalExcelRecords = _pipeline.CountSourceRecords(parsed, mapDefinition); var totalHeaderRows = rows.Count; var totalRows = CountRows(rows); await _notifier.NotifyProgressAsync(runId, "MapAndTransform", totalRows, totalRows, token).ConfigureAwait(false); await _runBll.AppendLogAsync(runId, "Info", $"MapAndTransform stage complete — {totalRows} row(s) mapped.", login, token).ConfigureAwait(false); // ── Stage: Validate ────────────────────────────────────────────── await _runBll.UpdateStageAsync(runId, "Validate", login, token).ConfigureAwait(false); await _notifier.NotifyProgressAsync(runId, "Validate", 0, totalRows, token).ConfigureAwait(false); var validationResults = await _pipeline.ValidateAsync(rows, parsed, mapDefinition, login, token) .ConfigureAwait(false); var pushed = 0; foreach (var vr in validationResults) { foreach (var err in vr.Errors) { if (pushed >= MaxLiveRowErrorPushes) break; await _notifier.NotifyRowErrorAsync(runId, err, token).ConfigureAwait(false); pushed++; } if (pushed >= MaxLiveRowErrorPushes) break; } await _notifier.NotifyProgressAsync(runId, "Validate", totalRows, totalRows, token).ConfigureAwait(false); await _runBll.AppendLogAsync(runId, "Info", $"Validate stage complete — {validationResults.Count} row(s) have findings.", login, token) .ConfigureAwait(false); // Same grouped Code/Name -> Id "not found" logging, and the same all-or-nothing // abort-on-Error-severity policy, as ExecuteTrackedRunAsync. await LogGroupedLookupFindingsAsync(runId, validationResults, login, token).ConfigureAwait(false); if (validationResults.Any(r => r.HasErrors)) { var erroredRowResults = validationResults .Where(r => r.HasErrors) .Select(r => new IceImportRunRowResultDTO { RowNumber = r.RowNumber, EntityCode = r.EntityCode, Status = "Error", ErrorMessage = string.Join(" | ", r.Errors.Select(e => $"{e.FieldName}: {e.Message}")) }) .ToList(); await _runBll.SaveRowResultsAsync(runId, erroredRowResults, login, token).ConfigureAwait(false); var abortedCommitResult = new IceImportCommitResultDTO { TotalRows = totalRows, ValidationErrors = validationResults.SelectMany(r => r.Errors).ToList() }; try { var storageKey = await GenerateErrorReportAsync( runId, iceMapId, abortedCommitResult, erroredRowResults, login, token).ConfigureAwait(false); await _runBll.SaveArtifactAsync(runId, "ErrorReport", storageKey, login, token).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.RecordError(ex, "ice-import-error-report-generation-failed"); _logger.LogWarning(ex, "IceImport: error-report generation failed for RunId {RunId}", runId); } var abortMessage = $"Update aborted — {erroredRowResults.Count} row(s) failed validation. No rows were updated."; await _runBll.AppendLogAsync(runId, "Error", abortMessage, login, token).ConfigureAwait(false); // Validation-abort is all-or-nothing — none of the parsed rows were actually // updated, so every header counts as a failure in this summary. await _runBll.AppendLogAsync(runId, "Info", $"Summary — Records in Excel: {totalExcelRecords}, Headers: {totalHeaderRows}, Success: 0, Failure: {totalHeaderRows}.", login, token).ConfigureAwait(false); await _runBll.CompleteRunAsync( runId, "Failed", totalRows, 0, erroredRowResults.Count, abortMessage, login, CancellationToken.None) .ConfigureAwait(false); return; } // ── Stage: Commit (as UPDATE) ──────────────────────────────────── await _runBll.UpdateStageAsync(runId, "Commit", login, token).ConfigureAwait(false); await _notifier.NotifyProgressAsync(runId, "Commit", 0, totalRows, token).ConfigureAwait(false); var commitResult = await _pipeline .CommitAsUpdateAsync(rows, validationResults, mapDefinition, login, token) .ConfigureAwait(false); await _notifier.NotifyProgressAsync( runId, "Commit", commitResult.SuccessCount + commitResult.FailedCount + commitResult.SkippedCount, totalRows, token).ConfigureAwait(false); var rowResults = BuildRowResults(commitResult, validationResults); if (rowResults.Count > 0) await _runBll.SaveRowResultsAsync(runId, rowResults, login, token).ConfigureAwait(false); var failedCount = commitResult.FailedCount + commitResult.SkippedCount; var finalStatus = failedCount > 0 ? "Failed" : "Success"; var errorMessage = failedCount > 0 ? $"{commitResult.FailedCount} failed, {commitResult.SkippedCount} skipped (validation errors) out of {commitResult.TotalRows} total row(s)." : null; var hasFindings = commitResult.ValidationErrors.Count > 0 || rowResults.Any(r => r.Status != "Success"); if (hasFindings) { try { var storageKey = await GenerateErrorReportAsync(runId, iceMapId, commitResult, rowResults, login, token) .ConfigureAwait(false); await _runBll.SaveArtifactAsync(runId, "ErrorReport", storageKey, login, token).ConfigureAwait(false); } catch (Exception ex) { GB5Trace.RecordError(ex, "ice-import-error-report-generation-failed"); _logger.LogWarning(ex, "IceImport: error-report generation failed for RunId {RunId}", runId); } } await _runBll.AppendLogAsync(runId, "Info", $"Update run finished with status {finalStatus}.", login, token) .ConfigureAwait(false); await _runBll.AppendLogAsync(runId, "Info", $"Summary — Records in Excel: {totalExcelRecords}, Headers: {totalHeaderRows}, Success: {commitResult.SuccessCount}, Failure: {failedCount}.", login, token).ConfigureAwait(false); await _runBll.CompleteRunAsync( runId, finalStatus, commitResult.TotalRows, commitResult.SuccessCount, failedCount, errorMessage, login, CancellationToken.None).ConfigureAwait(false); } catch (OperationCanceledException) { _logger.LogInformation("IceImport update run {RunId} was cancelled.", runId); try { await _runBll.AppendLogAsync(runId, "Warning", "Run was cancelled.", login, CancellationToken.None) .ConfigureAwait(false); await _runBll.CompleteRunAsync(runId, "Cancelled", 0, 0, 0, "Run was cancelled.", login, CancellationToken.None) .ConfigureAwait(false); } catch (Exception completeEx) { _logger.LogError(completeEx, "IceImport: failed to record cancellation for RunId {RunId}", runId); } } catch (Exception ex) { GB5Trace.MarkFailed("ice-import-update-run-execution-failed", ex); _logger.LogError(ex, "IceImport update run {RunId} failed.", runId); try { await _runBll.CompleteRunAsync(runId, "Failed", 0, 0, 0, ex.Message, login, CancellationToken.None) .ConfigureAwait(false); } catch (Exception completeEx) { _logger.LogError(completeEx, "IceImport: failed to record failure for RunId {RunId} (original error: {OriginalError})", runId, ex.Message); } } finally { _cancellationRegistry.Complete(runId); } } private async Task LoadMapDefinitionAsync(int iceMapId, LoginDTO login) { var json = await _iceMapBLL.GetIceMap(iceMapId, login).ConfigureAwait(false); return JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException($"IceMap {iceMapId} was not found."); } private async Task<(Stream Stream, string FileName)> OpenSourceAsync( string? uploadToken, string? sourceStorageKey, int iceMapId, LoginDTO login, CancellationToken ct) { if (!string.IsNullOrWhiteSpace(uploadToken)) { var staged = await _staging.OpenStagedAsync(uploadToken, iceMapId, login, ct).ConfigureAwait(false); return (staged.Stream, staged.FileName); } if (!string.IsNullOrWhiteSpace(sourceStorageKey)) { var stream = await _storage.GetStreamAsync(sourceStorageKey, ct).ConfigureAwait(false); return (stream, Path.GetFileName(sourceStorageKey)); } throw new InvalidOperationException( "ExecuteTrackedRunAsync requires either an uploadToken (CommitImport) or a sourceStorageKey (RetryImportRun)."); } // Groups Code/Name -> Id "not found" findings by column (FieldName) and writes one // TICEIMPORTRUNLOG row per column instead of one per unresolved value — e.g. column A with 3 // not-found values and column B with 1 produce exactly two log rows, each LogLevel="Validation" // with Message a JSON array of {FieldName, Value, RowNumber}. IceImportFieldErrorDTO.Value is // only ever populated by CodeToIdLookupResolver/NameToIdLookupResolver, so filtering on it // excludes every other kind of validation finding (header checks, other IRowValidators, ...). private async Task LogGroupedLookupFindingsAsync( long runId, List validationResults, LoginDTO login, CancellationToken ct) { var lookupFindings = validationResults .SelectMany(r => r.Errors) .Where(e => !string.IsNullOrEmpty(e.Value)) .ToList(); foreach (var group in lookupFindings.GroupBy(e => e.FieldName)) { var entries = group .OrderBy(e => e.RowNumber) .Select(e => new { e.FieldName, e.Value, e.RowNumber }) .ToList(); var json = JsonConvert.SerializeObject(entries); await _runBll.AppendLogAsync(runId, "Validation", json, login, ct).ConfigureAwait(false); } } private static int CountRows(IEnumerable rows) { var count = 0; foreach (var row in rows) { count++; foreach (var childList in row.Children.Values) count += CountRows(childList); } return count; } // Built primarily from commitResult.RowResults (every row Commit actually attempted — // Success or Error, including rows skipped for Error-severity validation findings). Rows with // only Warning/Advisory findings that were NOT skipped (so never appear in RowResults) get a // separate Status="Warning" entry here so they still show up in GetImportRunRowErrors. private static List BuildRowResults( IceImportCommitResultDTO commitResult, List validationResults) { var results = new List(); var alreadyRepresented = new HashSet(); foreach (var rr in commitResult.RowResults) { alreadyRepresented.Add(rr.CorrelationKey); results.Add(new IceImportRunRowResultDTO { RowNumber = rr.RowNumber, EntityCode = rr.EntityCode, Status = rr.Success ? "Success" : "Error", TargetEntityId = rr.TargetEntityId, PostData = rr.PostData, ErrorMessage = rr.ErrorMessage }); } foreach (var vr in validationResults) { if (alreadyRepresented.Contains(vr.CorrelationKey) || vr.Errors.Count == 0) continue; results.Add(new IceImportRunRowResultDTO { RowNumber = vr.RowNumber, EntityCode = vr.EntityCode, Status = "Warning", ErrorMessage = string.Join(" | ", vr.Errors.Select(e => $"{e.FieldName}: {e.Message}")) }); } return results; } private async Task GenerateErrorReportAsync( long runId, int iceMapId, IceImportCommitResultDTO commitResult, List rowResults, LoginDTO login, CancellationToken ct) { var columnHeaders = new List { "RowNumber", "EntityCode", "Severity", "Message" }; var records = new List>(); foreach (var err in commitResult.ValidationErrors) records.Add(new Dictionary { ["RowNumber"] = err.RowNumber, ["EntityCode"] = err.EntityCode, ["Severity"] = err.Severity.ToString(), ["Message"] = $"{err.FieldName}: {err.Message}" }); foreach (var rr in rowResults.Where(r => r.Status != "Success")) records.Add(new Dictionary { ["RowNumber"] = rr.RowNumber, ["EntityCode"] = rr.EntityCode, ["Severity"] = rr.Status, ["Message"] = rr.ErrorMessage }); await using var ms = new MemoryStream(); await _excelExport.Export( ToAsyncEnumerable(records), ToAsyncEnumerable(Enumerable.Empty()), ToAsyncEnumerable(columnHeaders), ToAsyncEnumerable(columnHeaders), ms, login, "ErrorReport", ct).ConfigureAwait(false); ms.Position = 0; var storageKey = $"iceimport/{login.ClientId}/{iceMapId}/runs/{runId}/error-report-{Guid.NewGuid():N}.xlsx"; await _storage.SaveAsync( ms, storageKey, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ct) .ConfigureAwait(false); return storageKey; } // Small local helper so we don't need to pull in the System.Linq.Async package just to satisfy // IExcelExport.Export's IAsyncEnumerable parameters from plain in-memory lists. private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable source) { foreach (var item in source) { yield return item; await Task.Yield(); } } }