diff --git a/src/Wiki.Host/Composition/EncyclopediaBackgroundService.cs b/src/Wiki.Host/Composition/EncyclopediaBackgroundService.cs index 3db6183..4d0ddb2 100644 --- a/src/Wiki.Host/Composition/EncyclopediaBackgroundService.cs +++ b/src/Wiki.Host/Composition/EncyclopediaBackgroundService.cs @@ -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); diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs index a58491e..501832f 100644 --- a/src/Wiki.Host/Program.cs +++ b/src/Wiki.Host/Program.cs @@ -98,9 +98,15 @@ builder.Services.AddSingleton(sp => sp.GetRequiredKeyedService { 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()) { diff --git a/test/Wiki.Application.Tests/EncyclopediaCoordinatorTests.cs b/test/Wiki.Application.Tests/EncyclopediaCoordinatorTests.cs index 9b2fe0f..0a62188 100644 --- a/test/Wiki.Application.Tests/EncyclopediaCoordinatorTests.cs +++ b/test/Wiki.Application.Tests/EncyclopediaCoordinatorTests.cs @@ -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); diff --git a/test/Wiki.Host.Tests/EncyclopediaBackgroundServiceTests.cs b/test/Wiki.Host.Tests/EncyclopediaBackgroundServiceTests.cs index 6aacc46..c873871 100644 --- a/test/Wiki.Host.Tests/EncyclopediaBackgroundServiceTests.cs +++ b/test/Wiki.Host.Tests/EncyclopediaBackgroundServiceTests.cs @@ -15,14 +15,29 @@ public sealed class EncyclopediaBackgroundServiceTests : IAsyncLifetime { private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); + /// Signals each time something arms a timer, so tests move the clock only once the loop is waiting. + 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 CatalogCommits { get; } = []; + public SemaphoreSlim Ran { get; } = new(0); public Queue Failures { get; } = new(); public async Task 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.Instance); await _service.StartAsync(CancellationToken.None); + await WaitArmedAsync(); } - private async Task AdvanceToAsync(DateTimeOffset target) + /// Moves the clock without firing the pending timer; the loop is waiting, so nothing can run. + private void AdvanceTo(DateTimeOffset target) => _clock.SetUtcNow(target); + + /// Moves the clock to a run time, waits for the run, then for the loop to arm its next timer. + 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(); } - /// Lets the service loop run to its next wait; it works on the thread pool, off the fake clock. - 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); }