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);
}
}