Skip to content

Commit a945ef9

Browse files
committed
Stop owned Aspire test workloads on terminal resource failures
1 parent bb16152 commit a945ef9

23 files changed

Lines changed: 1034 additions & 24 deletions

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ jobs:
103103
run: node scripts/Features/RepositoryGovernance/verify.mjs
104104
- name: Test code analyzers
105105
run: dotnet run --project src/KeyLoad.AppHost --no-build --no-restore --configuration Release -- --KeyLoadTests:Suite=analyzers
106+
- name: Test prompt termination on Aspire resource failures
107+
run: dotnet run --project src/KeyLoad.AppHost --no-build --no-restore --configuration Release -- --KeyLoadTests:Suite=comparison '--KeyLoadTests:Filter=/*/*/AspireFailure*/*' --KeyLoadTests:ResultsDirectory=TestResults/aspire-failure --KeyLoadTests:ReportTrx=true
106108
- name: Test live benchmark progress capture
107109
run: dotnet run --project src/KeyLoad.AppHost --no-build --no-restore --configuration Release -- --KeyLoadTests:Suite=comparison '--KeyLoadTests:Filter=/*/*/ComparisonProgress*/*' --KeyLoadTests:ResultsDirectory=TestResults/live-benchmark-progress --KeyLoadTests:ReportTrx=true
108110
- name: Test live benchmark process entry

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@ For changes outside existing owner authorization, obtain direction before changi
404404
## Preferences
405405

