Skip to content

Commit 32a7951

Browse files
committed
test(cache): coordinate the noncooperative factory timeout proof
Hold the original factory until its own cache timeout is observed, then release it and verify replacement and late-result behavior. This removes the race between two elapsed timers on loaded CI workers without changing production code, budgets or the asserted contract. Distinguish the test harness timeout and always release the factory.
1 parent ec86884 commit 32a7951

2 files changed

Lines changed: 37 additions & 15 deletions

File tree

‎backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs‎

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,26 +1105,37 @@ public async Task A_Factory_That_Ignores_Its_Token_Is_Still_Bounded()
11051105
var cache = new InMemoryCacheService(
11061106
new FixedClock(Origin), MeterFactory, factoryTimeout: TimeSpan.FromMilliseconds(80));
11071107
var runs = 0;
1108+
var release = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
11081109

1109-
var overrunning = cache.GetOrSetAsync<string>(Key(), async _ =>
1110+
var overrunning = cache.GetOrSetAsync(Key(), _ =>
11101111
{
11111112
Interlocked.Increment(ref runs);
1112-
await Task.Delay(TimeSpan.FromMilliseconds(600), CancellationToken.None);
1113-
return "late";
1113+
return release.Task;
11141114
});
11151115

1116-
await ((Func<Task>)(() => overrunning)).Should().ThrowAsync<TimeoutException>();
1117-
1118-
// The replacement waits for the overrunning factory rather than starting
1119-
// a second one beside it, and is not answered with the first caller's
1120-
// timeout for ever.
1121-
var replacement = await cache.GetOrSetAsync(Key(), _ => Task.FromResult("fresh"))
1122-
.WaitAsync(TestTimeout);
1123-
1124-
replacement.Should().Be("fresh");
1125-
runs.Should().Be(1, "no second factory ran alongside the first");
1126-
(await cache.GetAsync<string>(Key())).Should().Be("fresh",
1127-
"a result that arrived after its deadline is not cached");
1116+
try
1117+
{
1118+
// Only the cache's deadline can finish first; two elapsed timers can
1119+
// run in reverse order on a busy worker. The outer bound must never
1120+
// masquerade as the cache's own timeout if its deadline regresses.
1121+
var refusal = await ((Func<Task>)(() => overrunning.WaitAsync(TestTimeout)))
1122+
.Should().ThrowAsync<TimeoutException>();
1123+
refusal.WithMessage("The cache factory for '*' did not complete within *s.");
1124+
1125+
// A replacement waits for the original factory's real termination.
1126+
var replacement = cache.GetOrSetAsync(Key(), _ => Task.FromResult("fresh"));
1127+
replacement.IsCompleted.Should().BeFalse("the original factory has not been released");
1128+
release.SetResult("late");
1129+
1130+
(await replacement.WaitAsync(TestTimeout)).Should().Be("fresh");
1131+
runs.Should().Be(1, "no second factory ran alongside the first");
1132+
(await cache.GetAsync<string>(Key())).Should().Be("fresh",
1133+
"a result that arrived after its deadline is not cached");
1134+
}
1135+
finally
1136+
{
1137+
release.TrySetResult("late");
1138+
}
11281139
}
11291140

11301141
[Theory]

‎docs/roadmap/phase-02d-walking-skeleton.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6453,6 +6453,17 @@ removed afterward. Whitespace and the complete PR's link/fragment audit pass.
64536453
The final PR rollup below owns replacement final-head CI; all seven jobs remain
64546454
required before merge.
64556455

6456+
**CI stability follow-up.** Run 38126790757 reports one unit-test failure in
6457+
`A_Factory_That_Ignores_Its_Token_Is_Still_Bounded`; all 203 architecture cases,
6458+
including the new admission controls, pass. This pre-existing test races an
6459+
80 ms cache deadline against a 600 ms factory delay. It now holds the factory
6460+
behind an explicit release signal until the cache's own timeout is observed,
6461+
checks that a replacement waits and retains the late-result/cache assertions.
6462+
The bounded test wait cannot masquerade as the cache timeout: its message must
6463+
identify the cache's own deadline. The release is guaranteed in `finally`.
6464+
Production cache behavior and its budget are unchanged. Focused verification
6465+
and both independent review rounds remain required before replacement CI.
6466+
64566467
**Other finding dispositions.** The gzip adapter equality checks wiring; the
64576468
independent Script observer checks source-window coverage, not a second byte
64586469
measurement. Existing fast tests already cover report admission, source-byte

0 commit comments

Comments
 (0)