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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user