diff --git a/src/AppName.Application/Time/TaiwanClock.cs b/src/AppName.Application/Time/TaiwanClock.cs index e306141..9c33124 100644 --- a/src/AppName.Application/Time/TaiwanClock.cs +++ b/src/AppName.Application/Time/TaiwanClock.cs @@ -7,9 +7,66 @@ public static class TaiwanClock { private static readonly TimeZoneInfo Taipei = TimeZoneInfo.FindSystemTimeZoneById("Asia/Taipei"); + /// + /// Taipei local time by which the exchange has published the day's close. + /// + private static readonly TimeOnly ClosingQuotesPublishedBy = new(15, 0); + + /// + /// Taipei local time after which a day that has still not produced its own + /// close is treated as a non-trading day rather than a late publication. + /// + private static readonly TimeOnly ClosePublishGraceEndsAt = new(18, 0); + /// /// Returns today's date in the Asia/Taipei time zone. /// public static DateOnly Today(TimeProvider timeProvider) => DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(timeProvider.GetUtcNow(), Taipei).DateTime); + + /// + /// Returns the most recent Taiwan date whose closing quotes should already + /// exist, which is the date cached market data has to carry to be current. + /// + /// Supplies the current instant. + public static DateOnly ExpectedDataDate(TimeProvider timeProvider) + { + var taipeiNow = TimeZoneInfo.ConvertTime(timeProvider.GetUtcNow(), Taipei).DateTime; + + // Before the exchange publishes, today's close does not exist yet, so the + // newest data anyone can hold is the previous trading day's. Chasing today + // instead would leave the gate open every morning. + var date = DateOnly.FromDateTime(taipeiNow); + if (TimeOnly.FromDateTime(taipeiNow) < ClosingQuotesPublishedBy) + { + date = date.AddDays(-1); + } + + // A weekend never produces a close. Without this the newest data would sit + // permanently behind the requested date and the gate would re-pull the whole + // market every interval, all weekend. + while (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) + { + date = date.AddDays(-1); + } + + return date; + } + + /// + /// Returns true while a missing close for is + /// still plausibly late rather than proof of a non-trading day. + /// + /// Supplies the current instant. + public static bool IsClosePublishGraceOpen(TimeProvider timeProvider) + { + // Taiwan public holidays cannot be derived from the calendar, so they are + // recognised only by their effect: the day passes without publishing a + // close. This window bounds how long a refresh keeps retrying before + // drawing that conclusion and waiting for the next day. + var taipeiTime = TimeOnly.FromDateTime( + TimeZoneInfo.ConvertTime(timeProvider.GetUtcNow(), Taipei).DateTime); + + return taipeiTime >= ClosingQuotesPublishedBy && taipeiTime < ClosePublishGraceEndsAt; + } } diff --git a/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs b/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs index fb4bac4..21b8375 100644 --- a/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs +++ b/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs @@ -1,12 +1,13 @@ using AppName.Application.Time; using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.ValueObjects.Bonds; using Microsoft.Extensions.Logging; namespace AppName.Application.UseCases.Bonds; /// -/// The once-per-Taiwan-trading-day refresh gate every valuation read passes -/// through. +/// The refresh gate every valuation read passes through, which brings the +/// cache up to the newest published Taiwan trading data and then leaves it alone. /// internal static class DailyRefreshGate { @@ -16,12 +17,17 @@ internal static class DailyRefreshGate internal static readonly TimeSpan RetryBackoff = TimeSpan.FromMinutes(5); /// - /// Refreshes the valuation cache when this caller wins today's claim, and + /// How long an attempt that brought back no newer data blocks the next one. + /// + internal static readonly TimeSpan StalePollInterval = TimeSpan.FromMinutes(15); + + /// + /// Refreshes the valuation cache when this caller wins the claim, and /// returns without refreshing when it loses. /// /// Holds the singleton refresh marker the claim updates. /// Runs the whole-market refresh. - /// Resolves today's Taiwan trading date. + /// Resolves the Taiwan trading dates the claim is judged against. /// Records a failed attempt, which is otherwise invisible to the caller. /// Cancels the claim and the refresh. internal static async Task EnsureFreshAsync( @@ -36,10 +42,20 @@ internal static async Task EnsureFreshAsync( var now = timeProvider.GetUtcNow().UtcDateTime; var today = TaiwanClock.Today(timeProvider); - // A losing claim falls through and serves the cache. It does not wait for - // the winner, because freshness is best-effort and waiting would couple - // every window request to one whole-market fetch. - if (!await refreshStateRepo.TryClaimAttemptAsync(today, now, ct)) + var claim = new BondValuationRefreshClaim + { + ExpectedDataDate = TaiwanClock.ExpectedDataDate(timeProvider), + TaiwanToday = today, + IsClosePublishGraceOpen = TaiwanClock.IsClosePublishGraceOpen(timeProvider), + Now = now, + NextAttemptNotBefore = now + StalePollInterval, + }; + + // A losing claim falls through and serves the cache -- either the cache + // already carries the newest published data, or another caller is fetching + // it. It does not wait for that caller, because freshness is best-effort + // and waiting would couple every window request to one whole-market fetch. + if (!await refreshStateRepo.TryClaimAttemptAsync(claim, ct)) { return; } @@ -51,10 +67,10 @@ internal static async Task EnsureFreshAsync( catch (OperationCanceledException) { // The caller walked away mid-refresh. Nothing failed, so this is not - // logged as an error, but the day must still be handed back: an - // attempt that never finished has not refreshed anything. The retry - // is immediate because there is no upstream fault to back off from. - await ReleaseQuietlyAsync(refreshStateRepo, now, logger); + // logged as an error, but the lease must still be dropped: an attempt + // that never finished has not refreshed anything. The next attempt is + // immediate because there is no upstream fault to back off from. + await SetNextAttemptQuietlyAsync(refreshStateRepo, now, logger); throw; } catch (Exception ex) @@ -65,32 +81,37 @@ internal static async Task EnsureFreshAsync( // identical from the outside. logger.LogError( ex, - "Daily bond valuation refresh failed for Taiwan date {TaiwanDate}. Serving the cached valuations. The day has been released; the next read after {RetryNotBefore:O} will try again.", + "Bond valuation refresh failed for Taiwan date {TaiwanDate}. Serving the cached valuations. The next read after {NextAttemptNotBefore:O} will try again.", today, now + RetryBackoff); - await ReleaseQuietlyAsync(refreshStateRepo, now + RetryBackoff, logger); + await SetNextAttemptQuietlyAsync(refreshStateRepo, now + RetryBackoff, logger); + return; } + + // A successful pull rewrites the whole marker row. Restoring the interval + // is what paces the retries when the pull succeeded but came back with + // nothing newer than the cache already held. + await SetNextAttemptQuietlyAsync(refreshStateRepo, now + StalePollInterval, logger); } /// - /// Releases the claim, swallowing any failure to do so. + /// Records when the next attempt may run, swallowing any failure to do so. /// - /// - private static async Task ReleaseQuietlyAsync( + private static async Task SetNextAttemptQuietlyAsync( IBondValuationRefreshStateRepository refreshStateRepo, - DateTime retryNotBefore, + DateTime notBefore, ILogger logger) { try { - await refreshStateRepo.ReleaseClaimAsync(retryNotBefore, CancellationToken.None); + await refreshStateRepo.SetNextAttemptAsync(notBefore, CancellationToken.None); } catch (Exception ex) { logger.LogError( ex, - "Could not release the bond valuation refresh claim. No further refresh will be attempted until tomorrow."); + "Could not schedule the next bond valuation refresh attempt. No further refresh will be attempted until the claim's lease expires."); } } } diff --git a/src/AppName.Application/UseCases/Bonds/RefreshAllBondsUseCase.cs b/src/AppName.Application/UseCases/Bonds/RefreshAllBondsUseCase.cs index 8815d82..dc1e707 100644 --- a/src/AppName.Application/UseCases/Bonds/RefreshAllBondsUseCase.cs +++ b/src/AppName.Application/UseCases/Bonds/RefreshAllBondsUseCase.cs @@ -50,8 +50,20 @@ public async Task ExecuteAsync(CancellationToken ct = default) await snapshotRepo.UpsertManyAsync(snapshots, ct); - DateOnly? maxAsOf = snapshots.Count > 0 ? snapshots.Max(s => s.AsOf) : null; + var previous = await refreshStateRepo.GetAsync(ct); + + // A pull that produced no usable quotes must not drag LastAsOf backwards. + // It is the key the refresh gate reads, so a null there would reopen the + // gate on every read. The pending next-attempt time is carried over for + // the same reason: this write replaces the whole row, and dropping the + // lease would let a second caller in behind an in-flight refresh. + DateOnly? maxAsOf = snapshots.Count > 0 ? snapshots.Max(s => s.AsOf) : previous.LastAsOf; + await refreshStateRepo.SetAsync( - new BondValuationRefreshState(BondValuationRefreshState.SingletonId, maxAsOf, TaiwanClock.Today(timeProvider)), ct); + new BondValuationRefreshState( + BondValuationRefreshState.SingletonId, + maxAsOf, + TaiwanClock.Today(timeProvider), + previous.NextAttemptNotBefore), ct); } } diff --git a/src/AppName.Domain/Abstractions/Bonds/IBondValuationRefreshStateRepository.cs b/src/AppName.Domain/Abstractions/Bonds/IBondValuationRefreshStateRepository.cs index 649d4b0..cd30320 100644 --- a/src/AppName.Domain/Abstractions/Bonds/IBondValuationRefreshStateRepository.cs +++ b/src/AppName.Domain/Abstractions/Bonds/IBondValuationRefreshStateRepository.cs @@ -1,4 +1,5 @@ using AppName.Domain.Entities.Bonds; +using AppName.Domain.ValueObjects.Bonds; namespace AppName.Domain.Abstractions.Bonds; @@ -18,42 +19,22 @@ public interface IBondValuationRefreshStateRepository Task SetAsync(BondValuationRefreshState state, CancellationToken ct = default); /// - /// Claims today's refresh attempt for exactly one caller. + /// Claims the next refresh attempt for exactly one caller. /// - /// The Taiwan calendar date of the attempt. - /// The UTC instant the claim is made, compared against a pending retry. + /// Everything the claim predicate compares the marker against. /// Cancels the claim. /// /// True when this caller won the claim and must run the refresh; false when - /// another caller already claimed and this caller - /// must serve the cached data. + /// the cached data is already current, when the day has been shown to be a + /// non-trading day, or when another caller's attempt still holds the marker. /// - /// - /// - /// A read-then-write gate lets concurrent requests all observe the stale - /// marker and all start a whole-market refresh. This claim is one - /// conditional statement, so only the first caller of the day wins. - /// - /// - /// A caller also wins when the day is already claimed but a previous - /// attempt released it and its - /// has passed. A - /// won claim clears that marker, so the retry is handed to exactly one - /// caller too. - /// - /// - Task TryClaimAttemptAsync(DateOnly today, DateTime now, CancellationToken ct = default); + Task TryClaimAttemptAsync(BondValuationRefreshClaim claim, CancellationToken ct = default); /// - /// Gives today's claim back after a failed attempt, so it can be retried - /// once has passed. + /// Records the earliest UTC instant at which the next refresh attempt may be + /// claimed. /// - /// The earliest UTC instant another caller may claim the day again. - /// Cancels the release. - /// - /// Without this, a single upstream failure consumes the whole trading day: - /// the claim is stamped before the refresh runs, so every later read sees - /// the day as done and serves an empty cache until tomorrow. - /// - Task ReleaseClaimAsync(DateTime retryNotBefore, CancellationToken ct = default); + /// The earliest UTC instant another caller may claim an attempt. + /// Cancels the write. + Task SetNextAttemptAsync(DateTime notBefore, CancellationToken ct = default); } diff --git a/src/AppName.Domain/Entities/Bonds/BondValuationRefreshState.cs b/src/AppName.Domain/Entities/Bonds/BondValuationRefreshState.cs index 9800f7f..cd66743 100644 --- a/src/AppName.Domain/Entities/Bonds/BondValuationRefreshState.cs +++ b/src/AppName.Domain/Entities/Bonds/BondValuationRefreshState.cs @@ -3,17 +3,14 @@ namespace AppName.Domain.Entities.Bonds; /// -/// Single-row marker recording when bond data was last pulled, used to gate -/// refreshes to once per Taiwan trading day. +/// Single-row marker recording how current the cached bond data is and when the +/// next pull may run, used to gate whole-market refreshes. /// -/// -/// Creates a refresh-state marker. -/// public sealed class BondValuationRefreshState( long id, DateOnly? lastAsOf, DateOnly? lastAttemptDate, - DateTime? retryNotBefore = null) + DateTime? nextAttemptNotBefore = null) { /// /// The fixed primary-key value of the single marker row. @@ -39,30 +36,19 @@ public sealed class BondValuationRefreshState( public DateOnly? LastAttemptDate { get; private set; } = lastAttemptDate; /// - /// UTC instant before which a released claim must not be retried; null when - /// no retry is pending. + /// UTC instant before which the next pull attempt must not start; null when + /// no attempt is pending. /// - /// - /// Set only when an attempt failed and gave its day back. It is what lets a - /// failed refresh be retried within the same day without every request - /// paying the upstream timeout: the day is claimable again, but not until - /// this instant has passed. A successful attempt clears it. - /// - /// Stored as a UTC rather than a - /// because the repository compares it in SQL, - /// and EF Core's SQLite provider -- which the repository tests run on -- - /// cannot translate a DateTimeOffset comparison. - /// - [DisplayName("Retry Not Before")] - public DateTime? RetryNotBefore { get; private set; } = retryNotBefore; + [DisplayName("Next Attempt Not Before")] + public DateTime? NextAttemptNotBefore { get; private set; } = nextAttemptNotBefore; /// /// Replaces the recorded markers after a pull attempt. /// - public void Update(DateOnly? lastAsOf, DateOnly? lastAttemptDate, DateTime? retryNotBefore = null) + public void Update(DateOnly? lastAsOf, DateOnly? lastAttemptDate, DateTime? nextAttemptNotBefore = null) { LastAsOf = lastAsOf; LastAttemptDate = lastAttemptDate; - RetryNotBefore = retryNotBefore; + NextAttemptNotBefore = nextAttemptNotBefore; } } diff --git a/src/AppName.Domain/ValueObjects/Bonds/BondValuationRefreshClaim.cs b/src/AppName.Domain/ValueObjects/Bonds/BondValuationRefreshClaim.cs new file mode 100644 index 0000000..ac483a0 --- /dev/null +++ b/src/AppName.Domain/ValueObjects/Bonds/BondValuationRefreshClaim.cs @@ -0,0 +1,43 @@ +using System.ComponentModel; + +namespace AppName.Domain.ValueObjects.Bonds; + +/// +/// One caller's bid to run the next whole-market bond refresh, carrying every +/// value the claim predicate compares the stored marker against. +/// +public sealed record BondValuationRefreshClaim +{ + /// + /// The most recent Taiwan date whose closing quotes should already exist. + /// Cached data older than this is stale; data on or after it is current. + /// + [DisplayName("Expected Data Date")] + public required DateOnly ExpectedDataDate { get; init; } + + /// + /// The Taiwan calendar date of this attempt, stamped on a won claim. + /// + [DisplayName("Taiwan Today")] + public required DateOnly TaiwanToday { get; init; } + + /// + /// True while a missing close for is still + /// plausibly late rather than proof that the day was not a trading day. + /// + [DisplayName("Is Close Publish Grace Open")] + public required bool IsClosePublishGraceOpen { get; init; } + + /// + /// The UTC instant the claim is made, compared against the stored + /// next-attempt time. + /// + [DisplayName("Now")] + public required DateTime Now { get; init; } + + /// + /// The UTC instant a later caller may claim again, stamped by a won claim. + /// + [DisplayName("Next Attempt Not Before")] + public required DateTime NextAttemptNotBefore { get; init; } +} diff --git a/src/AppName.Infrastructure/Migrations/20260901194434_RenameRetryNotBeforeToNextAttemptNotBefore.Designer.cs b/src/AppName.Infrastructure/Migrations/20260901194434_RenameRetryNotBeforeToNextAttemptNotBefore.Designer.cs new file mode 100644 index 0000000..884f9d9 --- /dev/null +++ b/src/AppName.Infrastructure/Migrations/20260901194434_RenameRetryNotBeforeToNextAttemptNotBefore.Designer.cs @@ -0,0 +1,107 @@ +// +using System; +using AppName.Infrastructure.Persistence.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AppName.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260901194434_RenameRetryNotBeforeToNextAttemptNotBefore")] + partial class RenameRetryNotBeforeToNextAttemptNotBefore + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppName.Domain.Entities.Bonds.BondValuationRefreshState", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("LastAsOf") + .HasColumnType("date"); + + b.Property("LastAttemptDate") + .HasColumnType("date"); + + b.Property("NextAttemptNotBefore") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("BondValuationRefreshStates"); + + b.HasData( + new + { + Id = 1L + }); + }); + + modelBuilder.Entity("AppName.Domain.Entities.Bonds.BondValuationSnapshot", b => + { + b.Property("Symbol") + .HasColumnType("text"); + + b.Property("AsOf") + .HasColumnType("date"); + + b.Property("BondPrice") + .HasColumnType("numeric"); + + b.Property("ConversionShares") + .HasColumnType("numeric"); + + b.Property("ConversionValue") + .HasColumnType("numeric"); + + b.Property("IsInTheMoney") + .HasColumnType("boolean"); + + b.Property("StockPrice") + .HasColumnType("numeric"); + + b.HasKey("Symbol"); + + b.ToTable("BondValuationSnapshots"); + }); + + modelBuilder.Entity("AppName.Domain.Entities.Bonds.ConvertibleBond", b => + { + b.Property("Symbol") + .HasColumnType("text"); + + b.Property("ConversionPrice") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParValue") + .HasColumnType("numeric"); + + b.Property("UnderlyingSymbol") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Symbol"); + + b.ToTable("ConvertibleBonds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AppName.Infrastructure/Migrations/20260901194434_RenameRetryNotBeforeToNextAttemptNotBefore.cs b/src/AppName.Infrastructure/Migrations/20260901194434_RenameRetryNotBeforeToNextAttemptNotBefore.cs new file mode 100644 index 0000000..01b2542 --- /dev/null +++ b/src/AppName.Infrastructure/Migrations/20260901194434_RenameRetryNotBeforeToNextAttemptNotBefore.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AppName.Infrastructure.Migrations +{ + /// + public partial class RenameRetryNotBeforeToNextAttemptNotBefore : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "RetryNotBefore", + table: "BondValuationRefreshStates", + newName: "NextAttemptNotBefore"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "NextAttemptNotBefore", + table: "BondValuationRefreshStates", + newName: "RetryNotBefore"); + } + } +} diff --git a/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 42caa3d..d699e26 100644 --- a/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -33,7 +33,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastAttemptDate") .HasColumnType("date"); - b.Property("RetryNotBefore") + b.Property("NextAttemptNotBefore") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); diff --git a/src/AppName.Infrastructure/Persistence/Bonds/BondValuationRefreshStateRepository.cs b/src/AppName.Infrastructure/Persistence/Bonds/BondValuationRefreshStateRepository.cs index ddb41dd..f10994f 100644 --- a/src/AppName.Infrastructure/Persistence/Bonds/BondValuationRefreshStateRepository.cs +++ b/src/AppName.Infrastructure/Persistence/Bonds/BondValuationRefreshStateRepository.cs @@ -1,5 +1,6 @@ using AppName.Domain.Abstractions.Bonds; using AppName.Domain.Entities.Bonds; +using AppName.Domain.ValueObjects.Bonds; using AppName.Infrastructure.Persistence.Context; using Microsoft.EntityFrameworkCore; @@ -31,55 +32,67 @@ public async Task SetAsync(BondValuationRefreshState state, CancellationToken ct if (existing is null) { db.BondValuationRefreshStates.Add(new BondValuationRefreshState( - BondValuationRefreshState.SingletonId, state.LastAsOf, state.LastAttemptDate, state.RetryNotBefore)); + BondValuationRefreshState.SingletonId, state.LastAsOf, state.LastAttemptDate, state.NextAttemptNotBefore)); } else { - existing.Update(state.LastAsOf, state.LastAttemptDate, state.RetryNotBefore); + existing.Update(state.LastAsOf, state.LastAttemptDate, state.NextAttemptNotBefore); } await db.SaveChangesAsync(ct); } /// - public async Task TryClaimAttemptAsync(DateOnly today, DateTime now, CancellationToken ct = default) + public async Task TryClaimAttemptAsync(BondValuationRefreshClaim claim, CancellationToken ct = default) { await using var db = factory.CreateDbContext(); + var expectedDataDate = claim.ExpectedDataDate; + var taiwanToday = claim.TaiwanToday; + var closePublishGraceOpen = claim.IsClosePublishGraceOpen; + var now = claim.Now; + var nextAttemptNotBefore = claim.NextAttemptNotBefore; + // ExecuteUpdateAsync keeps the compare and the set inside one UPDATE // statement, so the database decides the winner. Raw SQL is not an // option here: the natural predicate is IS DISTINCT FROM, and SQLite // does not support it, but the repository tests run on SQLite. var claimed = await db.BondValuationRefreshStates .Where(r => r.Id == BondValuationRefreshState.SingletonId - // The null arm is REQUIRED. SQL evaluates NULL != @today as - // NULL, not true, so without it the very first claim of all - // time matches no row and the refresh never starts. - && (r.LastAttemptDate == null || r.LastAttemptDate != today - // A failed attempt gave the day back and named when it - // may be tried again. Clearing RetryNotBefore in the - // same statement is what stops two callers taking the - // retry, exactly as the day claim stops two taking the - // first attempt. - || (r.RetryNotBefore != null && r.RetryNotBefore <= now))) + // Freshness is judged by the data, not by the attempt. A pull + // that ran before the exchange published leaves LastAsOf behind. + // The null arm is REQUIRED: SQL evaluates NULL < @date as NULL, + // not true, so without it the very first claim of all time + // matches no row and the refresh never starts. + && (r.LastAsOf == null || r.LastAsOf < expectedDataDate) + // Once the grace period closes, a day that still has not + // published its own close was not a trading day. Without this + // arm every Taiwan holiday costs a whole-market pull per + // interval until midnight. + && (closePublishGraceOpen || r.LastAttemptDate == null || r.LastAttemptDate < taiwanToday) + // Every attempt stamps this. While a refresh runs it is the + // mutex that keeps a second caller out; afterwards it is the + // throttle that paces retries. Its null arm is required for + // the same reason as above. + && (r.NextAttemptNotBefore == null || r.NextAttemptNotBefore <= now)) .ExecuteUpdateAsync(s => s - .SetProperty(r => r.LastAttemptDate, today) - .SetProperty(r => r.RetryNotBefore, (DateTime?)null), ct); + .SetProperty(r => r.LastAttemptDate, taiwanToday) + .SetProperty(r => r.NextAttemptNotBefore, (DateTime?)nextAttemptNotBefore), ct); - // Zero rows means another caller already claimed today. + // Zero rows means the cache is already current, the day has been shown not + // to trade, or another caller's attempt still holds the marker. return claimed > 0; } /// - public async Task ReleaseClaimAsync(DateTime retryNotBefore, CancellationToken ct = default) + public async Task SetNextAttemptAsync(DateTime notBefore, CancellationToken ct = default) { await using var db = factory.CreateDbContext(); - // LastAttemptDate is deliberately left stamped. It still records when - // the attempt happened, and RetryNotBefore alone is what reopens the - // claim -- see the second arm of the predicate above. + // LastAttemptDate is deliberately left stamped. It still records when the + // attempt happened; NextAttemptNotBefore alone is what reopens the claim. await db.BondValuationRefreshStates .Where(r => r.Id == BondValuationRefreshState.SingletonId) - .ExecuteUpdateAsync(s => s.SetProperty(r => r.RetryNotBefore, (DateTime?)retryNotBefore), ct); + .ExecuteUpdateAsync(s => s.SetProperty(r => r.NextAttemptNotBefore, (DateTime?)notBefore), ct); } } diff --git a/src/AppName.Infrastructure/Persistence/Context/AppDbContext.cs b/src/AppName.Infrastructure/Persistence/Context/AppDbContext.cs index c3669f9..32014f3 100644 --- a/src/AppName.Infrastructure/Persistence/Context/AppDbContext.cs +++ b/src/AppName.Infrastructure/Persistence/Context/AppDbContext.cs @@ -29,8 +29,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) bondValuationRefreshState.HasKey(r => r.Id); bondValuationRefreshState.Property(r => r.Id).ValueGeneratedNever(); - // Seed the singleton marker row. TryClaimAttemptAsync claims the daily - // refresh with a conditional UPDATE, and an UPDATE needs a row to hit. + // Seed the singleton marker row. TryClaimAttemptAsync claims a refresh + // attempt with a conditional UPDATE, and an UPDATE needs a row to hit. // Without the seed the row appears only on the first SetAsync, so every // caller would lose the claim until then and no refresh would ever run. // An anonymous object is used because the entity exposes Id as get-only @@ -40,7 +40,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) Id = BondValuationRefreshState.SingletonId, LastAsOf = (DateOnly?)null, LastAttemptDate = (DateOnly?)null, - RetryNotBefore = (DateTime?)null, + NextAttemptNotBefore = (DateTime?)null, }); } } diff --git a/tests/AppName.Application.Tests/Time/TaiwanClockTests.cs b/tests/AppName.Application.Tests/Time/TaiwanClockTests.cs new file mode 100644 index 0000000..dfcfc58 --- /dev/null +++ b/tests/AppName.Application.Tests/Time/TaiwanClockTests.cs @@ -0,0 +1,103 @@ +using AppName.Application.Time; + +namespace AppName.Application.Tests.Time; + +public class TaiwanClockTests +{ + // Taipei is UTC+8. Each instant below is written as the UTC that produces the + // Taipei wall-clock time named in the comment. + private static readonly DateTimeOffset WedBeforeClose = new(2026, 7, 1, 4, 0, 0, TimeSpan.Zero); // Wed 12:00 + private static readonly DateTimeOffset WedAfterClose = new(2026, 7, 1, 8, 0, 0, TimeSpan.Zero); // Wed 16:00 + private static readonly DateTimeOffset WedLateEvening = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero); // Wed 20:00 + private static readonly DateTimeOffset SatAfterClose = new(2026, 7, 4, 8, 0, 0, TimeSpan.Zero); // Sat 16:00 + private static readonly DateTimeOffset SunAfterClose = new(2026, 7, 5, 8, 0, 0, TimeSpan.Zero); // Sun 16:00 + private static readonly DateTimeOffset MonBeforeClose = new(2026, 7, 6, 4, 0, 0, TimeSpan.Zero); // Mon 12:00 + + [Fact] + public void ExpectedDataDate_ReturnsToday_WhenTheCloseHasBeenPublished() + { + var clock = new FixedTimeProvider(WedAfterClose); + + var expected = TaiwanClock.ExpectedDataDate(clock); + + Assert.Equal(new DateOnly(2026, 7, 1), expected); + } + + [Fact] + public void ExpectedDataDate_ReturnsThePreviousDay_WhenTheCloseIsNotPublishedYet() + { + var clock = new FixedTimeProvider(WedBeforeClose); + + var expected = TaiwanClock.ExpectedDataDate(clock); + + // Chasing today's date before it can exist is what left the gate open + // every morning. + Assert.Equal(new DateOnly(2026, 6, 30), expected); + } + + [Fact] + public void ExpectedDataDate_ReturnsFriday_OnASaturday() + { + var clock = new FixedTimeProvider(SatAfterClose); + + var expected = TaiwanClock.ExpectedDataDate(clock); + + // A weekend never publishes a close, so a Saturday date would sit forever + // ahead of the newest data and re-pull the whole market all weekend. + Assert.Equal(new DateOnly(2026, 7, 3), expected); + } + + [Fact] + public void ExpectedDataDate_ReturnsFriday_OnASunday() + { + var clock = new FixedTimeProvider(SunAfterClose); + + var expected = TaiwanClock.ExpectedDataDate(clock); + + Assert.Equal(new DateOnly(2026, 7, 3), expected); + } + + [Fact] + public void ExpectedDataDate_ReturnsFriday_OnAMondayMorning() + { + var clock = new FixedTimeProvider(MonBeforeClose); + + var expected = TaiwanClock.ExpectedDataDate(clock); + + Assert.Equal(new DateOnly(2026, 7, 3), expected); + } + + [Fact] + public void IsClosePublishGraceOpen_ReturnsFalse_BeforeThePublishTime() + { + var clock = new FixedTimeProvider(WedBeforeClose); + + Assert.False(TaiwanClock.IsClosePublishGraceOpen(clock)); + } + + [Fact] + public void IsClosePublishGraceOpen_ReturnsTrue_ShortlyAfterThePublishTime() + { + var clock = new FixedTimeProvider(WedAfterClose); + + Assert.True(TaiwanClock.IsClosePublishGraceOpen(clock)); + } + + [Fact] + public void IsClosePublishGraceOpen_ReturnsFalse_LateInTheEvening() + { + var clock = new FixedTimeProvider(WedLateEvening); + + // By now a day with no close of its own was not a trading day, and the + // refresh must stop retrying until tomorrow. + Assert.False(TaiwanClock.IsClosePublishGraceOpen(clock)); + } + + [Fact] + public void Today_ReturnsTheTaipeiDate() + { + var clock = new FixedTimeProvider(WedLateEvening); + + Assert.Equal(new DateOnly(2026, 7, 1), TaiwanClock.Today(clock)); + } +} diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs index 1af59c8..386d4d1 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs @@ -1,5 +1,6 @@ using AppName.Application.UseCases.Bonds; using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.ValueObjects.Bonds; using AppName.Domain.ValueObjects.Valuations; using Microsoft.Extensions.Logging.Abstractions; @@ -7,9 +8,18 @@ namespace AppName.Application.Tests.UseCases.Bonds; public class GetValuationCountUseCaseTests { - private static readonly DateTimeOffset FixedUtc = new(2026, 7, 2, 4, 0, 0, TimeSpan.Zero); // TW 2026-07-02 + private static readonly DateTimeOffset FixedUtc = new(2026, 7, 2, 4, 0, 0, TimeSpan.Zero); // TW 2026-07-02 12:00 private static readonly DateOnly TwToday = new(2026, 7, 2); + // Noon in Taipei is before the exchange publishes, so the newest data that can + // exist is the previous trading day's. + private static readonly DateOnly TwExpectedDataDate = new(2026, 7, 1); + + // DailyRefreshGate's own constants are internal to the Application project, so + // they are restated here rather than referenced. + private static readonly TimeSpan RetryBackoff = TimeSpan.FromMinutes(5); + private static readonly TimeSpan StalePollInterval = TimeSpan.FromMinutes(15); + private sealed class Fixture { public Mock RefreshState { get; } = new(); @@ -18,7 +28,7 @@ private sealed class Fixture public Fixture() { - RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny())) + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(true); Snapshots.Setup(s => s.CountAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(0); @@ -27,7 +37,7 @@ public Fixture() public Fixture WithLostClaim() { - RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny())) + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(false); return this; } @@ -53,9 +63,9 @@ public Fixture WithCancelledRefresh() return this; } - public Fixture WithFailingRelease() + public Fixture WithFailingNextAttemptWrite() { - RefreshState.Setup(r => r.ReleaseClaimAsync(It.IsAny(), It.IsAny())) + RefreshState.Setup(r => r.SetNextAttemptAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("database down")); return this; } @@ -81,7 +91,10 @@ public async Task ExecuteAsync_ClaimsTaiwanToday() await fx.Build().ExecuteAsync(ValuationFilter.None); - fx.RefreshState.Verify(r => r.TryClaimAttemptAsync(TwToday, It.IsAny(), It.IsAny()), Times.Once); + fx.RefreshState.Verify( + r => r.TryClaimAttemptAsync( + It.Is(c => c.TaiwanToday == TwToday), It.IsAny()), + Times.Once); } [Fact] @@ -124,60 +137,80 @@ public async Task ExecuteAsync_ReturnsCache_WhenRefreshThrows() Assert.Equal(412, count); // no throw; stale cache counted } - // The four tests below cover DailyRefreshGate's release path. They go through - // this use case rather than the gate directly, because the gate is internal - // and reached through the use cases by design — see its class remarks. + // The five tests below cover how DailyRefreshGate paces the next attempt. They + // go through this use case rather than the gate directly, because the gate is + // internal and reached through the use cases by design. [Fact] - public async Task ExecuteAsync_ReleasesClaimAfterBackoff_WhenRefreshThrows() + public async Task ExecuteAsync_SchedulesNextAttemptAfterBackoff_WhenRefreshThrows() { var fx = new Fixture().WithFailingRefresh(); await fx.Build().ExecuteAsync(ValuationFilter.None); - // Five minutes is DailyRefreshGate.RetryBackoff, spelled out because the - // constant is internal to the Application project. Without the release, - // one upstream failure would consume the whole trading day. + // The backoff replaces the claim's longer lease, so an upstream blip is + // retried sooner than a market that simply has nothing newer to give. fx.RefreshState.Verify( - r => r.ReleaseClaimAsync(FixedUtc.UtcDateTime.AddMinutes(5), It.IsAny()), + r => r.SetNextAttemptAsync(FixedUtc.UtcDateTime + RetryBackoff, It.IsAny()), Times.Once); } [Fact] - public async Task ExecuteAsync_ReleasesClaimImmediately_WhenRefreshIsCancelled() + public async Task ExecuteAsync_SchedulesNextAttemptImmediately_WhenRefreshIsCancelled() { var fx = new Fixture().WithCancelledRefresh(); - // Cancellation still propagates; only the claim is given back. + // Cancellation still propagates; only the next-attempt time is moved up. await Assert.ThrowsAsync( () => fx.Build().ExecuteAsync(ValuationFilter.None)); - // No backoff: a caller walking away is not an upstream fault, so the next - // reader should try at once. + // No backoff: a caller walking away is not an upstream fault, and nothing + // was refreshed, so the next reader should try at once. fx.RefreshState.Verify( - r => r.ReleaseClaimAsync(FixedUtc.UtcDateTime, It.IsAny()), + r => r.SetNextAttemptAsync(FixedUtc.UtcDateTime, It.IsAny()), Times.Once); } [Fact] - public async Task ExecuteAsync_KeepsClaim_WhenRefreshSucceeds() + public async Task ExecuteAsync_LeasesTheClaim_ForThePollInterval() { var fx = new Fixture(); await fx.Build().ExecuteAsync(ValuationFilter.None); + // The lease shuts the gate behind the winner. Nothing else can: the data + // date the claim tests against cannot move until this refresh finishes. fx.RefreshState.Verify( - r => r.ReleaseClaimAsync(It.IsAny(), It.IsAny()), - Times.Never); + r => r.TryClaimAttemptAsync( + It.Is(c => + c.ExpectedDataDate == TwExpectedDataDate + && c.Now == FixedUtc.UtcDateTime + && c.NextAttemptNotBefore == FixedUtc.UtcDateTime + StalePollInterval), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ExecuteAsync_SchedulesNextAttemptAfterPollInterval_WhenRefreshSucceeds() + { + var fx = new Fixture(); + + await fx.Build().ExecuteAsync(ValuationFilter.None); + + // A successful pull rewrites the marker row, so without this the next read + // would refresh again at once whenever the pull came back stale. + fx.RefreshState.Verify( + r => r.SetNextAttemptAsync(FixedUtc.UtcDateTime + StalePollInterval, It.IsAny()), + Times.Once); } [Fact] - public async Task ExecuteAsync_ReturnsCache_WhenReleasingTheClaimAlsoThrows() + public async Task ExecuteAsync_ReturnsCache_WhenSchedulingTheNextAttemptAlsoThrows() { - var fx = new Fixture().WithFailingRefresh().WithFailingRelease().WithCount(412); + var fx = new Fixture().WithFailingRefresh().WithFailingNextAttemptWrite().WithCount(412); - // The release runs on the failure path of a best-effort refresh, so it - // must not become the thing that fails the caller. + // This write runs on the failure path of a best-effort refresh, so it must + // not become the thing that fails the caller. Assert.Equal(412, await fx.Build().ExecuteAsync(ValuationFilter.None)); } } diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs index cfb3ccb..af21a7c 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs @@ -1,5 +1,6 @@ using AppName.Application.UseCases.Bonds; using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.ValueObjects.Bonds; using AppName.Domain.Entities.Bonds; using AppName.Domain.Querying; using AppName.Domain.ValueObjects.Valuations; @@ -28,7 +29,7 @@ private sealed class Fixture public Fixture() { - RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny())) + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(true); Snapshots.Setup(s => s.QueryAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Array.Empty()); @@ -39,7 +40,7 @@ public Fixture() public Fixture WithLostClaim() { - RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny())) + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(false); return this; } @@ -86,7 +87,10 @@ public async Task ExecuteAsync_ClaimsTaiwanToday() await fx.Build().ExecuteAsync(DefaultQuery); - fx.RefreshState.Verify(r => r.TryClaimAttemptAsync(TwToday, It.IsAny(), It.IsAny()), Times.Once); + fx.RefreshState.Verify( + r => r.TryClaimAttemptAsync( + It.Is(c => c.TaiwanToday == TwToday), It.IsAny()), + Times.Once); } [Fact] diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs index 38bf138..ad7a1bc 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs @@ -1,5 +1,6 @@ using AppName.Application.UseCases.Bonds; using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.ValueObjects.Bonds; using AppName.Domain.Entities.Bonds; using AppName.Domain.Querying; using AppName.Domain.ValueObjects.Valuations; @@ -26,7 +27,7 @@ private sealed class Fixture public Fixture() { - RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny())) + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(true); Snapshots.Setup(s => s.QueryAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Array.Empty()); @@ -35,7 +36,7 @@ public Fixture() public Fixture WithLostClaim() { - RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny())) + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(false); return this; } @@ -75,7 +76,10 @@ public async Task ExecuteAsync_ClaimsTaiwanToday() await fx.Build().ExecuteAsync(DefaultQuery); - fx.RefreshState.Verify(r => r.TryClaimAttemptAsync(TwToday, It.IsAny(), It.IsAny()), Times.Once); + fx.RefreshState.Verify( + r => r.TryClaimAttemptAsync( + It.Is(c => c.TaiwanToday == TwToday), It.IsAny()), + Times.Once); } [Fact] diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/RefreshAllBondsUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/RefreshAllBondsUseCaseTests.cs index 7d08ea6..ddb413b 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/RefreshAllBondsUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/RefreshAllBondsUseCaseTests.cs @@ -12,6 +12,7 @@ public class RefreshAllBondsUseCaseTests private static readonly DateTimeOffset FixedUtc = new(2026, 7, 2, 4, 0, 0, TimeSpan.Zero); private static readonly DateOnly TwToday = new(2026, 7, 2); private static readonly DateOnly QuoteDate = new(2026, 6, 30); + private static readonly DateTime PendingNextAttempt = new(2026, 7, 2, 4, 15, 0, DateTimeKind.Utc); private sealed class Fixture { @@ -34,11 +35,21 @@ public Fixture() Snapshots.Setup(s => s.UpsertManyAsync(It.IsAny>(), It.IsAny())) .Callback, CancellationToken>((s, _) => Saved.AddRange(s)) .Returns(Task.CompletedTask); + RefreshState.Setup(r => r.GetAsync(It.IsAny())) + .ReturnsAsync(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, null, null)); RefreshState.Setup(r => r.SetAsync(It.IsAny(), It.IsAny())) .Callback((s, _) => SavedState = s) .Returns(Task.CompletedTask); } + public Fixture WithStoredMarker(DateOnly? lastAsOf, DateTime? nextAttemptNotBefore) + { + RefreshState.Setup(r => r.GetAsync(It.IsAny())) + .ReturnsAsync(new BondValuationRefreshState( + BondValuationRefreshState.SingletonId, lastAsOf, null, nextAttemptNotBefore)); + return this; + } + public Fixture WithBonds(params ConvertibleBond[] bonds) { Provider.Setup(p => p.GetAllIssuanceTermsAsync(It.IsAny())).ReturnsAsync(bonds); @@ -134,4 +145,31 @@ public async Task ExecuteAsync_StampsRefreshState_WithTwTodayAndMaxAsOf() Assert.Equal(TwToday, fx.SavedState!.LastAttemptDate); Assert.Equal(QuoteDate, fx.SavedState.LastAsOf); } + + [Fact] + public async Task ExecuteAsync_KeepsStoredLastAsOf_WhenNoSnapshotsWereProduced() + { + var fx = new Fixture().WithStoredMarker(QuoteDate, null); + + await fx.Build().ExecuteAsync(); + + // LastAsOf is the key the refresh gate compares against. Writing null on a + // degraded pull would reopen the gate on every later read. + Assert.Equal(QuoteDate, fx.SavedState!.LastAsOf); + } + + [Fact] + public async Task ExecuteAsync_KeepsStoredNextAttemptTime_WhenStampingTheMarker() + { + var fx = new Fixture() + .WithStoredMarker(QuoteDate, PendingNextAttempt) + .WithBonds(new ConvertibleBond("11011", "台泥一永", 100_000m, 50m, "1101")) + .WithStockQuotes(new StockQuote("1101", 60m, QuoteDate)); + + await fx.Build().ExecuteAsync(); + + // This write replaces the whole marker row. Dropping the pending time would + // open the gate to a second caller before this refresh has finished. + Assert.Equal(PendingNextAttempt, fx.SavedState!.NextAttemptNotBefore); + } } diff --git a/tests/AppName.Infrastructure.Tests/Persistence/Bonds/BondValuationRefreshStateRepositoryTests.cs b/tests/AppName.Infrastructure.Tests/Persistence/Bonds/BondValuationRefreshStateRepositoryTests.cs index 6fe6713..15d2609 100644 --- a/tests/AppName.Infrastructure.Tests/Persistence/Bonds/BondValuationRefreshStateRepositoryTests.cs +++ b/tests/AppName.Infrastructure.Tests/Persistence/Bonds/BondValuationRefreshStateRepositoryTests.cs @@ -1,4 +1,5 @@ using AppName.Domain.Entities.Bonds; +using AppName.Domain.ValueObjects.Bonds; using AppName.Infrastructure.Persistence.Bonds; using AppName.Infrastructure.Persistence.Context; using Microsoft.Data.Sqlite; @@ -12,7 +13,13 @@ namespace AppName.Infrastructure.Tests.Persistence.Bonds; // the database, not the test, picks the winner. These tests prove the predicate. public class BondValuationRefreshStateRepositoryTests { + private static readonly DateOnly Today = new(2026, 7, 1); + private static readonly DateOnly Yesterday = new(2026, 6, 30); + private static readonly DateOnly Tomorrow = new(2026, 7, 2); + private static readonly DateTime Now = new(2026, 7, 1, 4, 0, 0, DateTimeKind.Utc); + private static readonly DateTime NextAttempt = Now.AddMinutes(15); + private static readonly DateTime RetryAt = Now.AddMinutes(5); private sealed class Fixture : IDisposable { @@ -22,6 +29,7 @@ private sealed class SingleContextFactory(DbContextOptions options } private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; public BondValuationRefreshStateRepository Repo { get; } @@ -30,19 +38,48 @@ public Fixture() _connection = new SqliteConnection("DataSource=:memory:"); _connection.Open(); - var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + _options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; // EnsureCreated applies HasData, so the seeded singleton marker row // exists before any test runs. TryClaimAttemptAsync needs that row, // because a conditional UPDATE cannot hit a table with no rows. - using (var ctx = new AppDbContext(options)) + using (var ctx = new AppDbContext(_options)) ctx.Database.EnsureCreated(); - Repo = new BondValuationRefreshStateRepository(new SingleContextFactory(options)); + Repo = new BondValuationRefreshStateRepository(new SingleContextFactory(_options)); + } + + // Writes the marker straight through a context rather than through the + // repository, so a test that arranges state does not also exercise the + // member it is about to assert on. + public Fixture WithMarker(DateOnly? lastAsOf, DateOnly? lastAttemptDate, DateTime? nextAttemptNotBefore = null) + { + using var ctx = new AppDbContext(_options); + + var row = ctx.BondValuationRefreshStates.Single(r => r.Id == BondValuationRefreshState.SingletonId); + row.Update(lastAsOf, lastAttemptDate, nextAttemptNotBefore); + ctx.SaveChanges(); + + return this; } public void Dispose() => _connection.Dispose(); } + private static BondValuationRefreshClaim Claim( + DateOnly expectedDataDate, + DateTime now, + DateTime nextAttemptNotBefore, + DateOnly? taiwanToday = null, + bool closePublishGraceOpen = true) => + new() + { + ExpectedDataDate = expectedDataDate, + TaiwanToday = taiwanToday ?? expectedDataDate, + IsClosePublishGraceOpen = closePublishGraceOpen, + Now = now, + NextAttemptNotBefore = nextAttemptNotBefore, + }; + [Fact] public async Task GetAsync_ReturnsDefault_WhenNeverSet() { @@ -60,23 +97,25 @@ public async Task SetAsync_ThenGet_RoundTrips() { using var fx = new Fixture(); - await fx.Repo.SetAsync(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, new DateOnly(2026, 7, 2), new DateOnly(2026, 7, 2))); - var state = await fx.Repo.GetAsync(); + await fx.Repo.SetAsync(new BondValuationRefreshState( + BondValuationRefreshState.SingletonId, Tomorrow, Tomorrow)); - Assert.Equal(new DateOnly(2026, 7, 2), state.LastAttemptDate); - Assert.Equal(new DateOnly(2026, 7, 2), state.LastAsOf); + var state = await fx.Repo.GetAsync(); + Assert.Equal(Tomorrow, state.LastAttemptDate); + Assert.Equal(Tomorrow, state.LastAsOf); } [Fact] public async Task SetAsync_Twice_UpdatesSingleRowInPlace() { - using var fx = new Fixture(); + using var fx = new Fixture() + .WithMarker(Today, Today); - await fx.Repo.SetAsync(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, new DateOnly(2026, 7, 1), new DateOnly(2026, 7, 1))); - await fx.Repo.SetAsync(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, new DateOnly(2026, 7, 2), new DateOnly(2026, 7, 2))); - var state = await fx.Repo.GetAsync(); + await fx.Repo.SetAsync(new BondValuationRefreshState( + BondValuationRefreshState.SingletonId, Tomorrow, Tomorrow)); - Assert.Equal(new DateOnly(2026, 7, 2), state.LastAttemptDate); + var state = await fx.Repo.GetAsync(); + Assert.Equal(Tomorrow, state.LastAttemptDate); } [Fact] @@ -84,122 +123,187 @@ public async Task TryClaimAttemptAsync_ReturnsTrue_OnFreshDatabase() { using var fx = new Fixture(); - // The seeded row has a null LastAttemptDate. This fails if the claim - // predicate drops its "== null" arm, because SQL evaluates - // NULL != @today as NULL and the UPDATE then matches no row. - Assert.True(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now)); + var won = await fx.Repo.TryClaimAttemptAsync(Claim(Today, Now, NextAttempt)); + + // The seeded row has a null LastAsOf. This fails if the claim predicate + // drops its "== null" arm, because SQL evaluates NULL < @date as NULL + // and the UPDATE then matches no row. + Assert.True(won); } [Fact] - public async Task TryClaimAttemptAsync_ReturnsFalse_OnSecondClaimForSameDay() + public async Task TryClaimAttemptAsync_ReturnsFalse_WhenCachedDataCarriesTheExpectedDate() { - using var fx = new Fixture(); + using var fx = new Fixture() + .WithMarker(Today, Today); - await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); + var won = await fx.Repo.TryClaimAttemptAsync(Claim(Today, Now, NextAttempt)); - Assert.False(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now)); + Assert.False(won); } [Fact] - public async Task TryClaimAttemptAsync_ReturnsTrue_OnNextDay() + public async Task TryClaimAttemptAsync_ReturnsTrue_WhenCachedDataIsOlderThanTheExpectedDate() { - using var fx = new Fixture(); + // The pull ran before the exchange published today's close, so it brought + // back yesterday's prices. Gating on the attempt would serve those until + // tomorrow; gating on the data tries again. + using var fx = new Fixture() + .WithMarker(Yesterday, Today); - await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); + var won = await fx.Repo.TryClaimAttemptAsync(Claim(Today, Now, NextAttempt)); - Assert.True(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 2), Now)); + Assert.True(won); } [Fact] - public async Task TryClaimAttemptAsync_RecordsClaimedDay_WhenClaimWins() + public async Task TryClaimAttemptAsync_ReturnsFalse_WhenTheDayHasAlreadyFailedToPublishAClose() { - using var fx = new Fixture(); + // A Taiwan holiday looks exactly like this: the day was attempted, the data + // never reached the expected date, and the grace period has run out. Without + // the attempt-date arm the whole market would be pulled again every interval + // until midnight. + using var fx = new Fixture() + .WithMarker(Yesterday, Today); - await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); - var state = await fx.Repo.GetAsync(); + var won = await fx.Repo.TryClaimAttemptAsync( + Claim(Today, Now, NextAttempt, closePublishGraceOpen: false)); - Assert.Equal(new DateOnly(2026, 7, 1), state.LastAttemptDate); + Assert.False(won); } [Fact] - public async Task TryClaimAttemptAsync_LeavesLastAsOfUnchanged_WhenClaimWins() + public async Task TryClaimAttemptAsync_ReturnsTrue_WhenTheGraceIsClosedButTheDayWasNeverAttempted() { - using var fx = new Fixture(); + // Opening the app late in the evening must still pick up today's close. + using var fx = new Fixture() + .WithMarker(Yesterday, Yesterday); - await fx.Repo.SetAsync(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, new DateOnly(2026, 6, 30), null)); + var won = await fx.Repo.TryClaimAttemptAsync( + Claim(Today, Now, NextAttempt, closePublishGraceOpen: false)); - await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); - var state = await fx.Repo.GetAsync(); + Assert.True(won); + } - // The claim marks only the attempt. LastAsOf still records the last - // successful pull, so a losing refresh must not appear as a success. - Assert.Equal(new DateOnly(2026, 6, 30), state.LastAsOf); + [Fact] + public async Task TryClaimAttemptAsync_ReturnsFalse_WhenNextAttemptTimeHasNotElapsed() + { + using var fx = new Fixture() + .WithMarker(null, null, NextAttempt); + + var won = await fx.Repo.TryClaimAttemptAsync(Claim(Today, Now.AddMinutes(1), NextAttempt)); + + // The stamp the winner left is the mutex: LastAsOf cannot move until that + // refresh finishes, so nothing else here would keep a second caller out. + Assert.False(won); } [Fact] - public async Task ReleaseClaimAsync_RecordsRetryNotBefore() + public async Task TryClaimAttemptAsync_ReturnsTrue_WhenNextAttemptTimeHasElapsed() + { + using var fx = new Fixture() + .WithMarker(null, null, NextAttempt); + + var won = await fx.Repo.TryClaimAttemptAsync(Claim(Today, NextAttempt, NextAttempt.AddMinutes(15))); + + Assert.True(won); + } + + [Fact] + public async Task TryClaimAttemptAsync_StampsNextAttemptTime_WhenClaimWins() { using var fx = new Fixture(); - var retryAt = Now.AddMinutes(5); - await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); - await fx.Repo.ReleaseClaimAsync(retryAt); + await fx.Repo.TryClaimAttemptAsync(Claim(Today, Now, NextAttempt)); + var state = await fx.Repo.GetAsync(); + Assert.Equal(NextAttempt, state.NextAttemptNotBefore); + } - Assert.Equal(retryAt, state.RetryNotBefore); + [Fact] + public async Task TryClaimAttemptAsync_ReturnsTrue_OnNextDay() + { + using var fx = new Fixture() + .WithMarker(Today, Today, NextAttempt); + + var won = await fx.Repo.TryClaimAttemptAsync( + Claim(Tomorrow, NextAttempt.AddDays(1), NextAttempt.AddDays(1))); + + Assert.True(won); } [Fact] - public async Task ReleaseClaimAsync_LeavesLastAttemptDateStamped() + public async Task TryClaimAttemptAsync_RecordsClaimedDay_WhenClaimWins() { using var fx = new Fixture(); - await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); - await fx.Repo.ReleaseClaimAsync(Now.AddMinutes(5)); + await fx.Repo.TryClaimAttemptAsync(Claim(Yesterday, Now, NextAttempt, taiwanToday: Today)); + + var state = await fx.Repo.GetAsync(); + Assert.Equal(Today, state.LastAttemptDate); + } + + [Fact] + public async Task TryClaimAttemptAsync_LeavesLastAsOfUnchanged_WhenClaimWins() + { + using var fx = new Fixture() + .WithMarker(Yesterday, null); + + await fx.Repo.TryClaimAttemptAsync(Claim(Today, Now, NextAttempt)); + + // The claim marks only the attempt. LastAsOf still records the last + // successful pull, so a losing refresh must not appear as a success. var state = await fx.Repo.GetAsync(); + Assert.Equal(Yesterday, state.LastAsOf); + } - // RetryNotBefore alone reopens the claim. The attempt date still records - // that an attempt was made today. - Assert.Equal(new DateOnly(2026, 7, 1), state.LastAttemptDate); + [Fact] + public async Task SetNextAttemptAsync_AfterAClaim_RecordsTheNewTime() + { + using var fx = new Fixture() + .WithMarker(null, Today, NextAttempt); + + await fx.Repo.SetNextAttemptAsync(RetryAt); + + var state = await fx.Repo.GetAsync(); + Assert.Equal(RetryAt, state.NextAttemptNotBefore); } [Fact] - public async Task TryClaimAttemptAsync_ReturnsFalse_WhenReleasedButBackoffHasNotElapsed() + public async Task SetNextAttemptAsync_AfterAClaim_LeavesLastAttemptDateStamped() { - using var fx = new Fixture(); + using var fx = new Fixture() + .WithMarker(null, Today, NextAttempt); - await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); - await fx.Repo.ReleaseClaimAsync(Now.AddMinutes(5)); + await fx.Repo.SetNextAttemptAsync(RetryAt); - // This is the whole point of the backoff: an outage must not make every - // read pay the upstream timeout again. - Assert.False(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now.AddMinutes(1))); + var state = await fx.Repo.GetAsync(); + Assert.Equal(Today, state.LastAttemptDate); } [Fact] - public async Task TryClaimAttemptAsync_ReturnsTrue_WhenReleasedAndBackoffHasElapsed() + public async Task TryClaimAttemptAsync_ReturnsTrue_WhenBackoffFromAFailedAttemptHasElapsed() { - using var fx = new Fixture(); + using var fx = new Fixture() + .WithMarker(null, Today, RetryAt); - await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); - await fx.Repo.ReleaseClaimAsync(Now.AddMinutes(5)); + var won = await fx.Repo.TryClaimAttemptAsync(Claim(Today, Now.AddMinutes(6), NextAttempt)); - // Same day, already claimed once -- the retry is what reopens it. - Assert.True(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now.AddMinutes(6))); + // A shorter backoff replaces the claim's worst-case lease, so a failed + // attempt is retried on its own schedule rather than the lease's. + Assert.True(won); } [Fact] - public async Task TryClaimAttemptAsync_ClearsRetryNotBefore_WhenRetryIsWon() + public async Task TryClaimAttemptAsync_ReturnsFalse_WhenBackoffFromAFailedAttemptHasNotElapsed() { - using var fx = new Fixture(); + using var fx = new Fixture() + .WithMarker(null, Today, RetryAt); - await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); - await fx.Repo.ReleaseClaimAsync(Now.AddMinutes(5)); - await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now.AddMinutes(6)); + var won = await fx.Repo.TryClaimAttemptAsync(Claim(Today, Now.AddMinutes(1), NextAttempt)); - // Clearing it in the same statement is what stops a second caller also - // taking the retry. - Assert.Null((await fx.Repo.GetAsync()).RetryNotBefore); - Assert.False(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now.AddMinutes(6))); + // This is the whole point of the backoff: an outage must not make every + // read pay the upstream timeout again. + Assert.False(won); } }