406406
### Likes
407+
- Owner correction 2026-10-04 requires an actual Aspire/AppHost or required topology startup failure to terminate its owned test/workload promptly, retain the original failure and diagnostics, and clean up owned resources instead of waiting for the full workload deadline. Dashboard-disabled warnings alone are not topology failures; planned fault injection and independent database jobs retain their explicit contracts.
407408
- Owner correction 2026-10-04 requires bounded live benchmark phase and attempt progress in GitHub Actions so a long workload cannot appear idle for tens of minutes. Retain cancellation diagnostics before teardown; keep original failures, required workload sizes, native topology and qualification gates intact.
408409
- Owner correction 2026-10-04 requires Benchmarks to finish every independent database workload and publish authenticated successful measurements even when another benchmark, including unfinished KeyLoad, fails. Record each failed workload as unavailable with no numeric data and its actual failure/job provenance; never invent results or label a failed job successful. This explicitly supersedes the all-success comparison-cohort publication restriction while preserving complete planned-cell accounting, source/run/attempt identity, native topology, fairness, site qualification and least-privilege deployment. Benchmark repair does not authorize taking over concurrent KeyLoad engine implementation.
409410
- Owner correction 2026-10-04 separates database measurements from website testing: Benchmarks owns native database preparation, independent workloads, result validation and authenticated JSON aggregation. Chrome, browser/site tests and website qualification MUST NOT be benchmark dependencies. A separate website-build action may run after the JSON is ready or be triggered through CI; the owner explicitly permits static site building at the end of Benchmarks. The selected implementation uses independent CI website jobs on own-main push/manual and completed Benchmarks events. Every website build MUST use the newest completed own-main Benchmarks run and its successful authenticated aggregate, across push/manual producers, rather than blindly using the triggering run or falling back after invalid latest evidence. Preserve exactly three workflows, failed/null cells, original provenance, website gates and predeploy freshness. Website failure cannot block database metrics production. This supersedes earlier integrated qualification placement and the interim static-build prohibition. KeyLoad engine implementation remains owned by the other agent.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# ADR-086: Stop owned test workloads on terminal Aspire failure
2+
3+
Status: Accepted implementation contract; native qualification pending.
4+
Date: 2026-10-04. Related: REQ/AC-TEST-012..014, ADR-074, ADR-085.
5+
6+
## Decision
7+
8+
Use actual Aspire resource notifications and host stopping cancellation, rather
9+
than parsing CLI warning text. The disabled dashboard is optional infrastructure
10+
and cannot determine the test outcome. Original CI37202347856 reports show
11+
completed native tests with business failures, not a proven AppHost crash.
12+
13+
Register observation before starting the owned application. Race native runner
14+
completion against the required dependency failure notifications. FailedToStart,
15+
RuntimeUnhealthy, Finished, Exited or an exit code end a runner wait; an absent
16+
original exit status is an error. Required long-lived dependencies cannot exit
17+
successfully during measurement. Derive dependencies from actual WaitAnnotations,
18+
following their graph once per resource, with bounded tasks proportional to the
19+
existing fixed topology. Expected WaitForCompletion exits remain valid, while
20+
wrong or missing exits fail. HealthStatus.Unhealthy by itself is not a terminal
21+
failure. Preserve native exit codes and fail before the full deadline.
22+
23+
Cancel and join every owned observer on success, failure or cancellation. Observe
24+
startup concurrently with failure so a resource failure also cancels pending
25+
startup. Retain the initiating exception rather than replacing it with sibling
26+
cancellation. Existing stop/dispose and bounded diagnostic retention remain the
27+
owner's finally responsibility. No detached task or new timeout increase.
28+
29+
Startup readiness for the shared RF3 fixture explicitly uses native
30+
StopOnResourceUnavailable and cancels/joins other startup waits when one fails.
31+
This startup guard ends at initial readiness. Continuous comparison guards end
32+
at runner completion, before intentional node loss/restart regression scenarios.
33+
Explicit WaitOnResourceUnavailable restart paths stay unchanged. Independent
34+
database jobs continue their own workloads after another database fails.
35+
36+
## Implementation and verification
37+
38+
1. Root owns requirements, this ADR, shared integration and Git delivery.
39+
2. progress_runner owns only new AppHost Features/TestInfrastructure native
40+
lifecycle helpers and new ComparisonTests Features/TestInfrastructure tests.
41+
Genuine Aspire notifications cover terminal/missing-exit, live dependency
42+
failure, successful/failed bootstrap, no dependencies, cancellation and task
43+
settlement. Native failures settle within five seconds of event publication.
44+
3. Root joins TestSuiteApplication, IsolatedNativeCase and shared ClusterFixture
45+
startup, with any necessary test friend visibility. vector_runtime reviews
46+
required resources and planned fault boundaries read-only. Concurrent engine
47+
CQRS/upgrade code and its dedicated wave fixtures remain with their owner.
48+
4. Build/format/governance and focused TUnit tests through the canonical Aspire
49+
entry, then retain exact-source Linux CI reports. Full unit/scalar/recovery/RF3
50+
and native benchmark qualification remain required; unrelated failures are
51+
reported honestly. Source/native service tests do not prove a Docker crash
52+
was exercised. A real failure artifact supplies environmental-path evidence.
53+
54+
Graph: CONTRACT -> NATIVE HELPERS/TESTS -> JOINS -> REVIEW -> EVIDENCE.
55+
Acceptance mapping is in TestInfrastructure. No production contract, package,
56+
data-format or topology migration. Rollback reverts only these lifecycle joins
57+
and helpers; the canonical Aspire entry and existing outcomes remain required.
58+
Frontend N/A: developer and CI lifecycle only.
59+
60+
```mermaid
61+
flowchart LR
62+
Start[Owned AppHost start] --> Race[Native runner and required resources]
63+
Race --> Exit[Original runner exit]
64+
Race --> Failure[Terminal required resource failure]
65+
Exit --> Join[Cancel and join observers]
66+
Failure --> Join
67+
Join --> Evidence[Original failure and diagnostics]
68+
Evidence --> Cleanup[Stop and dispose owned resources]
69+
```

‎docs/ADR/README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,8 @@ series-codec qualification stage; canonical layout, rewrite/migration and fault
165165
qualification remain separate. [ADR-081](ADR-081-awaited-native-search-execution.md)
166166
freezes awaited admitted native search execution and independent RF3 oracle
167167
repairs. Both retain source/runtime and exact-source Linux qualification gates.
168+
169+
[ADR-086](ADR-086-aspire-terminal-failure.md) defines prompt termination on actual
170+
owned Aspire resource failures, preserving original outcomes, dependency
171+
completion, planned fault injection and independent comparison jobs. Native
172+
notification regressions and delivered-source qualification remain pending.

‎docs/Features/TestInfrastructure.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,35 @@ ComparisonTests Features/TestInfrastructure; shared IntegrationTests fixture and
1616
CI composition are root-owned joins. No public database or persisted format change.
1717
Local entry runs are development evidence; delivered-source Linux gates remain.
1818

