Skip to content

#40 Inject TimeProvider - #46

Merged
matthewdevenny merged 5 commits into
mainfrom
matt/40-timeprovider
Jul 1, 2026
Merged

matthewdevenny merged 5 commits into
mainfrom
matt/40-timeprovider

Conversation

@matthewdevenny

@matthewdevenny matthewdevenny commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Closes #40

Problem

NatsCache used DateTimeOffset.Now throughout its expiration logic (TTL computation, entry creation, absolute-expiration checks, sliding-window updates). That relies on the local wall clock and makes expiration behavior impossible to unit-test without real delays.

Change

Route every clock read through an injected TimeProvider (default TimeProvider.System), resolved from DI. This is the .NET 8+ idiom and enables deterministic, instant expiration unit tests via FakeTimeProvider.

Library (src/NatsDistributedCache)

  • NatsCache.cs: replaced all DateTimeOffset.Now usages with TimeProvider.GetUtcNow(). The clock is exposed as an internal init-only TimeProvider property (default TimeProvider.System) rather than a constructor parameter, so the public constructor is unchanged and there is no ABI break. The absolute-expiration read check was extracted into IsAbsolutelyExpired, and the shared relative-vs-absolute resolution into ResolveAbsoluteExpiration.
  • NatsDistributedCacheExtensions.cs: the DI registration resolves an optional TimeProvider via sp.GetService<TimeProvider>() and sets the property, falling back to TimeProvider.System when none is registered.
  • README.md: documents overriding the clock by registering a TimeProvider in DI.

Switching from local-time DateTimeOffset.Now to UTC GetUtcNow() is behavior-preserving: every use is either an instant comparison or a duration, and DateTimeOffset operations are offset-aware. The one deliberate behavior change is that IsAbsolutelyExpired uses an inclusive >= boundary, matching GetTtl (which already treats an absolute expiration at "now" as elapsed) and BCL MemoryCache.

Tests (test/UnitTests)

  • Added the Microsoft.Extensions.TimeProvider.Testing package and regenerated the RID lock files.
  • TestBase injects a FakeTimeProvider (exposed as a property) via the init property and exposes the concrete NatsCache.
  • The existing absolute-expiration validation tests now drive time from TimeProvider.GetUtcNow().
  • New TimeProviderExpirationUnitTests: instant, no-delay tests for TTL computation, CreateCacheEntry, and absolute expiration flipping with FakeTimeProvider.Advance (including the exact-boundary case).
  • New DI tests proving a registered TimeProvider is used, and that resolution falls back to TimeProvider.System (verified against the real UTC clock) when none is registered.

The integration time-expiration tests (TimeExpirationTests/TimeExpirationAsyncTests) are intentionally left with real delays — they exercise NATS's server-side TTL, whose clock a FakeTimeProvider can't drive.

Acceptance criteria

  • ✅ No DateTimeOffset.Now remains in the library.
  • ✅ Time-expiration unit tests drive time with FakeTimeProvider (no real delays).
  • ✅ DI wiring resolves an optional TimeProvider, defaulting to TimeProvider.System.

Note on API surface

Injecting via the internal init property (instead of a constructor parameter) keeps the change purely additive — consumers compiled against 0.3.0 keep working without recompiling. The idiomatic way to override the clock is to register a TimeProvider in DI; consumers resolve IDistributedCache, so no public surface on the concrete NatsCache type is required.

Verification

  • dotnet build -p TreatWarningsAsErrors=true → 0 warnings (net8.0 + net10.0); the public constructor is byte-identical to main.
  • dotnet test test/UnitTests → 46/46 passing on both frameworks.
  • NuGet lock files regenerated and confirmed in sync (CI drift check passes).

🤖 Generated with Claude Code

Signed-off-by: Matthew DeVenny <matt@codecargo.com>
@matthewdevenny
matthewdevenny requested a review from Copilot June 30, 2026 19:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@matthewdevenny
matthewdevenny requested a review from Copilot June 30, 2026 19:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Extract the duplicated relative-expiration resolution shared by GetTtl
and CreateCacheEntry into a private ResolveAbsoluteExpiration helper so
the clock-source logic lives in one place.

Slim AddNatsCache_UsesRegisteredTimeProvider to assert that the
registered provider drives the computed expiration, instead of
re-testing the Advance->expire flip already covered by
TimeProviderExpirationUnitTests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Matthew DeVenny <matt@codecargo.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Comment thread src/NatsDistributedCache/NatsCache.cs Outdated
Comment thread src/NatsDistributedCache/NatsCache.cs Outdated
…usive

Rename IsExpired -> IsAbsolutelyExpired to make clear it only checks
absolute expiration (sliding expiration is enforced via the NATS entry
TTL), and tighten the comparison from > to >= so the absolute-expiration
instant is treated as elapsed. This matches GetTtl, which already treats
an absolute expiration at "now" as expired, and prevents the sliding
refresh path from extending an entry past its absolute expiration at the
exact boundary tick.

Add AbsoluteExpirationIsExpiredAtExactInstant to lock in the inclusive
boundary semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Matthew DeVenny <matt@codecargo.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Comment thread src/NatsDistributedCache/NatsCache.cs
Comment thread test/UnitTests/Cache/TimeProviderExpirationUnitTests.cs Outdated
GetTtlComputesRelativeExpirationFromProviderTime -> GetTtlReturnsConfiguredRelativeExpiration.
A relative expiration produces a TTL equal to the configured duration
regardless of the clock, so the old name overpromised provider-time
dependence.

Signed-off-by: Matthew DeVenny <matt@codecargo.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@matthewdevenny
matthewdevenny requested a review from mtmk June 30, 2026 20:39
@matthewdevenny
matthewdevenny marked this pull request as ready for review June 30, 2026 20:39

@mtmk mtmk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Keep the NatsCache constructor at its original 4-parameter signature
(no ABI break) and expose the clock as an internal init-only
TimeProvider property. The DI registration resolves an optional
TimeProvider from the container and sets it via object initializer,
defaulting to TimeProvider.System when none is registered.

Override the clock by registering a TimeProvider in DI (documented in
the README); external consumers no longer set it through the ctor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Matthew DeVenny <matt@codecargo.com>
@matthewdevenny
matthewdevenny merged commit 3515550 into main Jul 1, 2026
2 checks passed
@matthewdevenny
matthewdevenny deleted the matt/40-timeprovider branch July 1, 2026 15:55
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.

Inject TimeProvider for testable, UTC-based expiration

3 participants