From 59f0b65ad403f1f1afb84d4e4e24447655cc1fd0 Mon Sep 17 00:00:00 2001 From: "Ahmet Yildirim (from Dev Box)" Date: Fri, 18 Sep 2026 18:31:44 +0300 Subject: [PATCH] fix(traceparser-web): report import status and deletion failures accurately Use bounded DAB first queries and shared milestone checks, surface unavailable status, serialize status polling, and show deletion feedback above the trace list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7f085bfc-844d-4ec3-9391-b696cf6d0e6d --- .../Trace Parser/TraceParserWeb/README.md | 8 +- .../Components/Pages/Traces/TraceList.razor | 63 +++-- .../Components/Pages/Upload/EtlUpload.razor | 36 ++- .../Services/EtlUploadService.cs | 52 ++-- .../TraceParserWeb/Services/TraceService.cs | 43 +++- .../ImportStatusChecks.cs | 236 ++++++++++++++++++ .../TraceParserWeb.RegressionTests/Program.cs | 3 +- 7 files changed, 369 insertions(+), 72 deletions(-) create mode 100644 Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/ImportStatusChecks.cs diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/README.md b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/README.md index fe130145..acd7c789 100644 --- a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/README.md +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/README.md @@ -122,8 +122,14 @@ Deploy the updated **read-only** `TraceParserMCP/dab-config.json` as part of thi ### Deletion regression checks -The dependency-free console harness checks denied anonymous/wrong-tenant calls, ordinary tenant-user access, disabled configuration, incremental completion, failure/cancellation propagation, and read-only DAB permissions. It uses synthetic identities and a fake store, with no database or network calls: +The dependency-free console harness checks deletion authorization/completion, read-only DAB permissions, import-status query/error handling, visible deletion feedback, and non-overlapping status polls. It uses synthetic identities and fake stores/HTTP responses, with no database or network calls: ```powershell dotnet run --project .\tests\TraceParserWeb.RegressionTests -c Release ``` + +## Import status and database maintenance + +Status polling uses DAB's `$first` parameter, not OData's unsupported `$top`. The upload and trace-list pages share the same database-milestone checks. HTTP failures, timeouts and malformed responses are displayed as **status unavailable**, not as evidence that the Function is parsing or queued. Each page permits only one status poll at a time. + +Trace-list labels describe data availability, not a live worker heartbeat. For example, an empty trace has no parsed session data; that alone does not prove an ETL job is running. SQL index maintenance can delay status queries and deletion. Deletion errors appear above the trace list so a timeout is not hidden below other records. Do not treat a timeout as successful deletion or repeatedly submit deletes while maintenance is blocking SQL. diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Components/Pages/Traces/TraceList.razor b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Components/Pages/Traces/TraceList.razor index 1998b5ed..67e23b21 100644 --- a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Components/Pages/Traces/TraceList.razor +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Components/Pages/Traces/TraceList.razor @@ -28,6 +28,13 @@ + @if (!string.IsNullOrEmpty(_statusMessage)) + { +
+ @_statusMessage +
+ } + @if (_loading) {
@@ -89,7 +96,6 @@ else if (_importingStages.TryGetValue(trace.TraceId, out var stage)) {
- @GetImportStageText(stage)
} @@ -126,12 +132,6 @@
} - @if (!string.IsNullOrEmpty(_statusMessage)) - { -
- @_statusMessage -
- } @@ -147,6 +147,8 @@ string? _statusMessage; bool _statusIsError; System.Threading.Timer? _importPollTimer; + int _pollInProgress; + bool _disposed; protected override async Task OnInitializedAsync() { @@ -193,7 +195,9 @@ // Check stage for each trace without stats foreach (var trace in tracesWithoutStats) { + if (_disposed) return; var stage = await TraceSvc.GetImportStageAsync(trace.TraceId); + if (_disposed) return; if (stage != ImportStage.Complete) _importingStages[trace.TraceId] = stage; } @@ -201,20 +205,30 @@ if (_importingStages.Count == 0) return; // Start polling importing traces every 10s - _importPollTimer = new System.Threading.Timer(async _ => + _importPollTimer = new System.Threading.Timer(async _ => await PollImportStagesAsync(), + null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)); + } + + async Task PollImportStagesAsync() + { + if (Interlocked.CompareExchange(ref _pollInProgress, 1, 0) != 0) return; + try { - try + await InvokeAsync(async () => { + if (_disposed) return; var changed = false; foreach (var traceId in _importingStages.Keys.ToList()) { var stage = await TraceSvc.GetImportStageAsync(traceId); + if (_disposed) return; + if (!_importingStages.TryGetValue(traceId, out var previousStage)) continue; if (stage == ImportStage.Complete) { _importingStages.Remove(traceId); changed = true; } - else if (_importingStages[traceId] != stage) + else if (previousStage != stage) { _importingStages[traceId] = stage; changed = true; @@ -236,13 +250,17 @@ } } - await InvokeAsync(StateHasChanged); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Import poll error on Traces page"); - } - }, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)); + if (!_disposed) StateHasChanged(); + }); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Import poll error on Traces page"); + } + finally + { + Volatile.Write(ref _pollInProgress, 0); + } } void RequestDelete(int traceId) @@ -270,6 +288,7 @@ _statusIsError = false; _traces.RemoveAll(t => t.TraceId == traceId); _traceStats.Remove(traceId); + _importingStages.Remove(traceId); } catch (Exception ex) { @@ -304,14 +323,16 @@ static string GetImportStageText(ImportStage stage) => stage switch { ImportStage.WaitingForFunction => "Waiting for processing...", - ImportStage.Parsing => "Parsing ETL events...", - ImportStage.ProcessingDimensions => "Importing trace lines...", - ImportStage.Finalizing => "Finalizing...", - _ => "Importing..." + ImportStage.Parsing => "No parsed session data available yet.", + ImportStage.ProcessingDimensions => "Waiting for trace lines.", + ImportStage.Finalizing => "Waiting for session metrics.", + ImportStage.Unavailable => "Import status unavailable; the next poll will retry.", + _ => "Import status not confirmed." }; public void Dispose() { + _disposed = true; _importPollTimer?.Dispose(); } } diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Components/Pages/Upload/EtlUpload.razor b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Components/Pages/Upload/EtlUpload.razor index ec5fc5f2..f3f38eea 100644 --- a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Components/Pages/Upload/EtlUpload.razor +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Components/Pages/Upload/EtlUpload.razor @@ -176,6 +176,8 @@ long _uploadedBytes; string StatusMessage = ""; System.Threading.Timer? _pollTimer; + int _pollInProgress; + bool _disposed; DotNetObjectReference? _dotNetRef; ImportStage _currentStage = ImportStage.WaitingForFunction; DateTime _processingStartTime; @@ -278,11 +280,20 @@ Logger.LogInformation("Direct blob upload complete for session {Session}", SessionName); // Start polling for import stage - _pollTimer = new System.Threading.Timer(async _ => + _pollTimer = new System.Threading.Timer(async _ => await PollImportStatusAsync(), + null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + } + + async Task PollImportStatusAsync() + { + if (Interlocked.CompareExchange(ref _pollInProgress, 1, 0) != 0) return; + try { - try + await InvokeAsync(async () => { + if (_disposed) return; var status = await UploadSvc.GetImportStatusAsync(SessionName, CancellationToken.None); + if (_disposed) return; if (status.Stage != _currentStage) { @@ -300,13 +311,17 @@ StatusMessage = $"Import complete! Session '{SessionName}' is ready to query in Chat."; } - await InvokeAsync(StateHasChanged); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Status poll error for session {Session}", SessionName); - } - }, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + StateHasChanged(); + }); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Status poll error for session {Session}", SessionName); + } + finally + { + Volatile.Write(ref _pollInProgress, 0); + } } [JSInvokable] @@ -339,6 +354,7 @@ ImportStage.ProcessingDimensions => "Promoting trace lines and rebuilding indexes...", ImportStage.Finalizing => "Computing session aggregations...", ImportStage.Complete => "Import complete!", + ImportStage.Unavailable => "Import status unavailable; the next poll will retry.", _ => "Processing..." }; @@ -348,6 +364,7 @@ ImportStage.Parsing => "Parsing", ImportStage.ProcessingDimensions => "Importing", ImportStage.Finalizing => "Finalizing", + ImportStage.Unavailable => "Unavailable", _ => "" }; @@ -384,6 +401,7 @@ public void Dispose() { + _disposed = true; _pollTimer?.Dispose(); _dotNetRef?.Dispose(); } diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/EtlUploadService.cs b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/EtlUploadService.cs index e01e33c8..a322c6d6 100644 --- a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/EtlUploadService.cs +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/EtlUploadService.cs @@ -9,6 +9,7 @@ namespace TraceParserWeb.Services; public enum ImportStage { + Unavailable = -1, WaitingForFunction, Parsing, ProcessingDimensions, @@ -29,7 +30,11 @@ public class EtlImportOptions public string DabBaseUrl { get; set; } = ""; } -public class EtlUploadService(IOptions opts, IHttpClientFactory httpFactory) +public class EtlUploadService( + IOptions opts, + IHttpClientFactory httpFactory, + TraceService traceService, + ILogger logger) { /// /// Uploads an ETL file to Azure Blob Storage under {sessionName}/{fileName}. @@ -80,40 +85,29 @@ public async Task GetImportStatusAsync(string sessionName, Cancell { try { - var http = httpFactory.CreateClient("dab"); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(10)); + using var http = httpFactory.CreateClient("dab"); // Step 1: Find the trace - var url = $"/api/Traces?$filter=TraceName eq '{Uri.EscapeDataString(sessionName)}'"; - var resp = await http.GetFromJsonAsync(url, ct); - if (!resp.TryGetProperty("value", out var arr) || arr.GetArrayLength() == 0) + var escapedName = Uri.EscapeDataString(sessionName.Replace("'", "''")); + var url = $"/api/Traces?$filter=TraceName eq '{escapedName}'&$first=1&$select=TraceId"; + var resp = await http.GetFromJsonAsync(url, cts.Token); + var arr = TraceService.ReadRows(resp); + if (arr.GetArrayLength() == 0) return new ImportStatus { Stage = ImportStage.WaitingForFunction }; - var traceId = arr[0].GetProperty("TraceId").GetInt32(); - - // Step 2: Check threads exist (created during BulkInsertDimensions, after staging) - var threadUrl = $"/api/UserSessionProcessThreads?$filter=TraceId eq {traceId}&$top=1"; - var threadResp = await http.GetFromJsonAsync(threadUrl, ct); - if (!threadResp.TryGetProperty("value", out var threadArr) || threadArr.GetArrayLength() == 0) - return new ImportStatus { Stage = ImportStage.Parsing, TraceId = traceId }; - - // Step 3: Verify TraceLines exist (the final promote step) - var threadId = threadArr[0].GetProperty("UserSessionProcessThreadId").GetInt32(); - var tlUrl = $"/api/TraceLines?$filter=UserSessionProcessThreadId eq {threadId}&$top=1"; - var tlResp = await http.GetFromJsonAsync(tlUrl, ct); - if (!tlResp.TryGetProperty("value", out var tlArr) || tlArr.GetArrayLength() == 0) - return new ImportStatus { Stage = ImportStage.ProcessingDimensions, TraceId = traceId }; - - // Step 4: Check SessionMetrics exist (aggregation complete) - var smUrl = $"/api/SessionMetrics?$filter=TraceId eq {traceId}&$top=1"; - var smResp = await http.GetFromJsonAsync(smUrl, ct); - if (!smResp.TryGetProperty("value", out var smArr) || smArr.GetArrayLength() == 0) - return new ImportStatus { Stage = ImportStage.Finalizing, TraceId = traceId }; - - return new ImportStatus { Stage = ImportStage.Complete, TraceId = traceId }; + var traceId = TraceService.ReadId(arr[0], "TraceId"); + return new ImportStatus { + Stage = await traceService.GetImportStageAsync(traceId, cts.Token), + TraceId = traceId + }; } - catch + catch (Exception ex) when (ex is HttpRequestException or JsonException or OperationCanceledException) { - return new ImportStatus { Stage = ImportStage.WaitingForFunction }; + ct.ThrowIfCancellationRequested(); + logger.LogWarning(ex, "Import status lookup unavailable"); + return new ImportStatus { Stage = ImportStage.Unavailable }; } } diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/TraceService.cs b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/TraceService.cs index 555ac38e..e2a4dd6e 100644 --- a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/TraceService.cs +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/TraceService.cs @@ -35,7 +35,7 @@ public class TraceStats public int TotalDatabaseCalls { get; set; } } -public class TraceService(IHttpClientFactory httpFactory) +public class TraceService(IHttpClientFactory httpFactory, ILogger logger) { public async Task> GetTracesAsync(CancellationToken ct = default) { @@ -110,32 +110,53 @@ public async Task GetImportStageAsync(int traceId, CancellationToke using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(10)); - var http = httpFactory.CreateClient("dab"); + using var http = httpFactory.CreateClient("dab"); // Check USPT exists - var threadUrl = $"/api/UserSessionProcessThreads?$filter=TraceId eq {traceId}&$top=1"; + var threadUrl = $"/api/UserSessionProcessThreads?$filter=TraceId eq {traceId}&$first=1&$select=UserSessionProcessThreadId"; var threadResp = await http.GetFromJsonAsync(threadUrl, cts.Token); - if (!threadResp.TryGetProperty("value", out var threadArr) || threadArr.GetArrayLength() == 0) + var threadArr = ReadRows(threadResp); + if (threadArr.GetArrayLength() == 0) return ImportStage.Parsing; // Check TraceLines exist - var threadId = threadArr[0].GetProperty("UserSessionProcessThreadId").GetInt32(); - var tlUrl = $"/api/TraceLines?$filter=UserSessionProcessThreadId eq {threadId}&$top=1"; + var threadId = ReadId(threadArr[0], "UserSessionProcessThreadId"); + var tlUrl = $"/api/TraceLines?$filter=UserSessionProcessThreadId eq {threadId}&$first=1&$select=TraceLineId"; var tlResp = await http.GetFromJsonAsync(tlUrl, cts.Token); - if (!tlResp.TryGetProperty("value", out var tlArr) || tlArr.GetArrayLength() == 0) + if (ReadRows(tlResp).GetArrayLength() == 0) return ImportStage.ProcessingDimensions; // Check SessionMetrics exist - var smUrl = $"/api/SessionMetrics?$filter=TraceId eq {traceId}&$top=1"; + var smUrl = $"/api/SessionMetrics?$filter=TraceId eq {traceId}&$first=1&$select=TraceId"; var smResp = await http.GetFromJsonAsync(smUrl, cts.Token); - if (!smResp.TryGetProperty("value", out var smArr) || smArr.GetArrayLength() == 0) + if (ReadRows(smResp).GetArrayLength() == 0) return ImportStage.Finalizing; return ImportStage.Complete; } - catch + catch (Exception ex) when (ex is HttpRequestException or JsonException or OperationCanceledException) { - return ImportStage.Parsing; + ct.ThrowIfCancellationRequested(); + logger.LogWarning(ex, "Import status unavailable for trace {TraceId}", traceId); + return ImportStage.Unavailable; } } + + internal static JsonElement ReadRows(JsonElement response) + { + if (response.ValueKind != JsonValueKind.Object + || !response.TryGetProperty("value", out var rows) + || rows.ValueKind != JsonValueKind.Array) + throw new JsonException("DAB returned an invalid row collection."); + return rows; + } + + internal static int ReadId(JsonElement row, string property) + { + if (row.ValueKind != JsonValueKind.Object + || !row.TryGetProperty(property, out var id) + || id.ValueKind != JsonValueKind.Number || !id.TryGetInt32(out var value)) + throw new JsonException("DAB returned an invalid identifier."); + return value; + } } diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/ImportStatusChecks.cs b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/ImportStatusChecks.cs new file mode 100644 index 00000000..74d492fd --- /dev/null +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/ImportStatusChecks.cs @@ -0,0 +1,236 @@ +#pragma warning disable BL0006 +using System.Net; +using System.Reflection; +using System.Security.Claims; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using TraceParserWeb.Services; +using TraceListPage = TraceParserWeb.Components.Pages.Traces.TraceList; +using UploadPage = TraceParserWeb.Components.Pages.Upload.EtlUpload; + +static class ImportStatusChecks +{ + const string Empty = """{"value":[]}"""; + const string Thread = """{"value":[{"UserSessionProcessThreadId":7}]}"""; + const string Line = """{"value":[{"TraceLineId":9}]}"""; + const string Trace = """{"value":[{"TraceId":42}]}"""; + + public static async Task RunAsync() + { + var passed = 0; + foreach (var test in new[] { + (ImportStage.Parsing, new[] { Empty }), + (ImportStage.ProcessingDimensions, new[] { Thread, Empty }), + (ImportStage.Finalizing, new[] { Thread, Line, Empty }), + (ImportStage.Complete, new[] { Thread, Line, Trace }) }) + { + using var http = new StatusHttp(test.Item2); + var stage = await Service(http).GetImportStageAsync(42); + Check(stage == test.Item1 && http.Requests.Count == test.Item2.Length, "Incorrect import milestone"); + Check(http.Requests.All(uri => uri.Contains("$first=1") && uri.Contains("$select=") + && !uri.Contains("$top")), "Status query used unsupported or unbounded parameters"); + passed++; + } + foreach (var body in new[] { "not-json", "null", "{}", """{"value":null}""", """{"value":[{}]}""" }) + { + using var http = new StatusHttp(body); + Check(await Service(http).GetImportStageAsync(42) == ImportStage.Unavailable, + "Malformed response falsely indicated parsing"); + passed++; + } + foreach (var failure in new Exception[] { new HttpRequestException("Synthetic outage"), new TaskCanceledException() }) + { + using var http = new StatusHttp(Empty) { Failure = failure }; + Check(await Service(http).GetImportStageAsync(42) == ImportStage.Unavailable, + "Transport failure falsely indicated parsing"); + passed++; + } + { + using var http = new StatusHttp(Empty) { Status = HttpStatusCode.BadRequest }; + Check(await Service(http).GetImportStageAsync(42) == ImportStage.Unavailable, + "HTTP 400 falsely indicated parsing"); + passed++; + } + { + using var http = new StatusHttp(Empty); + using var ct = new CancellationTokenSource(); + ct.Cancel(); + var cancelled = false; + try { await Service(http).GetImportStageAsync(42, ct.Token); } + catch (OperationCanceledException) { cancelled = true; } + Check(cancelled, "Caller cancellation was swallowed"); + passed++; + } + foreach (var malformed in new[] { "{}", "not-json", """{"value":[{}]}""" }) + { + using var http = new StatusHttp(malformed); + Check((await UploadService(http).GetImportStatusAsync("synthetic", default)).Stage == ImportStage.Unavailable, + "Malformed upload status falsely indicated a queued job"); + passed++; + } + { + using var http = new StatusHttp(Empty) { Status = HttpStatusCode.ServiceUnavailable }; + Check((await UploadService(http).GetImportStatusAsync("synthetic", default)).Stage == ImportStage.Unavailable, + "Upload status hid HTTP failure"); + passed++; + } + { + using var http = new StatusHttp(Empty); + Check((await UploadService(http).GetImportStatusAsync("synthetic", default)).Stage == ImportStage.WaitingForFunction, + "Absent trace did not remain pending"); + passed++; + } + { + using var http = new StatusHttp(Trace, Thread, Line, Trace); + var status = await UploadService(http).GetImportStatusAsync("O'Neil", default); + Check(status.Stage == ImportStage.Complete && status.TraceId == 42, "Upload status did not reuse milestone checks"); + Check(http.Requests[0].Contains("TraceName eq 'O''Neil'"), "Trace name literal was not escaped"); + Check(http.Requests.Count == 4 && http.Requests.All(uri => uri.Contains("$first=1")), "Upload queries are not bounded"); + passed++; + } + { + using var http = new StatusHttp(Trace, Empty) { FailAt = 2 }; + Check((await UploadService(http).GetImportStatusAsync("synthetic", default)).Stage == ImportStage.Unavailable, + "Nested status failure became a progress state"); + passed++; + } + { + var page = new TraceListPage(); + Set(page, "_loading", false); + Set(page, "_statusMessage", "synthetic deletion failure"); + Set(page, "_statusIsError", true); + Set(page, "_traces", new List { new() { TraceId = 42, TraceName = "synthetic trace row" } }); + Set(page, "_importingStages", new Dictionary { [42] = ImportStage.Parsing }); + Set(page, "DeletionSvc", new TraceDeletionService(new ProbeStore(false), + new ProbeAuthentication(new ClaimsPrincipal()), + Options.Create(new TraceAdministrationOptions { SqlConnectionString = "synthetic" }), + new ConfigurationBuilder().Build(), NullLogger.Instance)); + using var builder = new RenderTreeBuilder(); + Invoke(page, "BuildRenderTree", builder); + var frames = builder.GetFrames(); + var text = string.Concat(frames.Array.Take(frames.Count).Select(frame => + frame.FrameType == RenderTreeFrameType.Text ? frame.TextContent : + frame.FrameType == RenderTreeFrameType.Markup ? frame.MarkupContent : "")); + Check(text.IndexOf("synthetic deletion failure", StringComparison.Ordinal) >= 0 + && text.IndexOf("synthetic deletion failure", StringComparison.Ordinal) + < text.IndexOf("synthetic trace row", StringComparison.Ordinal), "Deletion feedback is below the trace list"); + Check(!text.Contains("Parsing ETL events") && !text.Contains("importing-spinner"), + "Missing data was rendered as an active parser"); + page.Dispose(); + passed++; + } + { + var page = new UploadPage(); + Set(page, "_currentStage", ImportStage.Unavailable); + foreach (var stage in new[] { ImportStage.WaitingForFunction, ImportStage.Parsing, + ImportStage.ProcessingDimensions, ImportStage.Finalizing }) + Check((string)Invoke(page, "GetDotClass", stage)! == "stage-pending", + "Unavailable status falsely marked a stage complete"); + page.Dispose(); + passed++; + } + foreach (var upload in new[] { false, true }) + { + using var http = new StatusHttp(Empty) { Block = true }; + using var services = new ServiceCollection().BuildServiceProvider(); + await using var renderer = new StatusRenderer(services); + IComponent page; + string method; + if (upload) + { + var component = new UploadPage(); + Set(component, "UploadSvc", UploadService(http)); + Set(component, "Logger", NullLogger.Instance); + Set(component, "SessionName", "synthetic"); + page = component; + method = "PollImportStatusAsync"; + } + else + { + var component = new TraceListPage(); + Set(component, "TraceSvc", Service(http)); + Set(component, "Logger", NullLogger.Instance); + Set(component, "_importingStages", new Dictionary { [42] = ImportStage.Parsing }); + page = component; + method = "PollImportStagesAsync"; + } + await renderer.Dispatcher.InvokeAsync(() => renderer.Attach(page)); + var first = (Task)Invoke(page, method)!; + await http.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await ((Task)Invoke(page, method)!).WaitAsync(TimeSpan.FromSeconds(5)); + Check(http.Requests.Count == 1, "Status polls overlapped"); + await renderer.Dispatcher.InvokeAsync(((IDisposable)page).Dispose); + http.Release.SetResult(); + await first.WaitAsync(TimeSpan.FromSeconds(5)); + passed++; + } + return passed; + } + + static TraceService Service(StatusHttp http) => new(http, NullLogger.Instance); + static EtlUploadService UploadService(StatusHttp http) => new( + Options.Create(new EtlImportOptions()), http, Service(http), NullLogger.Instance); + + static void Check(bool condition, string message) + { + if (!condition) throw new Exception(message); + } + + static void Set(object instance, string name, object value) + { + var flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + var field = instance.GetType().GetField(name, flags); + if (field is not null) field.SetValue(instance, value); + else (instance.GetType().GetProperty(name, flags) + ?? throw new Exception($"Missing property {name}")).SetValue(instance, value); + } + + static object? Invoke(object instance, string name, params object[] args) => + (instance.GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new Exception($"Missing method {name}")).Invoke(instance, args); +} + +sealed class StatusHttp(params string[] responses) : HttpMessageHandler, IHttpClientFactory +{ + public List Requests { get; } = new(); + public HttpStatusCode Status { get; init; } = HttpStatusCode.OK; + public Exception? Failure { get; init; } + public int FailAt { get; init; } + public bool Block { get; init; } + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public HttpClient CreateClient(string name) + { + if (name != "dab") throw new Exception("Unexpected HTTP client"); + return new HttpClient(this, disposeHandler: false) { BaseAddress = new Uri("https://dab.invalid") }; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + Requests.Add(Uri.UnescapeDataString(request.RequestUri!.PathAndQuery)); + Started.TrySetResult(); + if (Block) await Release.Task.WaitAsync(ct); + if (Failure is not null) throw Failure; + if (FailAt == Requests.Count) throw new HttpRequestException("Synthetic status failure"); + if (Requests.Count > responses.Length) throw new Exception("Unexpected status request"); + return new HttpResponseMessage(Status) { + Content = new StringContent(responses[Requests.Count - 1], System.Text.Encoding.UTF8, "application/json") + }; + } +} + +sealed class StatusRenderer(IServiceProvider services) : Renderer(services, NullLoggerFactory.Instance) +{ + public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault(); + public void Attach(IComponent component) => AssignRootComponentId(component); + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask; + protected override void HandleException(Exception exception) => + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception).Throw(); +} diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/Program.cs b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/Program.cs index 87ecf87d..81879bdb 100644 --- a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/Program.cs +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/Program.cs @@ -120,7 +120,8 @@ await Reject(Service(store, User(), configuredTenan Assert(searches.All(name => entities.TryGetProperty(name, out _)), "An analysis procedure was removed"); passed++; } -Console.WriteLine($"{passed} deletion regression checks passed."); +passed += await ImportStatusChecks.RunAsync(); +Console.WriteLine($"{passed} regression checks passed."); sealed class ProbeAuthentication(ClaimsPrincipal user) : AuthenticationStateProvider {