Skip to content

fix(sqlite): compare idempotency CreatedAt in UTC instead of as offset-dependent strings - #916

Merged
samtrion merged 3 commits into
mainfrom
fix/860-sqlite-idempotency-utc
Sep 29, 2026
Merged

samtrion merged 3 commits into
mainfrom
fix/860-sqlite-idempotency-utc

Conversation

@samtrion

Copy link
Copy Markdown
Contributor

Summary

SQLiteIdempotencyKeyRepository stored CreatedAt and bound validFrom as round-trip ("O") strings with whatever offset the caller passed, and then compared them as TEXT. String order only matches time order when both values use the same offset, so a key stored as 13:00+02:00 (11:00Z) was reported as existing for a 12:00Z cutoff, and the reverse case reported valid keys as expired. The repository now normalizes every timestamp to UTC before it binds it, and compares legacy rows that still carry a non-UTC offset as points in time.

Closes #860

Changes

  • src/NetEvolve.Pulse.SQLite/Idempotency/SQLiteIdempotencyKeyRepository.cs
    • New private ToUtcString helper (ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)). All four bind sites use it: ExistsAsync.validFrom, StoreAsync.createdAt, and TryReserveAsync.createdAt/validFrom. The issue only lists the first two because it predates feat(idempotency): refresh expired idempotency keys atomically on reserve and store #909. TryReserveAsync had the same defect.
    • New rows keep the exact format the built-in IdempotencyStore already writes (...+00:00). No schema change and no data migration are needed.
    • The TTL predicates in ExistsAsync and the TryReserveAsync upsert (ON CONFLICT ... DO UPDATE ... WHERE) use a shared SQL condition:
      • Rows ending in +00:00 are compared as text. This is fixed width, so it is exact to 100 ns, which is the same behavior as before for all rows the built-in store writes.
      • Any other (legacy) row falls back to julianday(CreatedAt) vs. julianday(@validFrom), so rows written with a non-UTC offset by earlier versions are handled correctly. The fallback is precise to the millisecond.
    • Class remarks updated to describe the UTC normalization.
  • tests/NetEvolve.Pulse.Tests.Integration/Idempotency/SQLiteAdoNetIdempotencyTests.cs: five SQLite-specific integration tests (see below).

No new ADR: this follows the accepted 2026-01-21-datetimeoffset-and-timeprovider-usage decision and the existing ToUniversalTime() pattern of the SQLite outbox, audit and dead-letter stores.

Bug hunt

Suspicion Confirmed? Test
ExistsAsync reports a key stored with a larger offset as existing although it was created before the cutoff (issue scenario) Yes (red before fix) Should_Treat_Offset_Key_Before_Cutoff_As_Absent
ExistsAsync reports a key as expired when the cutoff carries a larger offset (reverse case) Yes (red before fix) Should_Treat_Key_After_Offset_Cutoff_As_Present
TryReserveAsync (added in #909) does not refresh an expired key stored with a non-UTC offset Yes (red before fix) Should_Reserve_Offset_Key_Before_Cutoff
TryReserveAsync refreshes (steals) a live key when the cutoff carries a larger offset Yes (red before fix) Should_Not_Reserve_Key_After_Offset_Cutoff
A UTC-only parameter fix still misjudges existing rows that were stored with a non-UTC offset Yes (red before fix, would also stay red with a parameter-only fix) Should_Compare_Legacy_Offset_Row_In_Utc
PostgreSQL ADO.NET/EF providers reject DateTimeOffset values with a non-zero offset (Npgsql only accepts offset 0 for timestamptz) Not verified (needs Docker, out of scope) None. The offset tests were kept SQLite-specific instead of in IdempotencyTestsBase for this reason

Impact

  • Behavior change only for callers that use IIdempotencyKeyRepository from NetEvolve.Pulse.SQLite directly with non-UTC offsets. The built-in IdempotencyStore always passes TimeProvider.GetUtcNow(), so its rows and comparisons are unchanged.
  • No public API change, no schema change, no migration needed. Existing legacy rows with non-UTC offsets are compared correctly without rewriting them.

Test evidence

  • Red: dotnet test --project tests/NetEvolve.Pulse.Tests.Integration -c Release -f net10.0 --treenode-filter "/*/*/SQLiteAdoNetIdempotencyTests/*" on the test commit: 5 failed (the new tests), 15 succeeded, 3 skipped.
  • Green: --treenode-filter "/*/*/SQLite*IdempotencyTests/*" (ADO.NET + EF Core SQLite, all TFMs): 123 total, 105 succeeded, 0 failed (18 skipped, provider capability skips).
  • dotnet build Pulse.slnx -c Release: 0 warnings, 0 errors.
  • Unit tests (all TFMs): 7476 total, 7476 succeeded, 0 failed.
  • csharpier check .: clean.

@samtrion
samtrion requested a review from a team as a code owner September 29, 2026 03:17
@samtrion
samtrion requested a review from benwirren September 29, 2026 03:17
@codecov

codecov Bot commented Sep 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.93%. Comparing base (f727013) to head (79b9ec3).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #916      +/-   ##
==========================================
+ Coverage   95.85%   95.93%   +0.07%     
==========================================
  Files         275      275              
  Lines       11895    11902       +7     
  Branches     1125     1125              
==========================================
+ Hits        11402    11418      +16     
+ Misses        275      265      -10     
- Partials      218      219       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@samtrion
samtrion merged commit 14ba6f4 into main Sep 29, 2026
14 checks passed
@samtrion
samtrion deleted the fix/860-sqlite-idempotency-utc branch September 29, 2026 03:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: Compare idempotency CreatedAt in UTC instead of as offset-dependent strings in NetEvolve.Pulse.SQLite

1 participant