19+
## Prompt termination on owned Aspire failure
20+
21+
[ADR-086](../ADR/ADR-086-aspire-terminal-failure.md) defines the private lifecycle
22+
repair prompted by CI [37202347856](https://github.com/managedcode/KeyLoad/actions/runs/37202347856).
23+
Its original reports show RF3 87/88 passed (cold-upgrade authorization assertion)
24+
and unit 2947/2953 passed (six native CQRS cases); they do not establish an AppHost
25+
crash. Website tests were 201/202 passed, with a real Chrome node-selection
26+
assertion failure. Dashboard-disabled CLI warnings appeared before successful
27+
suites too. These facts come from the original RF3, unit and site TUnit artifacts
28+
at source `15ea5030c6fc010b29127b4b8de07bd255afc7ff`, not inferred CLI text.
29+
30+
| Requirement | Acceptance and evidence |
31+
|---|---|
32+
| REQ-TEST-012: owned terminal Aspire failures stop waiting promptly | AC-TEST-012: a runner's FailedToStart, RuntimeUnhealthy, Finished/Exited without an original exit code, or a required long-lived dependency's terminal state fails its workload without consuming the suite deadline; genuine native notification tests settle within five seconds of publication |
33+
| REQ-TEST-013: completion and planned fault contracts remain intact | AC-TEST-013: expected WaitForCompletion exit is allowed; nonzero/missing completion exit fails; healthy/running and transient health snapshots continue waiting; no dependencies remains valid; guards are canceled and joined before intentional comparison node kill/restart assertions |
34+
| REQ-TEST-014: original outcome, ownership and cleanup remain intact | AC-TEST-014: native runner exit codes, startup exceptions and cancellation remain failures where applicable; AppHost stopping cancels waits; every notification task is canceled/joined, existing diagnostics and stop/disposal remain; unrelated database cells are unaffected |
35+
36+
TASK-TEST-FAILFAST-CONTRACT (root, complete): requirements/ADR and owning source
37+
review. TASK-TEST-FAILFAST-NATIVE (progress_runner, implementation): only new
38+
AppHost Features/TestInfrastructure lifecycle helpers and ComparisonTests
39+
Features/TestInfrastructure native notification regressions. TASK-TEST-FAILFAST-JOIN
40+
(root, after helper): TestSuiteApplication and IsolatedNativeCase integration,
41+
CI focused selection, docs, review and exact-source validation. vector_runtime
42+
owns read-only lifecycle review. These tasks preserve concurrent engine changes;
43+
RF3 CQRS/authorization failures belong to their existing owner. Public database,
44+
stored data, dependencies and topology are N/A: this is private test orchestration.
45+
Native service tests establish notification handling, not real Docker failure
46+
qualification; delivered Linux tests and failure artifact review remain required.
47+
1948
Status: implementation in progress. Owner: KeyLoad lead. Decision: [ADR-036](../ADR/ADR-036-orleans-foundation.md).
2049

2150
REQ-TEST-008 / AC-TEST-008 (TASK-RUNTIME-EVENTSOURCE-W3) coordinates the actual

‎src/KeyLoad.AppHost/Features/BenchmarkComparisons/IsolatedTestFriend.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Runtime.CompilerServices;
22

33
[assembly: InternalsVisibleTo(KeyLoad.AppHost.Features.BenchmarkComparisons.IsolatedTestFriend.AssemblyName)]
4+
[assembly: InternalsVisibleTo("KeyLoad.IntegrationTests")]
45

56
namespace KeyLoad.AppHost.Features.BenchmarkComparisons;
67

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using System.Runtime.ExceptionServices;
2+
3+
namespace KeyLoad.AppHost.Features.TestInfrastructure;
4+
5+
internal static class AspireOwnedTaskJoin
6+
{
7+
private const string MultipleFailures = "Aspire execution and owned task cleanup failed.";
8+
9+
internal static async Task<Task> FirstFailureOrAllAsync(Task[] tasks)
10+
{
11+
var pending = tasks.ToList();
12+
while (pending.Count != 0)
13+
{
14+
var settled = await Task.WhenAny(pending).ConfigureAwait(false);
15+
if (!settled.IsCompletedSuccessfully)
16+
{
17+
return settled;
18+
}
19+
pending.Remove(settled);
20+
}
21+
return Task.CompletedTask;
22+
}
23+
24+
internal static async Task CompleteAsync(CancellationTokenSource lifetime, Task[] tasks, Task primary)
25+
{
26+
await primary.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
27+
var failures = new List<Exception>();
28+
await RetainAsync(primary, failures, includeCancellation: true).ConfigureAwait(false);
29+
var cancellation = lifetime.CancelAsync();
30+
await cancellation.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
31+
await RetainAsync(cancellation, failures, includeCancellation: true).ConfigureAwait(false);
32+
await Task.WhenAll(tasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
33+
foreach (var task in tasks.Where(task => !ReferenceEquals(task, primary)))
34+
{
35+
await RetainAsync(task, failures, includeCancellation: false).ConfigureAwait(false);
36+
}
37+
ThrowFailures(failures);
38+
}
39+
40+
private static async Task RetainAsync(Task task, List<Exception> failures, bool includeCancellation)
41+
{
42+
if (task.IsFaulted)
43+
{
44+
failures.AddRange(task.Exception!.InnerExceptions);
45+
}
46+
else if (includeCancellation && task.IsCanceled)
47+
{
48+
try
49+
{
50+
await task.ConfigureAwait(false);
51+
}
52+
catch (OperationCanceledException error)
53+
{
54+
failures.Add(error);
55+
}
56+
}
57+
}
58+
59+
private static void ThrowFailures(List<Exception> failures)
60+
{
61+
if (failures.Count == 1)
62+
{
63+
ExceptionDispatchInfo.Capture(failures[0]).Throw();
64+
}
65+
if (failures.Count > 1)
66+
{
67+
throw new AggregateException(MultipleFailures, failures);
68+
}
69+
}
70+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using Aspire.Hosting.ApplicationModel;
2+
using Microsoft.Extensions.DependencyInjection;
3+
4+
namespace KeyLoad.AppHost.Features.TestInfrastructure;
5+
6+
internal static class AspireRequiredResources
7+
{
8+
private const string MissingRunner = "The Aspire application model has no selected runner.";
9+
private const string ConflictingCompletion = "An Aspire dependency has incompatible expected completion exits.";
10+
11+
internal static Dictionary<string, int?> ForRunner(DistributedApplication app, string runnerName)
12+
{
13+
var resources = app.Services.GetRequiredService<DistributedApplicationModel>().Resources;
14+
var runner = resources.SingleOrDefault(resource => string.Equals(resource.Name, runnerName, StringComparison.OrdinalIgnoreCase))
15+
?? throw new InvalidOperationException(MissingRunner);
16+
var dependencies = new Dictionary<string, int?>(StringComparer.OrdinalIgnoreCase);
17+
var visited = new HashSet<IResource> { runner };
18+
var pending = new Queue<IResource>();
19+
pending.Enqueue(runner);
20+
while (pending.TryDequeue(out var resource))
21+
{
22+
AddDependencies(resource, runner, dependencies, visited, pending);
23+
}
24+
return dependencies;
25+
}
26+
27+
private static void AddDependencies(IResource resource, IResource runner, Dictionary<string, int?> dependencies,
28+
HashSet<IResource> visited, Queue<IResource> pending)
29+
{
30+
foreach (var wait in resource.Annotations.OfType<WaitAnnotation>())
31+
{
32+
if (wait.Resource is IResourceWithoutLifetime || ReferenceEquals(wait.Resource, runner))
33+
{
34+
continue;
35+
}
36+
AddContract(dependencies, wait);
37+
if (visited.Add(wait.Resource))
38+
{
39+
pending.Enqueue(wait.Resource);
40+
}
41+
}
42+
}
43+
44+
private static void AddContract(Dictionary<string, int?> dependencies, WaitAnnotation wait)
45+
{
46+
int? expected = wait.WaitType == WaitType.WaitForCompletion ? wait.ExitCode : null;
47+
if (dependencies.TryGetValue(wait.Resource.Name, out var prior))
48+
{
49+
if (prior is not null && expected is not null && prior != expected)
50+
{
51+
throw new InvalidOperationException(ConflictingCompletion);
52+
}
53+
expected = prior is null || expected is null ? null : expected;
54+
}
55+
dependencies[wait.Resource.Name] = expected;
56+
}
57+
}

0 commit comments

Comments
 (0)