Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/AppName.Application/Time/TaiwanClock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,66 @@ public static class TaiwanClock
{
private static readonly TimeZoneInfo Taipei = TimeZoneInfo.FindSystemTimeZoneById("Asia/Taipei");

/// <summary>
/// Taipei local time by which the exchange has published the day's close.
/// </summary>
private static readonly TimeOnly ClosingQuotesPublishedBy = new(15, 0);

/// <summary>
/// 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.
/// </summary>
private static readonly TimeOnly ClosePublishGraceEndsAt = new(18, 0);

/// <summary>
/// Returns today's date in the Asia/Taipei time zone.
/// </summary>
public static DateOnly Today(TimeProvider timeProvider) =>
DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(timeProvider.GetUtcNow(), Taipei).DateTime);

/// <summary>
/// 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.
/// </summary>
/// <param name="timeProvider">Supplies the current instant.</param>
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;
}

/// <summary>
/// Returns true while a missing close for <see cref="ExpectedDataDate"/> is
/// still plausibly late rather than proof of a non-trading day.
/// </summary>
/// <param name="timeProvider">Supplies the current instant.</param>
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;
}
}
61 changes: 41 additions & 20 deletions src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
internal static class DailyRefreshGate
{
Expand All @@ -16,12 +17,17 @@ internal static class DailyRefreshGate
internal static readonly TimeSpan RetryBackoff = TimeSpan.FromMinutes(5);

/// <summary>
/// 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.
/// </summary>
internal static readonly TimeSpan StalePollInterval = TimeSpan.FromMinutes(15);

/// <summary>
/// Refreshes the valuation cache when this caller wins the claim, and
/// returns without refreshing when it loses.
/// </summary>
/// <param name="refreshStateRepo">Holds the singleton refresh marker the claim updates.</param>
/// <param name="refreshAllBonds">Runs the whole-market refresh.</param>
/// <param name="timeProvider">Resolves today's Taiwan trading date.</param>
/// <param name="timeProvider">Resolves the Taiwan trading dates the claim is judged against.</param>
/// <param name="logger">Records a failed attempt, which is otherwise invisible to the caller.</param>
/// <param name="ct">Cancels the claim and the refresh.</param>
internal static async Task EnsureFreshAsync(
Expand All @@ -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;
}
Expand All @@ -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)
Expand All @@ -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);
}

/// <summary>
/// Releases the claim, swallowing any failure to do so.
/// Records when the next attempt may run, swallowing any failure to do so.
/// </summary>
/// <remarks>
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.");
}
}
}
16 changes: 14 additions & 2 deletions src/AppName.Application/UseCases/Bonds/RefreshAllBondsUseCase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AppName.Domain.Entities.Bonds;
using AppName.Domain.ValueObjects.Bonds;

namespace AppName.Domain.Abstractions.Bonds;

Expand All @@ -18,42 +19,22 @@ public interface IBondValuationRefreshStateRepository
Task SetAsync(BondValuationRefreshState state, CancellationToken ct = default);

/// <summary>
/// Claims today's refresh attempt for exactly one caller.
/// Claims the next refresh attempt for exactly one caller.
/// </summary>
/// <param name="today">The Taiwan calendar date of the attempt.</param>
/// <param name="now">The UTC instant the claim is made, compared against a pending retry.</param>
/// <param name="claim">Everything the claim predicate compares the marker against.</param>
/// <param name="ct">Cancels the claim.</param>
/// <returns>
/// True when this caller won the claim and must run the refresh; false when
/// another caller already claimed <paramref name="today"/> 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.
/// </returns>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// A caller also wins when the day is already claimed but a previous
/// attempt released it and its
/// <see cref="BondValuationRefreshState.RetryNotBefore"/> has passed. A
/// won claim clears that marker, so the retry is handed to exactly one
/// caller too.
/// </para>
/// </remarks>
Task<bool> TryClaimAttemptAsync(DateOnly today, DateTime now, CancellationToken ct = default);
Task<bool> TryClaimAttemptAsync(BondValuationRefreshClaim claim, CancellationToken ct = default);

/// <summary>
/// Gives today's claim back after a failed attempt, so it can be retried
/// once <paramref name="retryNotBefore"/> has passed.
/// Records the earliest UTC instant at which the next refresh attempt may be
/// claimed.
/// </summary>
/// <param name="retryNotBefore">The earliest UTC instant another caller may claim the day again.</param>
/// <param name="ct">Cancels the release.</param>
/// <remarks>
/// 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.
/// </remarks>
Task ReleaseClaimAsync(DateTime retryNotBefore, CancellationToken ct = default);
/// <param name="notBefore">The earliest UTC instant another caller may claim an attempt.</param>
/// <param name="ct">Cancels the write.</param>
Task SetNextAttemptAsync(DateTime notBefore, CancellationToken ct = default);
}
32 changes: 9 additions & 23 deletions src/AppName.Domain/Entities/Bonds/BondValuationRefreshState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,14 @@
namespace AppName.Domain.Entities.Bonds;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Creates a refresh-state marker.
/// </remarks>
public sealed class BondValuationRefreshState(
long id,
DateOnly? lastAsOf,
DateOnly? lastAttemptDate,
DateTime? retryNotBefore = null)
DateTime? nextAttemptNotBefore = null)
{
/// <summary>
/// The fixed primary-key value of the single marker row.
Expand All @@ -39,30 +36,19 @@ public sealed class BondValuationRefreshState(
public DateOnly? LastAttemptDate { get; private set; } = lastAttemptDate;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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 <see cref="DateTime"/> rather than a
/// <see cref="DateTimeOffset"/> because the repository compares it in SQL,
/// and EF Core's SQLite provider -- which the repository tests run on --
/// cannot translate a DateTimeOffset comparison.
/// </remarks>
[DisplayName("Retry Not Before")]
public DateTime? RetryNotBefore { get; private set; } = retryNotBefore;
[DisplayName("Next Attempt Not Before")]
public DateTime? NextAttemptNotBefore { get; private set; } = nextAttemptNotBefore;

/// <summary>
/// Replaces the recorded markers after a pull attempt.
/// </summary>
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;
}
}
43 changes: 43 additions & 0 deletions src/AppName.Domain/ValueObjects/Bonds/BondValuationRefreshClaim.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.ComponentModel;

namespace AppName.Domain.ValueObjects.Bonds;

/// <summary>
/// One caller's bid to run the next whole-market bond refresh, carrying every
/// value the claim predicate compares the stored marker against.
/// </summary>
public sealed record BondValuationRefreshClaim
{
/// <summary>
/// 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.
/// </summary>
[DisplayName("Expected Data Date")]
public required DateOnly ExpectedDataDate { get; init; }

/// <summary>
/// The Taiwan calendar date of this attempt, stamped on a won claim.
/// </summary>
[DisplayName("Taiwan Today")]
public required DateOnly TaiwanToday { get; init; }

/// <summary>
/// True while a missing close for <see cref="ExpectedDataDate"/> is still
/// plausibly late rather than proof that the day was not a trading day.
/// </summary>
[DisplayName("Is Close Publish Grace Open")]
public required bool IsClosePublishGraceOpen { get; init; }

/// <summary>
/// The UTC instant the claim is made, compared against the stored
/// next-attempt time.
/// </summary>
[DisplayName("Now")]
public required DateTime Now { get; init; }

/// <summary>
/// The UTC instant a later caller may claim again, stamped by a won claim.
/// </summary>
[DisplayName("Next Attempt Not Before")]
public required DateTime NextAttemptNotBefore { get; init; }
}
Loading
Loading