fix(host): make encyclopedia schedule tests deterministic and harden the loop

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 21:24:07 +02:00
co-authored by Claude Sonnet 5.5
parent a99b8c9f06
commit 5b625a241f
4 changed files with 54 additions and 24 deletions
@@ -18,15 +18,19 @@ public sealed class EncyclopediaBackgroundService(
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var zone = ResolveZone();
TimeZoneInfo? zone = null;
while (!stoppingToken.IsCancellationRequested)
{
try
{
zone ??= ResolveZone();
var now = clock.GetUtcNow();
var next = NextRun(now, zone, options.RunAt);
log.LogInformation("next encyclopedia run at {Next:u}", next);
await Task.Delay(next - now, clock, stoppingToken);
var target = NextRun(now, zone, options.RunAt);
log.LogInformation("next encyclopedia run at {Target:u}", target);
await Task.Delay(target - now, clock, stoppingToken);
// A timer may fire early; the run must not start before its target, or the next target would be the same one.
while ((now = clock.GetUtcNow()) < target)
await Task.Delay(target - now, clock, stoppingToken);
await sync.RunAsync(force: false, stoppingToken);
await encyclopedia.RunAsync(stoppingToken);
}
@@ -36,7 +40,7 @@ public sealed class EncyclopediaBackgroundService(
}
catch (Exception e)
{
log.LogError(e, "encyclopedia schedule failed; retrying in {Backoff}", ErrorBackoff);
log.LogError(e, "encyclopedia schedule failed; scheduling the next run after a {Backoff} pause", ErrorBackoff);
try
{
await Task.Delay(ErrorBackoff, clock, stoppingToken);
+8 -1
View File
@@ -98,9 +98,15 @@ builder.Services.AddSingleton<IFactDrafter>(sp => sp.GetRequiredKeyedService<IFa
// ---- encyclopedia: the text model and the nightly run exist only when enabled ------------
var encyclopedia = wiki.Encyclopedia;
builder.Services.AddSingleton(encyclopedia);
string? encyclopediaConfigWarning = null;
if (encyclopedia.Enabled)
{
var answerEntry = wiki.AnswerModels.Models.GetValueOrDefault(encyclopedia.AnswerModel) ?? new AnswerModelOptions();
var answerEntry = wiki.AnswerModels.Models.GetValueOrDefault(encyclopedia.AnswerModel);
encyclopediaConfigWarning =
answerEntry is null ? $"Encyclopedia.AnswerModel '{encyclopedia.AnswerModel}' has no entry in Wiki:AnswerModels:Models; using the default API URL"
: string.IsNullOrEmpty(builder.Configuration[$"Wiki:AnswerModels:Models:{encyclopedia.AnswerModel}:ApiKey"]) ? $"No API key at Wiki:AnswerModels:Models:{encyclopedia.AnswerModel}:ApiKey; encyclopedia runs will fail until it is set"
: null;
answerEntry ??= new AnswerModelOptions();
builder.Services.AddAnthropicTextModel(o =>
{
o.BaseUrl = answerEntry.Url;
@@ -159,6 +165,7 @@ builder.Services.AddCascadingAuthenticationState();
builder.Services.AddMudServices();
var app = builder.Build();
if (encyclopediaConfigWarning is not null) app.Logger.LogWarning("{Warning}", encyclopediaConfigWarning);
if (!app.Environment.IsDevelopment())
{
@@ -79,7 +79,7 @@ public class EncyclopediaCoordinatorTests
var first = coordinator.RunAsync(CancellationToken.None);
await started.Task;
var second = coordinator.RunAsync(CancellationToken.None);
await Task.Delay(50);
Assert.False(second.IsCompleted);
Assert.True(coordinator.Running);
Assert.Equal(1, calls);
@@ -15,14 +15,29 @@ public sealed class EncyclopediaBackgroundServiceTests : IAsyncLifetime
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
/// <summary>Signals each time something arms a timer, so tests move the clock only once the loop is waiting.</summary>
private sealed class SignalingClock : FakeTimeProvider
{
public SemaphoreSlim Armed { get; } = new(0);
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
var timer = base.CreateTimer(callback, state, dueTime, period);
Armed.Release();
return timer;
}
}
private sealed class RecordingRun(IDocumentCatalog catalog) : IEncyclopediaRun
{
public List<string?> CatalogCommits { get; } = [];
public SemaphoreSlim Ran { get; } = new(0);
public Queue<Exception> Failures { get; } = new();
public async Task<ReconcileSummary> RunAsync(CancellationToken ct)
{
CatalogCommits.Add((await catalog.GetStateAsync(ct)).Commit);
Ran.Release();
if (Failures.TryDequeue(out var failure)) throw failure;
return new ReconcileSummary(0, 0, 0, 0, 0, false, null, null);
}
@@ -30,7 +45,9 @@ public sealed class EncyclopediaBackgroundServiceTests : IAsyncLifetime
private readonly FakeStore _store = new();
private readonly FileDocumentCatalog _catalog;
private readonly FakeTimeProvider _clock = new();
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(10);
private readonly SignalingClock _clock = new();
private readonly RecordingRun _run;
private readonly EncyclopediaCoordinator _coordinator;
private readonly SyncCoordinator _sync;
@@ -64,32 +81,35 @@ public sealed class EncyclopediaBackgroundServiceTests : IAsyncLifetime
_service = new EncyclopediaBackgroundService(_sync, _coordinator, new EncyclopediaOptions { TimeZone = timeZone },
_clock, NullLogger<EncyclopediaBackgroundService>.Instance);
await _service.StartAsync(CancellationToken.None);
await WaitArmedAsync();
}
private async Task AdvanceToAsync(DateTimeOffset target)
/// <summary>Moves the clock without firing the pending timer; the loop is waiting, so nothing can run.</summary>
private void AdvanceTo(DateTimeOffset target) => _clock.SetUtcNow(target);
/// <summary>Moves the clock to a run time, waits for the run, then for the loop to arm its next timer.</summary>
private async Task RunAtAsync(DateTimeOffset target)
{
await SettleAsync();
_clock.SetUtcNow(target);
await SettleAsync();
Assert.True(await _run.Ran.WaitAsync(Patience), "the run did not start");
await WaitArmedAsync();
}
/// <summary>Lets the service loop run to its next wait; it works on the thread pool, off the fake clock.</summary>
private static Task SettleAsync() => Task.Delay(300);
private async Task WaitArmedAsync() => Assert.True(await _clock.Armed.WaitAsync(Patience), "the service did not arm a timer");
[Fact]
public async Task Service_RunsOncePerDayAtRunAt()
{
await StartAtAsync(Berlin_(24, 5, 0));
await SettleAsync();
Assert.Empty(_run.CatalogCommits);
await AdvanceToAsync(Berlin_(24, 6, 0));
await RunAtAsync(Berlin_(24, 6, 0));
Assert.Single(_run.CatalogCommits);
await AdvanceToAsync(Berlin_(25, 5, 59));
AdvanceTo(Berlin_(25, 5, 59));
Assert.Single(_run.CatalogCommits);
await AdvanceToAsync(Berlin_(25, 6, 0));
await RunAtAsync(Berlin_(25, 6, 0));
Assert.Equal(2, _run.CatalogCommits.Count);
}
@@ -97,13 +117,12 @@ public sealed class EncyclopediaBackgroundServiceTests : IAsyncLifetime
public async Task Service_DoesNotRunOnStartup()
{
await StartAtAsync(Berlin_(24, 7, 0));
await SettleAsync();
Assert.Empty(_run.CatalogCommits);
await AdvanceToAsync(Berlin_(25, 5, 59));
AdvanceTo(Berlin_(25, 5, 59));
Assert.Empty(_run.CatalogCommits);
await AdvanceToAsync(Berlin_(25, 6, 0));
await RunAtAsync(Berlin_(25, 6, 0));
Assert.Single(_run.CatalogCommits);
}
@@ -117,7 +136,7 @@ public sealed class EncyclopediaBackgroundServiceTests : IAsyncLifetime
_store.Files["wiki/b.md"] = "---\ntitle: B\ntype: wiki\n---\n\nBody.\n";
var head = (await _store.GetHeadAsync(CancellationToken.None)).Commit;
await AdvanceToAsync(Berlin_(24, 6, 0));
await RunAtAsync(Berlin_(24, 6, 0));
var seen = Assert.Single(_run.CatalogCommits);
Assert.NotEqual(first, seen);
@@ -130,12 +149,12 @@ public sealed class EncyclopediaBackgroundServiceTests : IAsyncLifetime
_run.Failures.Enqueue(new JsonException("bad reply"));
await StartAtAsync(Berlin_(24, 5, 0));
await AdvanceToAsync(Berlin_(24, 6, 0));
await RunAtAsync(Berlin_(24, 6, 0));
Assert.Single(_run.CatalogCommits);
Assert.Equal("bad reply", _coordinator.LastError);
Assert.False(_service!.ExecuteTask!.IsCompleted);
await AdvanceToAsync(Berlin_(25, 6, 0));
await RunAtAsync(Berlin_(25, 6, 0));
Assert.Equal(2, _run.CatalogCommits.Count);
Assert.Null(_coordinator.LastError);
}
@@ -145,7 +164,7 @@ public sealed class EncyclopediaBackgroundServiceTests : IAsyncLifetime
{
await StartAtAsync(new DateTimeOffset(2026, 10, 24, 5, 0, 0, TimeSpan.Zero), "Mars/Olympus");
await AdvanceToAsync(new DateTimeOffset(2026, 10, 24, 6, 0, 0, TimeSpan.Zero));
await RunAtAsync(new DateTimeOffset(2026, 10, 24, 6, 0, 0, TimeSpan.Zero));
Assert.Single(_run.CatalogCommits);
}