From c1de4de889538812f88e239795ec924e35963705 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Tue, 1 Sep 2026 16:37:27 -0600 Subject: [PATCH] Rename column 'RetryNotBefore' to 'NextAttemptNotBefore' in BondValuationRefreshStates table and update related code references. Add tests for TaiwanClock functionality and adjust use cases to accommodate new claim structure for bond valuation refresh state. Ensure repository tests reflect changes in claim logic and next attempt scheduling. --- src/AppName.Application/Time/TaiwanClock.cs | 57 ++++ .../UseCases/Bonds/DailyRefreshGate.cs | 61 +++-- .../UseCases/Bonds/RefreshAllBondsUseCase.cs | 16 +- .../IBondValuationRefreshStateRepository.cs | 41 +-- .../Bonds/BondValuationRefreshState.cs | 32 +-- .../Bonds/BondValuationRefreshClaim.cs | 43 +++ ...otBeforeToNextAttemptNotBefore.Designer.cs | 107 ++++++++ ...ameRetryNotBeforeToNextAttemptNotBefore.cs | 28 ++ .../Migrations/AppDbContextModelSnapshot.cs | 2 +- .../BondValuationRefreshStateRepository.cs | 55 ++-- .../Persistence/Context/AppDbContext.cs | 6 +- .../Time/TaiwanClockTests.cs | 103 ++++++++ .../Bonds/GetValuationCountUseCaseTests.cs | 85 ++++-- .../Bonds/GetValuationPageUseCaseTests.cs | 10 +- .../Bonds/GetValuationsUseCaseTests.cs | 10 +- .../Bonds/RefreshAllBondsUseCaseTests.cs | 38 +++ ...ondValuationRefreshStateRepositoryTests.cs | 244 +++++++++++++----- 17 files changed, 736 insertions(+), 202 deletions(-) create mode 100644 src/AppName.Domain/ValueObjects/Bonds/BondValuationRefreshClaim.cs create mode 100644 src/AppName.Infrastructure/Migrations/20260901194434_RenameRetryNotBeforeToNextAttemptNotBefore.Designer.cs create mode 100644 src/AppName.Infrastructure/Migrations/20260901194434_RenameRetryNotBeforeToNextAttemptNotBefore.cs create mode 100644 tests/AppName.Application.Tests/Time/TaiwanClockTests.cs 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); } }