diff --git a/AGENTS.md b/AGENTS.md index bf173c0..7df19df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,6 +218,14 @@ catch (Exception ex) - WindowCache is **NOT designed for multiple users sharing one cache** (violates coherent access pattern) - Multiple threads from the SAME logical consumer CAN call WindowCache safely (read-only User Path) +**Consistency Modes (three options):** +- **Eventual consistency** (default): `GetDataAsync` — returns immediately, cache converges in background +- **Hybrid consistency**: `GetDataAndWaitOnMissAsync` — waits for idle only on `PartialHit` or `FullMiss`; returns immediately on `FullHit`. Use for warm-cache guarantees without always paying the idle-wait cost. +- **Strong consistency**: `GetDataAndWaitForIdleAsync` — always waits for idle regardless of `CacheInteraction` + +**Serialized Access Requirement for Hybrid/Strong Modes:** +`GetDataAndWaitOnMissAsync` and `GetDataAndWaitForIdleAsync` provide their warm-cache guarantee only under **serialized (one-at-a-time) access**. Under parallel access, `WaitForIdleAsync`'s "was idle at some point" semantics (Invariant H.49) may return the old completed TCS, missing the rebalance triggered by the concurrent request. These methods remain safe (no crashes/hangs) but the guarantee degrades under parallelism. + **Lock-Free Operations:** ```csharp // Intent management using Volatile and Interlocked diff --git a/README.md b/README.md index 5370f98..ff360ed 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Sliding Window Cache -A read-only, range-based, sequential-optimized cache with decision-driven background rebalancing, smart eventual consistency, and intelligent work avoidance. +A read-only, range-based, sequential-optimized cache with decision-driven background rebalancing, three consistency modes (eventual/hybrid/strong), and intelligent work avoidance. [![CI/CD](https://github.com/blaze6950/SlidingWindowCache/actions/workflows/slidingwindowcache.yml/badge.svg)](https://github.com/blaze6950/SlidingWindowCache/actions/workflows/slidingwindowcache.yml) [![NuGet](https://img.shields.io/nuget/v/SlidingWindowCache.svg)](https://www.nuget.org/packages/SlidingWindowCache/) @@ -17,6 +17,7 @@ Optimized for access patterns that move predictably across a domain (scrolling, - Single-writer architecture: only rebalance execution mutates shared cache state - Decision-driven execution: multi-stage analytical validation prevents thrashing and unnecessary I/O - Smart eventual consistency: cache converges to optimal configuration while avoiding unnecessary work +- Opt-in hybrid or strong consistency via extension methods (`GetDataAndWaitOnMissAsync`, `GetDataAndWaitForIdleAsync`) For the canonical architecture docs, see `docs/architecture.md`. @@ -126,7 +127,7 @@ Key points: 2. **Decision happens in background** — CPU-only validation (microseconds) in the intent processing loop 3. **Work avoidance prevents thrashing** — validation may skip rebalance entirely if unnecessary 4. **Only I/O happens asynchronously** — debounce + data fetching + cache updates run in background -5. **Smart eventual consistency** — cache converges to optimal state while avoiding unnecessary operations +5. **Smart eventual consistency** — cache converges to optimal state while avoiding unnecessary operations; opt-in hybrid or strong consistency via extension methods ## Materialization for Fast Access @@ -143,23 +144,17 @@ For detailed comparison and guidance, see `docs/storage-strategies.md`. ```csharp using SlidingWindowCache; -using SlidingWindowCache.Configuration; +using SlidingWindowCache.Public.Cache; +using SlidingWindowCache.Public.Configuration; using Intervals.NET; using Intervals.NET.Domain.Default.Numeric; -var options = new WindowCacheOptions( - leftCacheSize: 1.0, // Cache 100% of requested range size to the left - rightCacheSize: 2.0, // Cache 200% of requested range size to the right - leftThreshold: 0.2, // Rebalance if <20% left buffer remains - rightThreshold: 0.2 // Rebalance if <20% right buffer remains -); - -var cache = WindowCache.Create( - dataSource: myDataSource, - domain: new IntegerFixedStepDomain(), - options: options, - readMode: UserCacheReadMode.Snapshot -); +await using var cache = WindowCacheBuilder.For(myDataSource, new IntegerFixedStepDomain()) + .WithOptions(o => o + .WithCacheSize(left: 1.0, right: 2.0) // 100% left / 200% right of requested range + .WithReadMode(UserCacheReadMode.Snapshot) + .WithThresholds(0.2)) // rebalance if <20% buffer remains + .Build(); var result = await cache.GetDataAsync(Range.Closed(100, 200), cancellationToken); @@ -167,9 +162,47 @@ foreach (var item in result.Data.Span) Console.WriteLine(item); ``` +## Implementing a Data Source + +Implement `IDataSource` to connect the cache to your backing store. The `FetchAsync` single-range overload is the only method you must provide; the batch overload has a default implementation that parallelizes single-range calls. + +### FuncDataSource — inline without a class + +`FuncDataSource` wraps an async delegate so you can create a data source in one expression: + +```csharp +using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Dto; + +// Unbounded source — always returns data for any range +IDataSource source = new FuncDataSource( + async (range, ct) => + { + var data = await myService.QueryAsync(range, ct); + return new RangeChunk(range, data); + }); +``` + +For **bounded** sources (database with min/max IDs, time-series with temporal limits), return a `RangeChunk` with `Range = null` when no data is available — never throw: + +```csharp +IDataSource bounded = new FuncDataSource( + async (range, ct) => + { + var available = range.Intersect(Range.Closed(minId, maxId)); + if (available is null) + return new RangeChunk(null, []); + + var records = await db.FetchAsync(available, ct); + return new RangeChunk(available, records); + }); +``` + +For sources where a dedicated class is warranted (custom batch optimization, retry logic, dependency injection), implement `IDataSource` directly. See `docs/boundary-handling.md` for the full boundary contract. + ## Boundary Handling -`GetDataAsync` returns `RangeResult` where `Range` may be `null` when the data source has no data for the requested range. Always check before accessing data: +`GetDataAsync` returns `RangeResult` where `Range` may be `null` when the data source has no data for the requested range, and `CacheInteraction` indicates whether the request was a `FullHit`, `PartialHit`, or `FullMiss`. Always check `Range` before accessing data: ```csharp var result = await cache.GetDataAsync(Range.Closed(100, 200), ct); @@ -267,6 +300,51 @@ var options = new WindowCacheOptions( ); ``` +## Runtime Options Update + +Cache sizing, threshold, and debounce options can be changed on a live cache instance without recreation. Updates take effect on the **next rebalance decision/execution cycle**. + +```csharp +// Change left and right cache sizes at runtime +cache.UpdateRuntimeOptions(update => + update.WithLeftCacheSize(3.0) + .WithRightCacheSize(3.0)); + +// Change debounce delay +cache.UpdateRuntimeOptions(update => + update.WithDebounceDelay(TimeSpan.Zero)); + +// Change thresholds — or clear a threshold to null +cache.UpdateRuntimeOptions(update => + update.WithLeftThreshold(0.15) + .ClearRightThreshold()); +``` + +`UpdateRuntimeOptions` uses a **fluent builder** (`RuntimeOptionsUpdateBuilder`). Only fields explicitly set via builder calls are changed — all other options remain at their current values. + +**Constraints:** +- `ReadMode` and `RebalanceQueueCapacity` are creation-time only and cannot be changed at runtime. +- All validation rules from construction still apply (`ArgumentOutOfRangeException` for negative sizes, `ArgumentException` for threshold sum > 1.0, etc.). A failed update leaves the current options unchanged — no partial application. +- Calling `UpdateRuntimeOptions` on a disposed cache throws `ObjectDisposedException`. + +**`LayeredWindowCache`** delegates `UpdateRuntimeOptions` to the outermost (user-facing) layer. To update a specific inner layer, use the `Layers` property (see Multi-Layer Cache below). + +## Reading Current Runtime Options + +Use `CurrentRuntimeOptions` to inspect the live option values on any cache instance. It returns a `RuntimeOptionsSnapshot` — a read-only point-in-time copy of the five runtime-updatable values. + +```csharp +var snapshot = cache.CurrentRuntimeOptions; +Console.WriteLine($"Left: {snapshot.LeftCacheSize}, Right: {snapshot.RightCacheSize}"); + +// Useful for relative updates — double the current left size: +var current = cache.CurrentRuntimeOptions; +cache.UpdateRuntimeOptions(u => u.WithLeftCacheSize(current.LeftCacheSize * 2)); +``` + +The snapshot is immutable. Subsequent calls to `UpdateRuntimeOptions` do not affect previously obtained snapshots — obtain a new snapshot to see updated values. + +- Calling `CurrentRuntimeOptions` on a disposed cache throws `ObjectDisposedException`. ## Diagnostics ⚠️ **CRITICAL: You MUST handle `RebalanceExecutionFailed` in production.** Rebalance operations run in background tasks. Without handling this event, failures are silently swallowed and the cache stops rebalancing with no indication. @@ -320,9 +398,50 @@ Canonical guide: `docs/diagnostics.md`. 6. `docs/state-machine.md` — formal state transitions and mutation ownership 7. `docs/actors.md` — actor responsibilities and execution contexts -## Strong Consistency Mode +## Consistency Modes -By default, `GetDataAsync` is **eventually consistent**: data is returned immediately while the cache window converges asynchronously in the background. For scenarios where you need the cache to be fully converged before proceeding, use the `GetDataAndWaitForIdleAsync` extension method: +By default, `GetDataAsync` is **eventually consistent**: data is returned immediately while the cache window converges asynchronously in the background. Two opt-in extension methods provide stronger consistency guarantees. Both require a `using SlidingWindowCache.Public;` import. + +> **Serialized access requirement:** The hybrid and strong consistency modes provide their warm-cache guarantee only when requests are made one at a time (serialized). Under concurrent/parallel callers they remain safe (no crashes or hangs) but the guarantee degrades — due to `AsyncActivityCounter`'s "was idle at some point" semantics (Invariant H.49) and a brief gap between the counter increment and TCS publication in `IncrementActivity`, a concurrent waiter may observe a previously completed idle TCS and return without waiting for the new rebalance. + +### Eventual Consistency (Default) + +```csharp +// Returns immediately; rebalance converges asynchronously in background +var result = await cache.GetDataAsync(Range.Closed(100, 200), cancellationToken); +``` + +Use for all hot paths and rapid sequential access. No latency beyond data assembly. + +### Hybrid Consistency — `GetDataAndWaitOnMissAsync` + +```csharp +using SlidingWindowCache.Public; + +// Waits for idle only if the request was a PartialHit or FullMiss; returns immediately on FullHit +var result = await cache.GetDataAndWaitOnMissAsync( + Range.Closed(100, 200), + cancellationToken); + +// result.CacheInteraction tells you which path was taken: +// CacheInteraction.FullHit → returned immediately (no wait) +// CacheInteraction.PartialHit → waited for cache to converge +// CacheInteraction.FullMiss → waited for cache to converge +if (result.Range.HasValue) + ProcessData(result.Data); +``` + +**When to use:** +- Warm-cache fast path: pays no penalty on cache hits, still waits on misses +- Access patterns where most requests are hits but you want convergence on misses + +**When NOT to use:** +- First request (always a miss — pays full debounce + I/O wait) +- Hot paths with many misses + +> **Cancellation:** If the cancellation token fires during the idle wait (after `GetDataAsync` has already returned data), the method catches `OperationCanceledException` and returns the already-obtained result gracefully — degrading to eventual consistency for that call. The background rebalance is not affected. + +### Strong Consistency — `GetDataAndWaitForIdleAsync` ```csharp using SlidingWindowCache.Public; @@ -347,17 +466,30 @@ This is a thin composition of `GetDataAsync` followed by `WaitForIdleAsync`. The **When NOT to use:** - Hot paths or rapid sequential requests — each call waits for full rebalance, which includes the debounce delay plus data fetching. For normal usage, the default eventual consistency model is faster. +> **Cancellation:** If the cancellation token fires during the idle wait (after `GetDataAsync` has already returned data), the method catches `OperationCanceledException` and returns the already-obtained result gracefully — degrading to eventual consistency for that call. The background rebalance is not affected. + ### Deterministic Testing `WaitForIdleAsync()` provides race-free synchronization with background operations for tests. Uses "was idle at some point" semantics — does not guarantee still idle after completion. See `docs/invariants.md` (Activity tracking invariants). +### CacheInteraction on RangeResult + +Every `RangeResult` carries a `CacheInteraction` property classifying the request: + +| Value | Meaning | +|--------------|---------------------------------------------------------------------------------| +| `FullHit` | Entire requested range was served from cache | +| `PartialHit` | Request partially overlapped the cache; missing part fetched from `IDataSource` | +| `FullMiss` | No overlap (cold start or jump); full range fetched from `IDataSource` | + +This is the per-request programmatic alternative to the `UserRequestFullCacheHit` / `UserRequestPartialCacheHit` / `UserRequestFullCacheMiss` diagnostics callbacks. + ## Multi-Layer Cache For workloads with high-latency data sources, you can compose multiple `WindowCache` instances into a layered stack. Each layer uses the layer below it as its data source, allowing you to trade memory for reduced data-source I/O. ```csharp -await using var cache = LayeredWindowCacheBuilder - .Create(realDataSource, domain) +await using var cache = WindowCacheBuilder.Layered(realDataSource, domain) .AddLayer(new WindowCacheOptions( // L2: deep background cache leftCacheSize: 10.0, rightCacheSize: 10.0, @@ -375,6 +507,21 @@ var result = await cache.GetDataAsync(range, ct); `LayeredWindowCache` implements `IWindowCache` and is `IAsyncDisposable` — it owns and disposes all layers when you dispose it. +**Accessing and updating individual layers:** + +Use the `Layers` property to access any specific layer by index (0 = innermost, last = outermost). Each layer exposes the full `IWindowCache` interface: + +```csharp +// Update options on the innermost (deep background) layer +layeredCache.Layers[0].UpdateRuntimeOptions(u => u.WithLeftCacheSize(8.0)); + +// Inspect the outermost (user-facing) layer's current options +var outerOptions = layeredCache.Layers[^1].CurrentRuntimeOptions; + +// cache.UpdateRuntimeOptions() is shorthand for Layers[^1].UpdateRuntimeOptions() +layeredCache.UpdateRuntimeOptions(u => u.WithRightCacheSize(1.0)); +``` + **Recommended layer configuration pattern:** - **Inner layers** (closest to the data source): `CopyOnRead`, large buffer sizes (5–10×), handles the heavy prefetching - **Outer (user-facing) layer**: `Snapshot`, small buffer sizes (0.3–1.0×), zero-allocation reads @@ -393,8 +540,7 @@ var result = await cache.GetDataAsync(range, ct); **Three-layer example:** ```csharp -await using var cache = LayeredWindowCacheBuilder - .Create(realDataSource, domain) +await using var cache = WindowCacheBuilder.Layered(realDataSource, domain) .AddLayer(l3Options) // L3: 10× CopyOnRead — network/disk absorber .AddLayer(l2Options) // L2: 2× CopyOnRead — mid-level buffer .AddLayer(l1Options) // L1: 0.5× Snapshot — user-facing diff --git a/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/ExecutionStrategyBenchmarks.cs b/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/ExecutionStrategyBenchmarks.cs index 82d2d6a..ab20a0b 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/ExecutionStrategyBenchmarks.cs +++ b/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/ExecutionStrategyBenchmarks.cs @@ -4,6 +4,7 @@ using Intervals.NET.Domain.Extensions.Fixed; using SlidingWindowCache.Benchmarks.Infrastructure; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; namespace SlidingWindowCache.Benchmarks.Benchmarks; diff --git a/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/RebalanceFlowBenchmarks.cs b/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/RebalanceFlowBenchmarks.cs index 58a9459..731299d 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/RebalanceFlowBenchmarks.cs +++ b/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/RebalanceFlowBenchmarks.cs @@ -4,6 +4,7 @@ using Intervals.NET.Domain.Extensions.Fixed; using SlidingWindowCache.Benchmarks.Infrastructure; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; namespace SlidingWindowCache.Benchmarks.Benchmarks; diff --git a/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/ScenarioBenchmarks.cs b/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/ScenarioBenchmarks.cs index 4cb20e8..717072f 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/ScenarioBenchmarks.cs +++ b/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/ScenarioBenchmarks.cs @@ -3,6 +3,7 @@ using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Benchmarks.Infrastructure; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; namespace SlidingWindowCache.Benchmarks.Benchmarks; diff --git a/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/UserFlowBenchmarks.cs b/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/UserFlowBenchmarks.cs index 473e999..05b7bb5 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/UserFlowBenchmarks.cs +++ b/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/UserFlowBenchmarks.cs @@ -4,6 +4,7 @@ using Intervals.NET.Domain.Extensions.Fixed; using SlidingWindowCache.Benchmarks.Infrastructure; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; namespace SlidingWindowCache.Benchmarks.Benchmarks; diff --git a/docs/actors.md b/docs/actors.md index 1622623..ef7385d 100644 --- a/docs/actors.md +++ b/docs/actors.md @@ -50,7 +50,7 @@ Invariant ownership - 24f. Delivered data represents what user actually received Components -- `WindowCache` (facade / composition root) +- `WindowCache` (facade / composition root; also owns `RuntimeCacheOptionsHolder` and exposes `UpdateRuntimeOptions`) - `UserRequestHandler` - `CacheDataExtensionService` @@ -77,8 +77,8 @@ Invariant ownership - 35. Threshold sum constraint (leftThreshold + rightThreshold ≤ 1.0) Components -- `ProportionalRangePlanner` — computes `DesiredCacheRange` -- `NoRebalanceSatisfactionPolicy` / `NoRebalanceRangePlanner` — computes `NoRebalanceRange` +- `ProportionalRangePlanner` — computes `DesiredCacheRange`; reads configuration from `RuntimeCacheOptionsHolder` at invocation time +- `NoRebalanceSatisfactionPolicy` / `NoRebalanceRangePlanner` — computes `NoRebalanceRange`; reads configuration from `RuntimeCacheOptionsHolder` at invocation time --- diff --git a/docs/architecture.md b/docs/architecture.md index 523b1bb..6cdb8f5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,6 +80,8 @@ While the single-writer architecture eliminates write-write races between User P | **Task-based** (default) | `rebalanceQueueCapacity: null` | Lock-free task chaining | None (returns immediately) | Recommended for most scenarios | | **Channel-based** | `rebalanceQueueCapacity: >= 1` | `System.Threading.Channels` bounded | Async await on `WriteAsync()` when full | High-frequency or resource-constrained | +Both strategies extend `RebalanceExecutionControllerBase`, which implements the shared execution pipeline (`ExecuteRequestCoreAsync`: debounce + execute), last-execution-request tracking, and idempotent `DisposeAsync`. Concrete subclasses implement only the publication mechanism (`PublishExecutionRequest`) and their own disposal cleanup (`DisposeAsyncCore`). + **Task-Based Strategy (default):** - Lock-free using volatile write (single-writer pattern — only intent processing loop writes) - Fire-and-forget: returns `ValueTask.CompletedTask` immediately, executes on ThreadPool @@ -106,6 +108,23 @@ While the single-writer architecture eliminates write-write races between User P - Use **Task-based** for normal operation, maximum performance, minimal overhead - Use **Channel-based** for high-frequency rebalance scenarios requiring backpressure, or memory-constrained environments +### Runtime-Updatable Options + +A subset of cache configuration — `LeftCacheSize`, `RightCacheSize`, `LeftThreshold`, `RightThreshold`, and `DebounceDelay` — can be changed on a live cache instance without reconstruction via `IWindowCache.UpdateRuntimeOptions`. + +**Mechanism:** +- `WindowCache` constructs a `RuntimeCacheOptionsHolder` from `WindowCacheOptions` at creation time. +- The holder is shared (by reference) with all components that need configuration: `ProportionalRangePlanner`, `NoRebalanceRangePlanner`, `TaskBasedRebalanceExecutionController`, and `ChannelBasedRebalanceExecutionController`. +- `UpdateRuntimeOptions` applies the builder's deltas to the current `RuntimeCacheOptions` snapshot, validates the result, then publishes the new snapshot via `Volatile.Write`. +- All readers call `holder.Current` at the start of their operation — they always see the latest published snapshot. +- `CurrentRuntimeOptions` returns `holder.Current.ToSnapshot()`, projecting the internal `RuntimeCacheOptions` to the public `RuntimeOptionsSnapshot` DTO. The snapshot is immutable; callers must re-read the property to observe later updates. + +**"Next cycle" semantics:** Changes take effect on the next rebalance decision/execution cycle. Ongoing cycles use the snapshot they already read. + +**Single-writer guarantee is not affected:** `RuntimeCacheOptionsHolder` is a separate shared reference from `CacheState`. Writing to it does not violate the single-writer rule (which covers cache content mutations only). + +**Non-updatable at runtime:** `ReadMode` (materialization strategy) and `RebalanceQueueCapacity` (execution controller selection) are determined at construction and cannot be changed. + ### Intent Model (Signals, Not Commands) After a user request completes and has "delivered data" (what the caller actually received), the User Path publishes an intent containing the delivered range/data. @@ -137,7 +156,7 @@ The canonical formal definition of the validation pipeline is in `docs/invariant Cache state converges to optimal configuration asynchronously through decision-driven rebalance execution: -1. **User Path** returns correct data immediately (from cache or `IDataSource`) +1. **User Path** returns correct data immediately (from cache or `IDataSource`) and classifies the request as `FullHit`, `PartialHit`, or `FullMiss` — exposed on `RangeResult.CacheInteraction` 2. **User Path** publishes intent with delivered data (synchronously in user thread — lightweight signal only) 3. **Intent processing loop** (background) wakes on semaphore signal, reads latest intent via `Interlocked.Exchange` 4. **Rebalance Decision Engine** validates rebalance necessity through multi-stage analytical pipeline (background intent loop — CPU-only, side-effect free) @@ -197,7 +216,11 @@ Idle detection requires state-based semantics: when the system becomes idle, ALL **"Was idle" semantics — not "is idle":** `WaitForIdleAsync` completes when the system was idle at some point. It does not guarantee the system is still idle after completion. This is correct for eventual consistency models. Callers requiring stronger guarantees must re-check state after await. -**Opt-in strong consistency mode:** For scenarios that require the cache to be fully converged before proceeding, the `GetDataAndWaitForIdleAsync` extension method on `IWindowCache` composes `GetDataAsync` and `WaitForIdleAsync` into a single call. This provides a convenient strong consistency mode on top of the default eventual consistency model, at the cost of waiting for rebalance to complete. See `README.md` and `docs/components/public-api.md` for usage details. +**Opt-in consistency modes:** Two extension methods on `IWindowCache` layer consistency guarantees on top of the default eventual consistency model: +- `GetDataAndWaitOnMissAsync` — **hybrid mode**: waits for idle only when `CacheInteraction` is `PartialHit` or `FullMiss`; returns immediately on `FullHit`. Provides warm-cache performance on hot paths while ensuring convergence on cold or near-boundary requests. +- `GetDataAndWaitForIdleAsync` — **strong mode**: always waits for idle regardless of cache interaction type. Useful for cold start synchronization and integration tests. + +**Serialized access requirement:** Both extension methods provide their "cache has converged" guarantee only under serialized (one-at-a-time) access. Under parallel access the guarantee degrades: a caller may observe an already-completed (stale) idle `TaskCompletionSource` due to the gap between `Interlocked.Increment` (0→1) and `Volatile.Write` of the new TCS in `AsyncActivityCounter.IncrementActivity`. The methods remain safe (no deadlocks or data corruption) but may return before convergence is actually complete. See `README.md` and `docs/components/public-api.md` for usage details. --- diff --git a/docs/boundary-handling.md b/docs/boundary-handling.md index e5e8933..256ea45 100644 --- a/docs/boundary-handling.md +++ b/docs/boundary-handling.md @@ -44,24 +44,31 @@ ReadOnlyMemory data = result.Data; // The data for that range ## RangeResult Structure ```csharp -public sealed record RangeResult( - Range? Range, - ReadOnlyMemory Data -) where TRange : IComparable; +// RangeResult is a sealed record (reference type) with an internal constructor. +// Instances are created exclusively by UserRequestHandler. +public sealed record RangeResult + where TRange : IComparable +{ + public Range? Range { get; } + public ReadOnlyMemory Data { get; } + public CacheInteraction CacheInteraction { get; } +} ``` ### Properties -| Property | Type | Description | -|----------|-------------------------|--------------------------------------------------------------------------------------------------| -| `Range` | `Range?` | **Nullable**. The actual range covered by the returned data. `null` indicates no data available. | -| `Data` | `ReadOnlyMemory` | The materialized data elements. May be empty if `Range` is `null`. | +| Property | Type | Description | +|--------------------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------| +| `Range` | `Range?` | **Nullable**. The actual range covered by the returned data. `null` indicates no data available. | +| `Data` | `ReadOnlyMemory` | The materialized data elements. May be empty if `Range` is `null`. | +| `CacheInteraction` | `CacheInteraction` | How the request was served: `FullHit` (from cache), `PartialHit` (cache + fetch), or `FullMiss` (cold start or jump fetch). | ### Invariants 1. **Range-Data Consistency**: When `Range` is non-null, `Data.Length` MUST equal `Range.Span(domain)` 2. **Empty Data Semantics**: `Data.IsEmpty` when `Range` is `null` (no data available) 3. **Contiguity**: `Data` contains sequential elements matching the boundaries of `Range` +4. **CacheInteraction Accuracy**: `CacheInteraction` accurately reflects the cache scenario — `FullMiss` on cold start or jump, `FullHit` when fully cached, `PartialHit` on partial overlap (Invariant A.10b) --- @@ -385,9 +392,9 @@ dotnet test --filter "FullyQualifiedName~RangeResult_WithFullData_ReturnsRangeAn ### Thread Safety -**RangeResult is immutable** (`readonly record struct`), making it inherently thread-safe: -- No mutable state -- Value semantics (struct) +**RangeResult is immutable** (`sealed record` — a reference type), making it inherently thread-safe: +- No mutable state; all properties are `init`-only +- Reference semantics (class, not struct); safe to share across threads - `ReadOnlyMemory` is safe to share across threads - Multiple threads can hold references to the same `RangeResult` safely @@ -407,7 +414,7 @@ dotnet test --filter "FullyQualifiedName~RangeResult_WithFullData_ReturnsRangeAn ✅ **Nullable Range signals data unavailability** without exceptions ✅ **Data sources truncate gracefully** at physical boundaries ✅ **Comprehensive test coverage** validates all boundary scenarios -✅ **Thread-safe immutable design** with value semantics +✅ **Thread-safe immutable design** (sealed record, reference type) --- diff --git a/docs/components/overview.md b/docs/components/overview.md index 28af71f..f7bf5c3 100644 --- a/docs/components/overview.md +++ b/docs/components/overview.md @@ -17,11 +17,15 @@ The system is easier to reason about when components are grouped by: ### Top-Level Component Roles - Public facade: `WindowCache` -- Public extensions: `WindowCacheExtensions` — opt-in strong consistency mode (`GetDataAndWaitForIdleAsync`) +- Public extensions: `WindowCacheConsistencyExtensions` — opt-in hybrid and strong consistency modes (`GetDataAndWaitOnMissAsync`, `GetDataAndWaitForIdleAsync`) +- Runtime configuration: `RuntimeOptionsUpdateBuilder` — fluent builder for `UpdateRuntimeOptions`; only fields explicitly set are changed +- Runtime options snapshot: `RuntimeOptionsSnapshot` — public read-only DTO returned by `IWindowCache.CurrentRuntimeOptions` +- Shared validation: `RuntimeOptionsValidator` — internal static helper; centralizes cache-size and threshold validation for both `WindowCacheOptions` and `RuntimeCacheOptions` - Multi-layer support: `WindowCacheDataSourceAdapter`, `LayeredWindowCacheBuilder`, `LayeredWindowCache` - User Path: assembles requested data and publishes intent - Intent loop: observes latest intent and runs analytical validation - Execution: performs debounced, cancellable rebalance work and mutates cache state +- Execution controller base: `RebalanceExecutionControllerBase` — abstract base class for both `TaskBasedRebalanceExecutionController` and `ChannelBasedRebalanceExecutionController`; holds shared dependencies, implements `LastExecutionRequest`, `ExecuteRequestCoreAsync`, and `DisposeAsync` ### Component Index @@ -47,8 +51,8 @@ The system is easier to reason about when components are grouped by: ├── 🟦 CacheState ⚠️ Shared Mutable ├── 🟦 IntentController │ └── uses → 🟧 IRebalanceExecutionController - │ ├── implements → 🟦 TaskBasedRebalanceExecutionController (default) - │ └── implements → 🟦 ChannelBasedRebalanceExecutionController (optional) + │ ├── implements → 🟦 TaskBasedRebalanceExecutionController (default, extends RebalanceExecutionControllerBase) + │ └── implements → 🟦 ChannelBasedRebalanceExecutionController (optional, extends RebalanceExecutionControllerBase) ├── 🟦 RebalanceDecisionEngine │ ├── owns → 🟩 NoRebalanceSatisfactionPolicy │ └── owns → 🟩 ProportionalRangePlanner @@ -56,6 +60,25 @@ The system is easier to reason about when components are grouped by: └── 🟦 CacheDataExtensionService └── uses → 🟧 IDataSource (user-provided) +──────────────────────────── Execution Controllers ──────────────────────────── + +🟦 RebalanceExecutionControllerBase [Abstract base] +│ Holds: Executor, OptionsHolder, CacheDiagnostics, ActivityCounter +│ Implements: LastExecutionRequest, StoreLastExecutionRequest() +│ ExecuteRequestCoreAsync() (shared debounce + execute pipeline) +│ DisposeAsync() (idempotent guard + cancel + DisposeAsyncCore) +│ Abstract: PublishExecutionRequest(...), DisposeAsyncCore() +│ +├── implements → 🟦 TaskBasedRebalanceExecutionController (default) +│ Adds: lock-free task chain (_lastTask) +│ Overrides: PublishExecutionRequest → chains new task +│ DisposeAsyncCore → awaits task chain +│ +└── implements → 🟦 ChannelBasedRebalanceExecutionController (optional) + Adds: BoundedChannel, background loop task + Overrides: PublishExecutionRequest → writes to channel + DisposeAsyncCore → completes channel + awaits loop + ──────────────────────────── Multi-Layer Support ──────────────────────────── 🟦 LayeredWindowCacheBuilder [Fluent Builder] @@ -75,9 +98,12 @@ The system is easier to reason about when components are grouped by: 🟦 LayeredWindowCache [IWindowCache wrapper] │ LayerCount: int -│ GetDataAsync() → delegates to outermost WindowCache -│ WaitForIdleAsync() → awaits all layers sequentially, outermost to innermost -│ DisposeAsync() → disposes all layers outermost-first +│ Layers: IReadOnlyList> +│ GetDataAsync() → delegates to outermost WindowCache +│ WaitForIdleAsync() → awaits all layers sequentially, outermost to innermost +│ UpdateRuntimeOptions() → delegates to outermost WindowCache +│ CurrentRuntimeOptions → delegates to outermost WindowCache +│ DisposeAsync() → disposes all layers outermost-first 🟦 WindowCacheDataSourceAdapter [IDataSource adapter] │ Wraps IWindowCache as IDataSource @@ -90,7 +116,8 @@ The system is easier to reason about when components are grouped by: - 🟩 STRUCT = Value type (stack-allocated or inline) - 🟧 INTERFACE = Contract definition - 🟪 ENUM = Value type enumeration -- 🟨 RECORD = Reference type with value semantics + +> **Note:** `ProportionalRangePlanner` and `NoRebalanceRangePlanner` were previously `readonly struct` types. They are now `internal sealed class` types so they can hold a reference to the shared `RuntimeCacheOptionsHolder` and read configuration at invocation time. ## Ownership & Data Flow Diagram @@ -107,6 +134,7 @@ The system is easier to reason about when components are grouped by: │ │ │ Constructor wires: │ │ • CacheState (shared mutable) │ +│ • RuntimeCacheOptionsHolder (shared, volatile — runtime option updates) │ │ • UserRequestHandler │ │ • CacheDataExtensionService │ │ • IntentController │ @@ -116,7 +144,9 @@ The system is easier to reason about when components are grouped by: │ └─ ProportionalRangePlanner │ │ • RebalanceExecutor │ │ │ -│ GetDataAsync() → delegates to UserRequestHandler │ +│ GetDataAsync() → delegates to UserRequestHandler │ +│ UpdateRuntimeOptions() → updates OptionsHolder atomically │ +│ CurrentRuntimeOptions → returns OptionsHolder.Current.ToSnapshot() │ └────────────────────────────────────────────────────────────────────────────┘ @@ -213,6 +243,13 @@ The system is easier to reason about when components are grouped by: │ ICacheStorage implementations: │ │ • SnapshotReadStorage (array — zero-alloc reads) │ │ • CopyOnReadStorage (List — cheap writes) │ +│ │ +│ RuntimeCacheOptionsHolder [SHARED RUNTIME CONFIGURATION] │ +│ │ +│ Written by: WindowCache.UpdateRuntimeOptions (Volatile.Write) │ +│ Read by: ProportionalRangePlanner, NoRebalanceRangePlanner, │ +│ TaskBasedRebalanceExecutionController, │ +│ ChannelBasedRebalanceExecutionController │ └────────────────────────────────────────────────────────────────────────────┘ ``` @@ -327,7 +364,7 @@ Previous execution cancelled before starting new one. Single `IRebalanceExecutio ### Pure Decision Logic **Invariants**: D.25, D.26 -`RebalanceDecisionEngine` has no mutable fields. Decision policies are structs with no side effects. No I/O in decision path. Pure function: `(state, intent, config) → decision`. +`RebalanceDecisionEngine` has no mutable fields. Decision policies are classes with no side effects. No I/O in decision path. Pure function: `(state, intent, config) → decision`. - `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` — pure evaluation logic - `src/SlidingWindowCache/Core/Planning/NoRebalanceSatisfactionPolicy.cs` — stateless struct @@ -351,14 +388,14 @@ Five-stage pipeline with early exits. Stage 1: current `NoRebalanceRange` contai ### Desired Range Computation **Invariants**: E.30, E.31 -`ProportionalRangePlanner.Plan(requestedRange, config)` is a pure function — same inputs always produce same output. Never reads `CurrentCacheRange`. +`ProportionalRangePlanner.Plan(requestedRange, config)` is a pure function — same inputs always produce same output. Never reads `CurrentCacheRange`. Reads configuration from a shared `RuntimeCacheOptionsHolder` at invocation time to support runtime option updates. - `src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs` — pure range calculation ### NoRebalanceRange Computation **Invariants**: E.34, E.35 -`NoRebalanceRangePlanner.Plan(currentCacheRange)` — pure function of current range + config. Applies threshold percentages as negative expansion. Returns `null` when individual thresholds ≥ 1.0 (no stability zone possible). `WindowCacheOptions` constructor ensures threshold sum ≤ 1.0 at construction time. +`NoRebalanceRangePlanner.Plan(currentCacheRange)` — pure function of current range + config. Applies threshold percentages as negative expansion. Returns `null` when individual thresholds ≥ 1.0 (no stability zone possible). `WindowCacheOptions` constructor ensures threshold sum ≤ 1.0 at construction time. Reads configuration from a shared `RuntimeCacheOptionsHolder` at invocation time to support runtime option updates. - `src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs` — NoRebalanceRange computation - `src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs` — threshold sum validation diff --git a/docs/components/public-api.md b/docs/components/public-api.md index daa11e4..940b39c 100644 --- a/docs/components/public-api.md +++ b/docs/components/public-api.md @@ -78,7 +78,33 @@ Configuration parameters: **File**: `src/SlidingWindowCache/Public/DTO/RangeResult.cs` -Returned by `GetDataAsync`. `Range` may be null for physical boundary misses (when `IDataSource` returns null for the requested range). +Returned by `GetDataAsync`. Contains three properties: + +| Property | Type | Description | +|--------------------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------| +| `Range` | `Range?` | **Nullable**. The actual range returned. `null` indicates no data available (physical boundary miss). | +| `Data` | `ReadOnlyMemory` | The materialized data. Empty when `Range` is `null`. | +| `CacheInteraction` | `CacheInteraction` | How the request was served: `FullHit` (from cache), `PartialHit` (cache + fetch), or `FullMiss` (cold start or jump fetch). | + +`RangeResult` constructor is `internal`; instances are created exclusively by `UserRequestHandler`. + +### CacheInteraction + +**File**: `src/SlidingWindowCache/Public/Dto/CacheInteraction.cs` + +**Type**: `enum` + +Classifies how a `GetDataAsync` request was served relative to the current cache state. + +| Value | Meaning | +|--------------|-------------------------------------------------------------------------------------------------| +| `FullMiss` | Cache was uninitialized (cold start) or `RequestedRange` did not intersect `CurrentCacheRange`. | +| `FullHit` | `RequestedRange` was fully contained within `CurrentCacheRange`. | +| `PartialHit` | `RequestedRange` partially overlapped `CurrentCacheRange`; missing segments were fetched. | + +**Usage**: Inspect `result.CacheInteraction` to branch on cache efficiency per request. The `GetDataAndWaitOnMissAsync` extension method uses this value to decide whether to call `WaitForIdleAsync`. + +**Note**: `ICacheDiagnostics` provides the same three-way classification via `UserRequestFullCacheHit`, `UserRequestPartialCacheHit`, and `UserRequestFullCacheMiss` callbacks — those are aggregate counters; `CacheInteraction` is the per-request programmatic alternative. ### RangeChunk\ @@ -111,13 +137,37 @@ Optional observability interface with 18 event recording methods covering: ## Extensions -### WindowCacheExtensions +### WindowCacheConsistencyExtensions -**File**: `src/SlidingWindowCache/Public/WindowCacheExtensions.cs` +**File**: `src/SlidingWindowCache/Public/WindowCacheConsistencyExtensions.cs` **Type**: `static class` (extension methods on `IWindowCache`) -Provides opt-in strong consistency mode on top of the default eventual consistency model. +Provides opt-in hybrid and strong consistency modes on top of the default eventual consistency model. + +#### GetDataAndWaitOnMissAsync + +```csharp +ValueTask> GetDataAndWaitOnMissAsync( + this IWindowCache cache, + Range requestedRange, + CancellationToken cancellationToken = default) +``` + +Composes `GetDataAsync` + conditional `WaitForIdleAsync` into a single call. Waits for idle only when `result.CacheInteraction != CacheInteraction.FullHit` — i.e., on cold start, jump, or partial hit where a rebalance was triggered. Returns immediately (no idle wait) on a `FullHit`. + +**When to use:** +- Warm-cache guarantee on the first request to a new region (cold start or jump) +- Sequential access patterns where occasional rebalances should be awaited but hot hits should not +- Lower overhead than `GetDataAndWaitForIdleAsync` for workloads with frequent `FullHit` results + +**When NOT to use:** +- Parallel callers — the "warm cache after await" guarantee requires serialized (one-at-a-time) access (Invariant H.49) +- Hot paths — even though `FullHit` skips the wait, missed requests still incur the full rebalance cycle delay + +**Idle semantics**: Inherits "was idle at some point" semantics from `WaitForIdleAsync` (Invariant H.49). + +**Exception propagation**: If `GetDataAsync` throws, `WaitForIdleAsync` is never called. If `WaitForIdleAsync` throws `OperationCanceledException`, the already-obtained result is returned (graceful degradation to eventual consistency). Other exceptions from `WaitForIdleAsync` propagate normally. #### GetDataAndWaitForIdleAsync @@ -128,7 +178,7 @@ ValueTask> GetDataAndWaitForIdleAsync` as `GetDataAsync`, but does not complete until the cache has reached an idle state (no pending intent, no executing rebalance). +Composes `GetDataAsync` + `WaitForIdleAsync` into a single call. Always waits for idle regardless of `CacheInteraction`. Returns the same `RangeResult` as `GetDataAsync`, but does not complete until the cache has reached an idle state. **When to use:** - Asserting or inspecting cache geometry after a request (e.g., verifying a rebalance occurred) @@ -138,12 +188,13 @@ Composes `GetDataAsync` + `WaitForIdleAsync` into a single call. Returns the sam **When NOT to use:** - Hot paths — the idle wait adds latency equal to the full rebalance cycle (debounce delay + data fetch + cache update) - Rapid sequential requests — eliminates debounce and work-avoidance benefits +- Parallel callers — same serialized access requirement as `GetDataAndWaitOnMissAsync` -**Idle semantics**: Inherits "was idle at some point" semantics from `WaitForIdleAsync` (Invariant H.49). Sufficient for all strong consistency use cases. +**Idle semantics**: Inherits "was idle at some point" semantics from `WaitForIdleAsync` (Invariant H.49). Unlike `GetDataAndWaitOnMissAsync`, always waits even on `FullHit`. -**Exception propagation**: If `GetDataAsync` throws, `WaitForIdleAsync` is never called. If `WaitForIdleAsync` throws, the `GetDataAsync` result is discarded. +**Exception propagation**: If `GetDataAsync` throws, `WaitForIdleAsync` is never called. If `WaitForIdleAsync` throws `OperationCanceledException`, the already-obtained result is returned (graceful degradation to eventual consistency). Other exceptions from `WaitForIdleAsync` propagate normally. -**See**: `README.md` (Strong Consistency Mode section) and `docs/architecture.md` for broader context. +**See**: `README.md` (Consistency Modes section) and `docs/architecture.md` for broader context. ## Multi-Layer Cache @@ -176,22 +227,21 @@ Typically created via `LayeredWindowCacheBuilder.Build()` rather than directly. ### LayeredWindowCacheBuilder\ -**File**: `src/SlidingWindowCache/Public/LayeredWindowCacheBuilder.cs` +**File**: `src/SlidingWindowCache/Public/Cache/LayeredWindowCacheBuilder.cs` **Type**: `sealed class` — fluent builder ```csharp -await using var cache = LayeredWindowCacheBuilder - .Create(realDataSource, domain) +await using var cache = WindowCacheBuilder.Layered(realDataSource, domain) .AddLayer(deepOptions) // L2: inner layer (CopyOnRead, large buffers) .AddLayer(userOptions) // L1: outer layer (Snapshot, small buffers) .Build(); ``` -- `Create(dataSource, domain)` — factory entry point; validates both `dataSource` and `domain` are not null. -- `AddLayer(options, diagnostics?)` — adds a layer on top; first call = innermost layer, last call = outermost (user-facing). -- `Build()` — constructs all `WindowCache` instances, wires them via `WindowCacheDataSourceAdapter`, and wraps them in `LayeredWindowCache`. -- Throws `InvalidOperationException` from `Build()` if no layers were added. +- Obtain an instance via `WindowCacheBuilder.Layered(dataSource, domain)` — enables full generic type inference. +- `AddLayer(options, diagnostics?)` — adds a layer on top; first call = innermost layer, last call = outermost (user-facing). Also accepts `Action` for inline configuration. +- `Build()` — constructs all `WindowCache` instances, wires them via `WindowCacheDataSourceAdapter`, and wraps them in `LayeredWindowCache`. Returns `IWindowCache`; concrete type is `LayeredWindowCache<>`. +- Throws `InvalidOperationException` from `Build()` if no layers were added, or if an inline delegate fails validation. **See**: `README.md` (Multi-Layer Cache section) and `docs/storage-strategies.md` for recommended layer configuration patterns. diff --git a/docs/components/user-path.md b/docs/components/user-path.md index 4ab2fec..dc44db7 100644 --- a/docs/components/user-path.md +++ b/docs/components/user-path.md @@ -23,12 +23,14 @@ All user-path code executes on the **⚡ User Thread** (the caller's thread). No ## Operation Flow -1. **Cold-start check** — `!state.IsInitialized`: fetch full range from `IDataSource` and serve directly. -2. **Full cache hit** — `RequestedRange ⊆ Cache.Range`: read directly from storage (zero allocation for Snapshot mode). -3. **Partial cache hit** — intersection exists: serve cached portion + fetch missing segments via `CacheDataExtensionService`. -4. **Full cache miss** — no intersection: fetch full range from `IDataSource` directly. +1. **Cold-start check** — `!state.IsInitialized`: fetch full range from `IDataSource` and serve directly; `CacheInteraction = FullMiss`. +2. **Full cache hit** — `RequestedRange ⊆ Cache.Range`: read directly from storage (zero allocation for Snapshot mode); `CacheInteraction = FullHit`. +3. **Partial cache hit** — intersection exists: serve cached portion + fetch missing segments via `CacheDataExtensionService`; `CacheInteraction = PartialHit`. +4. **Full cache miss** — no intersection: fetch full range from `IDataSource` directly; `CacheInteraction = FullMiss`. 5. **Publish intent** — fire-and-forget; passes `deliveredData` to `IntentController.PublishIntent` and returns immediately. +`CacheInteraction` is classified during scenario detection (steps 1–4) and set on the `RangeResult` returned to the caller (Invariant A.10b). + ## Responsibilities - Assemble `RequestedRange` from cache and/or `IDataSource`. @@ -51,6 +53,8 @@ All user-path code executes on the **⚡ User Thread** (the caller's thread). No | A.4 | Intent publication is fire-and-forget (background only) | | A.5 | User path is strictly read-only w.r.t. `CacheState` | | A.10 | Returns exactly `RequestedRange` data | +| A.10a | `RangeResult` contains `Range`, `Data`, and `CacheInteraction` — all set by `UserRequestHandler` | +| A.10b | `CacheInteraction` accurately reflects the cache scenario: `FullMiss` (cold start / jump), `FullHit` (fully cached), `PartialHit` (partial overlap) | | G.45 | I/O isolation: `IDataSource` called on user's behalf from User Thread (partial hits) or Background Thread (rebalance execution); shared `CacheDataExtensionService` used by both paths | See `docs/invariants.md` (Section A: User Path invariants) for full specification. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index d145b66..774c3b4 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -252,6 +252,8 @@ Assert.Equal(1, diagnostics.CacheReplaced); **Scenarios:** User Scenarios U2, U3 (full cache hit) **Interpretation:** Optimal performance - requested range fully contained in cache +**Per-request programmatic alternative:** `result.CacheInteraction == CacheInteraction.FullHit` on the returned `RangeResult`. `ICacheDiagnostics` callbacks are aggregate counters; `CacheInteraction` is the per-call value for branching logic (e.g., `GetDataAndWaitOnMissAsync` uses it to skip `WaitForIdleAsync` on full hits). + **Example Usage:** ```csharp // Request 1: [100, 200] - cache miss, cache becomes [100, 200] @@ -271,6 +273,8 @@ Assert.Equal(1, diagnostics.UserRequestFullCacheHit); **Scenarios:** User Scenario U4 (partial cache hit) **Interpretation:** Efficient cache extension - some data reused, missing parts fetched +**Per-request programmatic alternative:** `result.CacheInteraction == CacheInteraction.PartialHit` on the returned `RangeResult`. + **Example Usage:** ```csharp // Request 1: [100, 200] @@ -290,6 +294,8 @@ Assert.Equal(1, diagnostics.UserRequestPartialCacheHit); **Scenarios:** U1 (cold start), U5 (non-intersecting jump) **Interpretation:** Most expensive path - no cache reuse +**Per-request programmatic alternative:** `result.CacheInteraction == CacheInteraction.FullMiss` on the returned `RangeResult`. + **Example Usage:** ```csharp // Cold start - no cache @@ -776,8 +782,7 @@ Pass a diagnostics instance as the second argument to `AddLayer`: var l2Diagnostics = new EventCounterCacheDiagnostics(); var l1Diagnostics = new EventCounterCacheDiagnostics(); -await using var cache = LayeredWindowCacheBuilder - .Create(realDataSource, domain) +await using var cache = WindowCacheBuilder.Layered(realDataSource, domain) .AddLayer(deepOptions, l2Diagnostics) // L2: inner / deep layer .AddLayer(userOptions, l1Diagnostics) // L1: outermost / user-facing layer .Build(); diff --git a/docs/glossary.md b/docs/glossary.md index 0e37e7a..f01dd7a 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -50,7 +50,7 @@ RangeChunk - See `docs/boundary-handling.md`. RangeResult -- The public API return from `GetDataAsync`: the delivered `Range?` and the materialized data. +- The public API return from `GetDataAsync`: the delivered `Range?`, the materialized data, and the `CacheInteraction` classification (`FullHit`, `PartialHit`, or `FullMiss`). - See `docs/boundary-handling.md`. ## Architectural Concepts @@ -107,11 +107,41 @@ AsyncActivityCounter WaitForIdleAsync (“Was Idle” Semantics) - Completes when the system was idle at some point, which is appropriate for tests and convergence checks. - It does not guarantee the system is still idle after the task completes. +- Under serialized (one-at-a-time) access this is sufficient for hybrid and strong consistency guarantees. Under parallel access the guarantee degrades: a caller may observe an already-completed (stale) idle TCS if another thread incremented the activity counter between the 0→1 transition and the new TCS publication. See Invariant H.49 and `docs/architecture.md`. + +CacheInteraction +- A per-request classification set on every `RangeResult` by `UserRequestHandler`, indicating how the cache contributed to serving the request. +- Values: `FullHit` (request fully served from cache), `PartialHit` (request partially served from cache; missing portion fetched from `IDataSource`), `FullMiss` (cache was uninitialized or had no overlap; full range fetched from `IDataSource`). +- Provides a programmatic per-request alternative to the aggregate `ICacheDiagnostics` callbacks (`UserRequestFullCacheHit`, `UserRequestPartialCacheHit`, `UserRequestFullCacheMiss`). +- See `docs/invariants.md` (A.10a, A.10b) and `docs/boundary-handling.md`. + +Hybrid Consistency Mode +- An opt-in mode provided by the `GetDataAndWaitOnMissAsync` extension method on `IWindowCache`. +- Composes `GetDataAsync` with conditional `WaitForIdleAsync`: waits only when `CacheInteraction` is `PartialHit` or `FullMiss`; returns immediately on `FullHit`. +- Provides warm-cache-speed hot paths with convergence guarantees on cold or near-boundary requests. +- The convergence guarantee holds only under serialized (one-at-a-time) access; under parallel access the "was idle" semantics may return a stale completed TCS. +- If cancellation is requested during the idle wait, the already-obtained result is returned gracefully (degrades to eventual consistency for that call); the background rebalance is not affected. +- See `README.md` and `docs/components/public-api.md`. + +Serialized Access +- An access pattern in which calls to a cache are issued one at a time (each call completes before the next begins). +- Required for the `GetDataAndWaitOnMissAsync` and `GetDataAndWaitForIdleAsync` extension methods to provide their “cache has converged” guarantee. +- Under parallel access the extension methods remain safe (no deadlocks or data corruption) but the idle-wait may return early due to `AsyncActivityCounter`’s “was idle at some point” semantics (see Invariant H.49). + +GetDataAndWaitOnMissAsync +- Extension method on `IWindowCache` providing hybrid consistency mode. +- Calls `GetDataAsync`, then conditionally calls `WaitForIdleAsync` only when the result's `CacheInteraction` is not `FullHit`. +- On `FullHit`, returns immediately (no idle wait). On `PartialHit` or `FullMiss`, waits for the cache to converge. +- If `WaitForIdleAsync` throws `OperationCanceledException`, the already-obtained result is returned gracefully (degrades to eventual consistency); the background rebalance continues. +- See `Hybrid Consistency Mode` above and `docs/components/public-api.md`. Strong Consistency Mode - An opt-in mode provided by the `GetDataAndWaitForIdleAsync` extension method on `IWindowCache`. - Composes `GetDataAsync` (returns data immediately) with `WaitForIdleAsync` (waits for convergence), returning the same `RangeResult` as `GetDataAsync` but only after the cache has reached an idle state. +- Unlike hybrid mode, always waits regardless of `CacheInteraction` value. - Useful for cold start synchronization, integration testing, and any scenario requiring a guarantee that the cache window has converged before proceeding. +- The convergence guarantee holds only under serialized (one-at-a-time) access; see `Serialized Access` above. +- If `WaitForIdleAsync` throws `OperationCanceledException`, the already-obtained result is returned gracefully (degrades to eventual consistency for that call); the background rebalance continues. - Not recommended for hot paths: adds latency equal to the rebalance execution time (debounce delay + I/O). - See `README.md` and `docs/components/public-api.md`. @@ -133,10 +163,10 @@ WindowCacheDataSourceAdapter - Adapts an `IWindowCache` to the `IDataSource` interface, enabling it to act as the backing store for an outer `WindowCache`. This is the composition point for building layered caches. The adapter does not own the inner cache; ownership is managed by `LayeredWindowCache`. See `src/SlidingWindowCache/Public/WindowCacheDataSourceAdapter.cs`. LayeredWindowCacheBuilder -- Fluent builder that wires `WindowCache` layers into a `LayeredWindowCache`. Layers are added bottom-up (deepest/innermost first, user-facing last). Each `AddLayer` call adds one `WindowCache` on top of the current stack. `Build()` returns a `LayeredWindowCache` that owns all layers. See `src/SlidingWindowCache/Public/LayeredWindowCacheBuilder.cs`. +- Fluent builder that wires `WindowCache` layers into a `LayeredWindowCache`. Obtain an instance via `WindowCacheBuilder.Layered(dataSource, domain)`. Layers are added bottom-up (deepest/innermost first, user-facing last). Each `AddLayer` call accepts either a pre-built `WindowCacheOptions` or an `Action` for inline configuration. `Build()` returns `IWindowCache<>` (concrete type: `LayeredWindowCache<>`). See `src/SlidingWindowCache/Public/Cache/LayeredWindowCacheBuilder.cs`. LayeredWindowCache -- A thin `IWindowCache` wrapper that owns a stack of `WindowCache` layers. Delegates `GetDataAsync` to the outermost layer. `WaitForIdleAsync` awaits all layers sequentially, outermost to innermost, ensuring full-stack convergence (required for correct behavior of `GetDataAndWaitForIdleAsync`). Disposes all layers outermost-first on `DisposeAsync`. Exposes `LayerCount`. See `src/SlidingWindowCache/Public/LayeredWindowCache.cs`. +- A thin `IWindowCache` wrapper that owns a stack of `WindowCache` layers. Delegates `GetDataAsync` to the outermost layer. `WaitForIdleAsync` awaits all layers sequentially, outermost to innermost, ensuring full-stack convergence (required for correct behavior of `GetDataAndWaitForIdleAsync`). Disposes all layers outermost-first on `DisposeAsync`. Exposes `LayerCount` and `Layers`. See `src/SlidingWindowCache/Public/LayeredWindowCache.cs`. ## Storage And Materialization @@ -162,6 +192,44 @@ ICacheDiagnostics NoOpDiagnostics - The default diagnostics implementation that does nothing (intended to be effectively zero overhead). +UpdateRuntimeOptions +- A method on `IWindowCache` (and its implementations) that updates cache sizing, threshold, and debounce options on a live cache instance without reconstruction. +- Takes an `Action` callback; only fields set via builder calls are changed (all others remain at current values). +- Updates use **next-cycle semantics**: changed values take effect on the next rebalance decision/execution cycle. +- Throws `ObjectDisposedException` if called after disposal. +- Throws `ArgumentOutOfRangeException` / `ArgumentException` if the resulting options would be invalid; invalid updates leave the current options unchanged. +- `ReadMode` and `RebalanceQueueCapacity` are creation-time only and cannot be changed at runtime. + +RuntimeOptionsUpdateBuilder +- Public fluent builder passed to the `UpdateRuntimeOptions` callback. +- Exposes `WithLeftCacheSize`, `WithRightCacheSize`, `WithLeftThreshold`, `ClearLeftThreshold`, `WithRightThreshold`, `ClearRightThreshold`, and `WithDebounceDelay`. +- `ClearLeftThreshold` / `ClearRightThreshold` explicitly set the threshold to `null`, distinguishing "don't change" from "set to null". +- Constructed internally; constructor is `internal`. + +RuntimeOptionsValidator +- Internal static helper class that contains the shared validation logic for cache sizes and thresholds. +- Used by both `WindowCacheOptions` and `RuntimeCacheOptions` to avoid duplicated validation rules. +- Validates: cache sizes ≥ 0, individual thresholds in [0, 1], threshold sum ≤ 1.0 when both thresholds are provided. +- See `src/SlidingWindowCache/Core/State/RuntimeOptionsValidator.cs`. + +RuntimeCacheOptions +- Internal immutable snapshot of the runtime-updatable subset of cache configuration: `LeftCacheSize`, `RightCacheSize`, `LeftThreshold`, `RightThreshold`, `DebounceDelay`. +- Created from `WindowCacheOptions` at construction time and republished on each `UpdateRuntimeOptions` call. +- All validation rules match `WindowCacheOptions` (negative sizes rejected, threshold sum ≤ 1.0 when both specified). +- Exposes `ToSnapshot()` which projects the internal values to a public `RuntimeOptionsSnapshot`. + +RuntimeOptionsSnapshot +- Public read-only DTO that captures the current values of the five runtime-updatable options. +- Obtained via `IWindowCache.CurrentRuntimeOptions`. +- Immutable — a snapshot of values at the moment the property was read. Subsequent `UpdateRuntimeOptions` calls do not affect previously obtained snapshots. +- Constructor is `internal`; created only via `RuntimeCacheOptions.ToSnapshot()`. +- See `src/SlidingWindowCache/Public/Configuration/RuntimeOptionsSnapshot.cs`. + +RuntimeCacheOptionsHolder +- Internal volatile wrapper that holds the current `RuntimeCacheOptions` snapshot. +- Readers (planners, execution controllers) call `holder.Current` at invocation time — always see the latest published snapshot. +- `Update(RuntimeCacheOptions)` publishes atomically via `Volatile.Write`. + ## Common Misconceptions **Intent vs Command**: Intents are signals — evaluation may skip execution entirely. They are not commands that guarantee rebalance will happen. diff --git a/docs/invariants.md b/docs/invariants.md index 4435217..80f7028 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -4,7 +4,7 @@ ## Understanding This Document -This document lists **49 system invariants** that define the behavior, architecture, and design intent of the Sliding Window Cache. +This document lists **54 system invariants** that define the behavior, architecture, and design intent of the Sliding Window Cache. ### Invariant Categories @@ -239,12 +239,13 @@ without polling or timing dependencies. - *Observable via*: Returned data length and content - *Test verifies*: Data matches requested range exactly (no more, no less) -**A.10a** 🔵 **[Architectural]** `GetDataAsync` returns `RangeResult` containing both the actual range fulfilled and the corresponding data. +**A.10a** 🔵 **[Architectural]** `GetDataAsync` returns `RangeResult` containing the actual range fulfilled, the corresponding data, and the cache interaction classification. **Formal Specification:** - Return type: `ValueTask>` - `RangeResult.Range` indicates the actual range returned (may differ from requested in bounded data sources) - `RangeResult.Data` contains `ReadOnlyMemory` for the returned range +- `RangeResult.CacheInteraction` classifies how the request was served (`FullHit`, `PartialHit`, or `FullMiss`) - `Range` is nullable to signal data unavailability without exceptions - When `Range` is non-null, `Data.Length` MUST equal `Range.Span(domain)` @@ -252,9 +253,21 @@ without polling or timing dependencies. - Explicit boundary contracts between cache and consumers - Bounded data sources can signal truncation or unavailability gracefully - No exceptions for normal boundary conditions (out-of-bounds is expected, not exceptional) +- `CacheInteraction` exposes per-request cache efficiency classification for programmatic use **Related Documentation:** [Boundary Handling Guide](boundary-handling.md) — comprehensive coverage of RangeResult usage patterns, bounded data source implementation, partial fulfillment handling, and testing. +**A.10b** 🔵 **[Architectural]** `RangeResult.CacheInteraction` **accurately reflects** the cache interaction type for every request. + +**Formal Specification:** +- `CacheInteraction.FullMiss` — `IsInitialized == false` (cold start) OR `CurrentCacheRange` does not intersect `RequestedRange` (jump) +- `CacheInteraction.FullHit` — `CurrentCacheRange` fully contains `RequestedRange` +- `CacheInteraction.PartialHit` — `CurrentCacheRange` intersects but does not fully contain `RequestedRange` + +**Rationale:** Enables callers to branch on cache efficiency per request — for example, `GetDataAndWaitOnMissAsync` (hybrid consistency mode) uses `CacheInteraction` to decide whether to call `WaitForIdleAsync`. + +**Implementation:** Set exclusively by `UserRequestHandler.HandleRequestAsync` at scenario classification time. `RangeResult` constructor is `internal`; only `UserRequestHandler` may construct instances. + ### A.3 Cache Mutation Rules (User Path) **A.7** 🔵 **[Architectural]** The User Path may read from cache and `IDataSource` but **does not mutate cache state**. @@ -824,6 +837,14 @@ Activity counter accurately reflects active work count at all times: - *Implication*: Callers requiring stronger guarantees (e.g., "still idle after await") must implement retry logic or re-check state - *Testing usage*: Sufficient for convergence testing — system stabilized at snapshot time +**Parallel Access Implication for Hybrid/Strong Consistency Extension Methods:** +`GetDataAndWaitOnMissAsync` and `GetDataAndWaitForIdleAsync` provide their warm-cache guarantee only under **serialized (one-at-a-time) access**. Under parallel access, the guarantee degrades: +- Thread A increments the activity counter 0→1 (has not yet published its new TCS) +- Thread B increments 1→2, then calls `WaitForIdleAsync`, reads the old (already-completed) TCS, and returns immediately — without waiting for Thread A's rebalance +- Result: Thread B observes "was idle" from the *previous* idle period, not the one Thread A is driving + +Under parallel access, the methods remain safe (no deadlocks, no crashes, no data corruption) but the "warm cache after await" guarantee is not reliable. These methods are designed for single-logical-consumer, one-at-a-time access patterns. + ### Activity-Based Stabilization Barrier The combination of H.47 and H.48 creates a **stabilization barrier** with strong guarantees: @@ -909,22 +930,53 @@ Complete trace demonstrating both invariants in current architecture: --- +## I. Runtime Options Update Invariants + +**I.50** 🟢 **[Behavioral — Tests: `RuntimeOptionsUpdateTests`]** `UpdateRuntimeOptions` **validates the merged options** before publishing. Invalid updates (negative sizes, threshold sum > 1.0, out-of-range threshold) throw and leave the current options unchanged. +- *Observable via*: Exception type and cache still accepts subsequent valid updates +- *Test verifies*: `ArgumentOutOfRangeException` / `ArgumentException` thrown; cache not partially updated + +**I.51** 🔵 **[Architectural]** `UpdateRuntimeOptions` uses **next-cycle semantics**: the new options snapshot takes effect on the next rebalance decision/execution cycle. Ongoing cycles use the snapshot already read at cycle start. + +**Formal Specification:** +- `RuntimeCacheOptionsHolder.Update` performs a `Volatile.Write` (release fence) +- Planners and execution controllers snapshot `holder.Current` once at the start of their operation +- No running cycle is interrupted or modified mid-flight by an options update + +**Rationale:** Prevents mid-cycle inconsistencies (e.g., a planner using new `LeftCacheSize` with old `RightCacheSize`). Cycles are short; the next cycle reflects the update. + +**Implementation:** `RuntimeCacheOptionsHolder.Update` in `src/SlidingWindowCache/Core/State/RuntimeCacheOptionsHolder.cs`. + +**I.52** 🔵 **[Architectural]** `UpdateRuntimeOptions` on a disposed cache **always throws `ObjectDisposedException`**. + +**Formal Specification:** +- Disposal state checked via `Volatile.Read` before any options update work +- Consistent with all other post-disposal operation guards in the public API + +**Implementation:** Disposal guard in `WindowCache.UpdateRuntimeOptions`. + +**I.53** 🟡 **[Conceptual]** **`ReadMode` and `RebalanceQueueCapacity` are creation-time only** — they determine the storage strategy and execution controller strategy, which are wired at construction and cannot be replaced at runtime without reconstruction. +- *Design decision*: These choices affect fundamental system structure (object graph), not just configuration parameters +- *Rationale*: Storage strategies and execution controllers have different object identities and lifecycles; hot-swapping them would require disposal and re-creation of component graphs + +--- + ## Summary Statistics -### Total Invariants: 49 +### Total Invariants: 54 #### By Category: -- 🟢 **Behavioral** (test-covered): 19 invariants -- 🔵 **Architectural** (structure-enforced): 22 invariants -- 🟡 **Conceptual** (design-level): 8 invariants +- 🟢 **Behavioral** (test-covered): 20 invariants +- 🔵 **Architectural** (structure-enforced): 25 invariants +- 🟡 **Conceptual** (design-level): 9 invariants #### Test Coverage Analysis: - **29 automated tests** in `WindowCacheInvariantTests` -- **19 behavioral invariants** directly covered -- **22 architectural invariants** enforced by code structure (not tested) -- **8 conceptual invariants** documented as design guidance (not tested) +- **20 behavioral invariants** directly covered +- **25 architectural invariants** enforced by code structure (not tested) +- **9 conceptual invariants** documented as design guidance (not tested) -**This is by design.** The gap between 49 invariants and 29 tests is intentional: +**This is by design.** The gap between 54 invariants and 29 tests is intentional: - Architecture enforces structural constraints automatically - Conceptual invariants guide development, not runtime behavior - Tests focus on externally observable behavior diff --git a/docs/scenarios.md b/docs/scenarios.md index dd1b3e9..e2ee881 100644 --- a/docs/scenarios.md +++ b/docs/scenarios.md @@ -46,11 +46,13 @@ Scenarios are grouped by path: 2. Cache detects it is not initialized 3. Cache requests `RequestedRange` from `IDataSource` in the user thread (unavoidable — user request must be served immediately) 4. A rebalance intent is published (fire-and-forget) with the fetched data -5. Data is returned to the user immediately +5. Data is returned to the user immediately — `RangeResult.CacheInteraction == FullMiss` 6. Rebalance Execution (background) stores the data as `CacheData`, sets `CurrentCacheRange = RequestedRange`, sets `IsInitialized = true` **Note**: The User Path does not expand the cache beyond `RequestedRange`. Cache expansion to `DesiredCacheRange` is performed exclusively by Rebalance Execution. +**Consistency note**: `GetDataAndWaitOnMissAsync` will call `WaitForIdleAsync` after this scenario (because `CacheInteraction != FullHit`), waiting for the background rebalance to complete. + --- ### U2 — Full Cache Hit (Within NoRebalanceRange) @@ -65,7 +67,7 @@ Scenarios are grouped by path: 2. Cache detects a full cache hit 3. Data is read from `CacheData` 4. Rebalance intent is published; Decision Engine rejects execution at Stage 1 (NoRebalanceRange containment) -5. Data is returned to the user +5. Data is returned to the user — `RangeResult.CacheInteraction == FullHit` --- @@ -81,7 +83,7 @@ Scenarios are grouped by path: 2. Cache detects all requested data is available 3. Subrange is read from `CacheData` 4. Rebalance intent is published; Decision Engine proceeds through validation -5. Data is returned to the user +5. Data is returned to the user — `RangeResult.CacheInteraction == FullHit` 6. Rebalance executes asynchronously to shift the window --- @@ -102,10 +104,12 @@ Scenarios are grouped by path: - does **not** trim excess data - does **not** update `CurrentCacheRange` (User Path is read-only with respect to cache state) 5. Rebalance intent is published; rebalance executes asynchronously -6. `RequestedRange` data is returned to the user +6. `RequestedRange` data is returned to the user — `RangeResult.CacheInteraction == PartialHit` **Note**: Cache expansion is permitted because `RequestedRange` intersects `CurrentCacheRange`, preserving cache contiguity. Excess data may temporarily remain in `CacheData` for reuse during Rebalance. +**Consistency note**: `GetDataAndWaitOnMissAsync` will call `WaitForIdleAsync` after this scenario (because `CacheInteraction != FullHit`), waiting for the background rebalance to complete. + --- ### U5 — Full Cache Miss (Jump) @@ -123,10 +127,12 @@ Scenarios are grouped by path: - **fully replaces** `CacheData` with new data - **fully replaces** `CurrentCacheRange` with `RequestedRange` 6. Rebalance intent is published; rebalance executes asynchronously -7. Data is returned to the user +7. Data is returned to the user — `RangeResult.CacheInteraction == FullMiss` **Critical**: Partial cache expansion is FORBIDDEN in this case — it would create logical gaps and violate the Cache Contiguity Rule (Invariant A.9a). The cache MUST remain contiguous at all times. +**Consistency note**: `GetDataAndWaitOnMissAsync` will call `WaitForIdleAsync` after this scenario (because `CacheInteraction != FullHit`), waiting for the background rebalance to complete. + --- ## II. Decision Path Scenarios @@ -432,8 +438,7 @@ from Lₙ's new window, and finally L1 expands from L2. var l2Diagnostics = new EventCounterCacheDiagnostics(); var l1Diagnostics = new EventCounterCacheDiagnostics(); -await using var cache = LayeredWindowCacheBuilder - .Create(dataSource, domain) +await using var cache = WindowCacheBuilder.Layered(dataSource, domain) .AddLayer(deepOptions, l2Diagnostics) // L2 .AddLayer(userOptions, l1Diagnostics) // L1 .Build(); diff --git a/docs/storage-strategies.md b/docs/storage-strategies.md index 0521d7b..75db19c 100644 --- a/docs/storage-strategies.md +++ b/docs/storage-strategies.md @@ -205,8 +205,7 @@ The library provides built-in support for layered cache composition via `Layered ```csharp // Two-layer cache: L2 (CopyOnRead, large) → L1 (Snapshot, small) -await using var cache = LayeredWindowCacheBuilder - .Create(slowDataSource, domain) // real (bottom-most) data source +await using var cache = WindowCacheBuilder.Layered(slowDataSource, domain) .AddLayer(new WindowCacheOptions( // L2: deep background cache leftCacheSize: 10.0, rightCacheSize: 10.0, diff --git a/src/SlidingWindowCache.WasmValidation/WasmCompilationValidator.cs b/src/SlidingWindowCache.WasmValidation/WasmCompilationValidator.cs index 78a8090..a5c1eae 100644 --- a/src/SlidingWindowCache.WasmValidation/WasmCompilationValidator.cs +++ b/src/SlidingWindowCache.WasmValidation/WasmCompilationValidator.cs @@ -1,8 +1,10 @@ using Intervals.NET; using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Dto; +using SlidingWindowCache.Public.Extensions; namespace SlidingWindowCache.WasmValidation; @@ -59,6 +61,22 @@ CancellationToken cancellationToken /// This ensures all storage strategies (SnapshotReadStorage, CopyOnReadStorage) and /// serialization strategies (task-based, channel-based) are WebAssembly-compatible. /// +/// Opt-In Consistency Modes: +/// +/// The validator also covers the extension methods +/// for hybrid and strong consistency modes, including the cancellation graceful degradation +/// path (OperationCanceledException from WaitForIdleAsync caught, result returned): +/// +/// +/// +/// — +/// strong consistency (always waits for idle) +/// +/// +/// — +/// hybrid consistency (waits on miss/partial hit, returns immediately on full hit) +/// +/// /// public static class WasmCompilationValidator { @@ -222,6 +240,134 @@ public static async Task ValidateConfiguration4_CopyOnReadMode_BoundedQueue() _ = result.Data.Length; } + /// + /// Validates strong consistency mode: + /// compiles for net8.0-browser. Exercises both the normal path (idle wait completes) and the + /// cancellation graceful degradation path (OperationCanceledException from WaitForIdleAsync is + /// caught and the already-obtained result is returned). + /// + /// + /// Types Validated: + /// + /// + /// — + /// strong consistency extension method; composes GetDataAsync + unconditional WaitForIdleAsync + /// + /// + /// The try { await WaitForIdleAsync } catch (OperationCanceledException) { } pattern + /// inside the extension method — validates that exception handling compiles on WASM + /// + /// + /// Why One Configuration Is Sufficient: + /// + /// The extension method introduces no new strategy axes (storage or serialization). It is a + /// thin wrapper over GetDataAsync + WaitForIdleAsync; the four internal strategy combinations + /// are already covered by Configurations 1–4. + /// + /// + public static async Task ValidateStrongConsistencyMode_GetDataAndWaitForIdleAsync() + { + var dataSource = new SimpleDataSource(); + var domain = new IntegerFixedStepDomain(); + + var options = new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot, + leftThreshold: 0.2, + rightThreshold: 0.2 + ); + + var cache = new WindowCache( + dataSource, + domain, + options + ); + + var range = Intervals.NET.Factories.Range.Closed(0, 10); + + // Normal path: waits for idle and returns the result + var result = await cache.GetDataAndWaitForIdleAsync(range, CancellationToken.None); + _ = result.Data.Length; + _ = result.CacheInteraction; + + // Cancellation graceful degradation path: pre-cancelled token; WaitForIdleAsync + // throws OperationCanceledException which is caught — result returned gracefully + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var degradedResult = await cache.GetDataAndWaitForIdleAsync(range, cts.Token); + _ = degradedResult.Data.Length; + _ = degradedResult.CacheInteraction; + } + + /// + /// Validates hybrid consistency mode: + /// compiles for net8.0-browser. Exercises the FullHit path (no idle wait), the FullMiss path + /// (conditional idle wait), and the cancellation graceful degradation path. + /// + /// + /// Types Validated: + /// + /// + /// — + /// hybrid consistency extension method; composes GetDataAsync + conditional WaitForIdleAsync + /// gated on + /// + /// + /// enum — read from + /// on the returned result + /// + /// + /// The try { await WaitForIdleAsync } catch (OperationCanceledException) { } pattern + /// inside the extension method — validates that exception handling compiles on WASM + /// + /// + /// Why One Configuration Is Sufficient: + /// + /// The extension method introduces no new strategy axes. The four internal strategy + /// combinations are already covered by Configurations 1–4. + /// + /// + public static async Task ValidateHybridConsistencyMode_GetDataAndWaitOnMissAsync() + { + var dataSource = new SimpleDataSource(); + var domain = new IntegerFixedStepDomain(); + + var options = new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot, + leftThreshold: 0.2, + rightThreshold: 0.2 + ); + + var cache = new WindowCache( + dataSource, + domain, + options + ); + + var range = Intervals.NET.Factories.Range.Closed(0, 10); + + // FullMiss path (first request — cold cache): idle wait is triggered + var missResult = await cache.GetDataAndWaitOnMissAsync(range, CancellationToken.None); + _ = missResult.Data.Length; + _ = missResult.CacheInteraction; // FullMiss + + // FullHit path (warm cache): no idle wait, returns immediately + var hitResult = await cache.GetDataAndWaitOnMissAsync(range, CancellationToken.None); + _ = hitResult.Data.Length; + _ = hitResult.CacheInteraction; // FullHit + + // Cancellation graceful degradation path: pre-cancelled token on a miss scenario; + // WaitForIdleAsync throws OperationCanceledException which is caught — result returned gracefully + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var degradedResult = await cache.GetDataAndWaitOnMissAsync(range, cts.Token); + _ = degradedResult.Data.Length; + _ = degradedResult.CacheInteraction; + } + /// /// Validates layered cache: , /// , and @@ -278,19 +424,18 @@ public static async Task ValidateLayeredCache_TwoLayer_RecommendedConfig() // Build the layered cache — exercises LayeredWindowCacheBuilder, // WindowCacheDataSourceAdapter, and LayeredWindowCache - await using var cache = LayeredWindowCacheBuilder - .Create(new SimpleDataSource(), domain) + await using var layered = (LayeredWindowCache)WindowCacheBuilder.Layered(new SimpleDataSource(), domain) .AddLayer(innerOptions) .AddLayer(outerOptions) .Build(); var range = Intervals.NET.Factories.Range.Closed(0, 10); - var result = await cache.GetDataAsync(range, CancellationToken.None); + var result = await layered.GetDataAsync(range, CancellationToken.None); // WaitForIdleAsync on LayeredWindowCache awaits all layers (outermost to innermost) - await cache.WaitForIdleAsync(); + await layered.WaitForIdleAsync(); _ = result.Data.Length; - _ = cache.LayerCount; + _ = layered.LayerCount; } } \ No newline at end of file diff --git a/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs b/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs index ea1c85a..8f18e20 100644 --- a/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs +++ b/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs @@ -1,8 +1,8 @@ -using Intervals.NET; +using Intervals.NET; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Decision; +using SlidingWindowCache.Core.State; using SlidingWindowCache.Infrastructure.Extensions; -using SlidingWindowCache.Public.Configuration; namespace SlidingWindowCache.Core.Planning; @@ -14,11 +14,19 @@ namespace SlidingWindowCache.Core.Planning; /// The type representing the domain of the ranges. /// /// Role: Cache Geometry Planning - Threshold Zone Computation -/// Characteristics: Pure function, stateless, configuration-driven +/// Characteristics: Pure function at the call site, configuration-driven /// /// Works in tandem with to define /// complete cache geometry: desired cache range (expansion) and no-rebalance zone (shrinkage). -/// Invalid threshold configurations (sum exceeding 1.0) are prevented at construction time. +/// Invalid threshold configurations (sum exceeding 1.0) are prevented at construction time +/// of / . +/// +/// Runtime-Updatable Configuration: +/// +/// The planner holds a reference to a shared rather than a frozen +/// copy of options. This allows LeftThreshold and RightThreshold to be updated at runtime via +/// IWindowCache.UpdateRuntimeOptions without reconstructing the planner. Changes take effect on the +/// next rebalance decision cycle ("next cycle" semantics). /// /// Execution Context: Background thread (intent processing loop) /// @@ -26,21 +34,30 @@ namespace SlidingWindowCache.Core.Planning; /// which executes in the background intent processing loop (see IntentController.ProcessIntentsAsync). /// /// -internal readonly struct NoRebalanceRangePlanner +internal sealed class NoRebalanceRangePlanner where TRange : IComparable where TDomain : IRangeDomain { - private readonly WindowCacheOptions _options; + private readonly RuntimeCacheOptionsHolder _optionsHolder; private readonly TDomain _domain; - public NoRebalanceRangePlanner(WindowCacheOptions options, TDomain domain) + /// + /// Initializes a new instance of with the specified options holder and domain. + /// + /// + /// Shared holder for the current runtime options snapshot. The planner reads + /// once per invocation so that + /// changes published via IWindowCache.UpdateRuntimeOptions take effect on the next cycle. + /// + /// Domain implementation used for range arithmetic and span calculations. + public NoRebalanceRangePlanner(RuntimeCacheOptionsHolder optionsHolder, TDomain domain) { - _options = options; + _optionsHolder = optionsHolder; _domain = domain; } /// - /// Computes the no-rebalance range by shrinking the cache range using threshold ratios. + /// Computes the no-rebalance range by shrinking the cache range using the current threshold ratios. /// /// The current cache range to compute thresholds from. /// @@ -52,12 +69,16 @@ public NoRebalanceRangePlanner(WindowCacheOptions options, TDomain domain) /// - Right threshold shrinks from the right boundary inward /// This creates a "stability zone" where requests don't trigger rebalancing. /// Returns null when the sum of left and right thresholds is >= 1.0, which would completely eliminate the no-rebalance range. - /// Note: WindowCacheOptions constructor ensures leftThreshold + rightThreshold does not exceed 1.0. + /// Note: constructor ensures leftThreshold + rightThreshold does not exceed 1.0. + /// Snapshots once at entry for consistency within the invocation. /// public Range? Plan(Range cacheRange) { - var leftThreshold = _options.LeftThreshold ?? 0; - var rightThreshold = _options.RightThreshold ?? 0; + // Snapshot current options once for consistency within this invocation + var options = _optionsHolder.Current; + + var leftThreshold = options.LeftThreshold ?? 0; + var rightThreshold = options.RightThreshold ?? 0; var sum = leftThreshold + rightThreshold; if (sum >= 1) diff --git a/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs b/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs index 65a84a7..af9ff20 100644 --- a/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs +++ b/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs @@ -2,8 +2,8 @@ using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.Rebalance.Intent; +using SlidingWindowCache.Core.State; using SlidingWindowCache.Infrastructure.Extensions; -using SlidingWindowCache.Public.Configuration; namespace SlidingWindowCache.Core.Planning; @@ -16,15 +16,22 @@ namespace SlidingWindowCache.Core.Planning; /// /// Invoked synchronously by RebalanceDecisionEngine within the background intent processing loop () /// Defines the shape of the sliding window cache by expanding the requested range according to configuration -/// Pure function: Stateless, value type, no side effects, deterministic: outcome depends only on configuration and request +/// Pure function at the call site: Reads a consistent snapshot of once at the start of and uses it throughout — no side effects, deterministic within a single invocation /// Does not read or mutate cache state; independent of current cache contents /// Used only as analytical input (never executes I/O or mutates shared state) /// /// +/// Runtime-Updatable Configuration: +/// +/// The planner holds a reference to a shared rather than a frozen +/// copy of options. This allows LeftCacheSize and RightCacheSize to be updated at runtime via +/// IWindowCache.UpdateRuntimeOptions without reconstructing the planner. Changes take effect on the +/// next rebalance decision cycle ("next cycle" semantics). +/// /// Responsibilities: /// /// -/// Computes DesiredCacheRange for any RequestedRange + config (see ) +/// Computes DesiredCacheRange for any RequestedRange + current config snapshot /// Defines canonical geometry for rebalance, ensuring predictability and stability /// Answers: "What shape to target?" in the rebalance decision pipeline /// @@ -49,34 +56,38 @@ namespace SlidingWindowCache.Core.Planning; /// /// Type representing the boundaries of a window/range; must be comparable (see ) so intervals can be ordered and spanned. /// Provides domain-specific logic to compute spans, boundaries, and interval arithmetic for TRange. -internal readonly struct ProportionalRangePlanner +internal sealed class ProportionalRangePlanner where TRange : IComparable where TDomain : IRangeDomain { - private readonly WindowCacheOptions _options; + private readonly RuntimeCacheOptionsHolder _optionsHolder; private readonly TDomain _domain; /// - /// Initializes a new instance of with the specified cache configuration and domain definition. + /// Initializes a new instance of with the specified options holder and domain definition. /// - /// Immutable cache geometry configuration (see ); provides proportional left/right sizing policies. + /// + /// Shared holder for the current runtime options snapshot. The planner reads + /// once per invocation so that + /// changes published via IWindowCache.UpdateRuntimeOptions take effect on the next cycle. + /// /// Domain implementation used for range arithmetic and span calculations. /// /// - /// This constructor wires the planner to a specific cache configuration and domain only; it does not perform any computation or validation. The planner is invoked by RebalanceDecisionEngine during Stage 3 (Desired Range Computation) of the decision evaluation pipeline, which executes in the background intent processing loop. + /// This constructor wires the planner to a shared options holder and domain only; it does not perform any computation or validation. The planner is invoked by RebalanceDecisionEngine during Stage 3 (Desired Range Computation) of the decision evaluation pipeline, which executes in the background intent processing loop. /// /// /// References: Invariants E.30-E.33, D.25-D.26 (see docs/invariants.md). /// /// - public ProportionalRangePlanner(WindowCacheOptions options, TDomain domain) + public ProportionalRangePlanner(RuntimeCacheOptionsHolder optionsHolder, TDomain domain) { - _options = options; + _optionsHolder = optionsHolder; _domain = domain; } /// - /// Computes the canonical DesiredCacheRange to target for a given window, expanding left/right according to the cache configuration. + /// Computes the canonical DesiredCacheRange to target for a given window, expanding left/right according to the current runtime configuration. /// /// User-requested range for which cache expansion should be planned. /// @@ -85,9 +96,10 @@ public ProportionalRangePlanner(WindowCacheOptions options, TDomain domain) /// /// This method: /// + /// Snapshots once at entry for consistency within the invocation /// Defines the shape of the sliding window, not the contents /// Is pure/side-effect free: No cache state or I/O interaction - /// Applies only configuration and domain arithmetic (see , ) + /// Applies only the current options snapshot and domain arithmetic (see LeftCacheSize, RightCacheSize on ) /// Does not trigger or decide rebalance — strictly analytical /// Enforces Invariants: E.30 (function of RequestedRange + config), E.31 (independent of cache state), E.32 (defines canonical convergent target), D.25-D.26 (analytical/CPU-only) /// @@ -102,10 +114,13 @@ public ProportionalRangePlanner(WindowCacheOptions options, TDomain domain) /// public Range Plan(Range requested) { + // Snapshot current options once for consistency within this invocation + var options = _optionsHolder.Current; + var size = requested.Span(_domain); - var left = size.Value * _options.LeftCacheSize; - var right = size.Value * _options.RightCacheSize; + var left = size.Value * options.LeftCacheSize; + var right = size.Value * options.RightCacheSize; return requested.Expand( domain: _domain, diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs index c6d2b4b..f9e7b6b 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs @@ -88,7 +88,7 @@ CancellationToken cancellationToken _cacheDiagnostics.DataSourceFetchMissingSegments(); // Step 1: Calculate which ranges are missing (and record the expansion/replacement diagnostic) - var missingRanges = CalculateMissingRanges(currentCache.Range, requested, out bool isCacheExpanded); + var missingRanges = CalculateMissingRanges(currentCache.Range, requested, out var isCacheExpanded); // Step 2: Record the diagnostic event here (caller context), not inside the pure helper if (isCacheExpanded) diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs index 80dc420..87a3aba 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs @@ -2,6 +2,7 @@ using Intervals.NET; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Intent; +using SlidingWindowCache.Core.State; using SlidingWindowCache.Infrastructure.Concurrency; using SlidingWindowCache.Public.Instrumentation; @@ -36,7 +37,7 @@ namespace SlidingWindowCache.Core.Rebalance.Execution; /// // Sequential processing loop: /// await foreach (var request in _executionChannel.Reader.ReadAllAsync()) /// { -/// await ExecuteRebalanceAsync(request); // One at a time +/// await ExecuteRequestCoreAsync(request); // One at a time /// } /// /// Backpressure Behavior: @@ -92,34 +93,22 @@ namespace SlidingWindowCache.Core.Rebalance.Execution; /// See also: for unbounded alternative /// internal sealed class ChannelBasedRebalanceExecutionController - : IRebalanceExecutionController + : RebalanceExecutionControllerBase where TRange : IComparable where TDomain : IRangeDomain { - private readonly RebalanceExecutor _executor; - private readonly TimeSpan _debounceDelay; - private readonly ICacheDiagnostics _cacheDiagnostics; private readonly Channel> _executionChannel; private readonly Task _executionLoopTask; - // Activity counter for tracking active operations - private readonly AsyncActivityCounter _activityCounter; - - // Disposal state tracking (lock-free using Interlocked) - // 0 = not disposed, 1 = disposed - private int _disposeState; - - /// - /// Stores the most recent execution request submitted to the execution controller. - /// Used for tracking the current execution state and for testing/diagnostic purposes. - /// - private ExecutionRequest? _lastExecutionRequest; - /// /// Initializes a new instance of the class. /// /// The executor for performing rebalance operations. - /// The debounce delay before executing rebalance. + /// + /// Shared holder for the current runtime options snapshot. The controller reads + /// at the start of each execution to pick up + /// the latest DebounceDelay published via IWindowCache.UpdateRuntimeOptions. + /// /// The diagnostics interface for recording rebalance-related metrics and events. /// Activity counter for tracking active operations. /// The bounded channel capacity for backpressure control. Must be >= 1. @@ -139,11 +128,11 @@ internal sealed class ChannelBasedRebalanceExecutionController public ChannelBasedRebalanceExecutionController( RebalanceExecutor executor, - TimeSpan debounceDelay, + RuntimeCacheOptionsHolder optionsHolder, ICacheDiagnostics cacheDiagnostics, AsyncActivityCounter activityCounter, int capacity - ) + ) : base(executor, optionsHolder, cacheDiagnostics, activityCounter) { if (capacity < 1) { @@ -151,11 +140,6 @@ int capacity "Capacity must be greater than or equal to 1."); } - _executor = executor; - _debounceDelay = debounceDelay; - _cacheDiagnostics = cacheDiagnostics; - _activityCounter = activityCounter; - // Initialize bounded channel with single reader/writer semantics // Bounded capacity enables backpressure on IntentController actor // SingleReader: only execution loop reads; SingleWriter: only IntentController writes @@ -172,20 +156,6 @@ int capacity _executionLoopTask = ProcessExecutionRequestsAsync(); } - /// - /// Gets the most recent execution request submitted to the execution controller. - /// Returns null if no execution request has been submitted yet. - /// - /// - /// Thread Safety: - /// - /// Uses to ensure proper memory visibility across threads. - /// This property can be safely accessed from multiple threads (intent loop, decision engine). - /// - /// - public ExecutionRequest? LastExecutionRequest - => Volatile.Read(ref _lastExecutionRequest); - /// /// Publishes a rebalance execution request to the bounded channel for sequential processing. /// @@ -218,14 +188,14 @@ public ExecutionRequest? LastExecutionRequest /// in a fire-and-forget manner. Only the background intent processing loop experiences backpressure. /// /// - public async ValueTask PublishExecutionRequest( + public override async ValueTask PublishExecutionRequest( Intent intent, Range desiredRange, Range? desiredNoRebalanceRange, CancellationToken loopCancellationToken) { - // Check disposal state using Volatile.Read (lock-free) - if (Volatile.Read(ref _disposeState) != 0) + // Check disposal state + if (IsDisposed) { throw new ObjectDisposedException( nameof(ChannelBasedRebalanceExecutionController), @@ -233,7 +203,7 @@ public async ValueTask PublishExecutionRequest( } // Increment activity counter for new execution request - _activityCounter.IncrementActivity(); + ActivityCounter.IncrementActivity(); // Create CancellationTokenSource for this execution request var cancellationTokenSource = new CancellationTokenSource(); @@ -245,7 +215,7 @@ public async ValueTask PublishExecutionRequest( desiredNoRebalanceRange, cancellationTokenSource ); - Volatile.Write(ref _lastExecutionRequest, request); + StoreLastExecutionRequest(request); // Enqueue execution request to bounded channel // BACKPRESSURE: This will await if channel is at capacity, creating backpressure on intent processing loop @@ -259,14 +229,14 @@ public async ValueTask PublishExecutionRequest( // Write cancelled during disposal - clean up and exit gracefully // Don't throw - disposal is shutting down the loop request.Dispose(); - _activityCounter.DecrementActivity(); + ActivityCounter.DecrementActivity(); } catch (Exception ex) { // If write fails (e.g., channel completed during disposal), clean up and report request.Dispose(); - _activityCounter.DecrementActivity(); - _cacheDiagnostics.RebalanceExecutionFailed(ex); + ActivityCounter.DecrementActivity(); + CacheDiagnostics.RebalanceExecutionFailed(ex); throw; // Re-throw to signal failure to caller } } @@ -281,15 +251,6 @@ public async ValueTask PublishExecutionRequest( /// This loop runs on a single background thread and processes requests one at a time via Channel. /// NO TWO REBALANCE EXECUTIONS can ever run in parallel. The Channel ensures serial processing. /// - /// Processing Steps for Each Request: - /// - /// Read ExecutionRequest from bounded channel (blocks if empty) - /// Apply debounce delay (with cancellation check) - /// Check cancellation before execution - /// Execute rebalance via RebalanceExecutor (CacheState mutation occurs here) - /// Handle exceptions and diagnostics - /// Dispose request resources and decrement activity counter - /// /// Backpressure Effect: /// /// When this loop processes a request, it frees space in the bounded channel, allowing @@ -300,117 +261,18 @@ private async Task ProcessExecutionRequestsAsync() { await foreach (var request in _executionChannel.Reader.ReadAllAsync()) { - _cacheDiagnostics.RebalanceExecutionStarted(); - - var intent = request.Intent; - var desiredRange = request.DesiredRange; - var desiredNoRebalanceRange = request.DesiredNoRebalanceRange; - var cancellationToken = request.CancellationToken; - - try - { - // Step 1: Apply debounce delay - allows superseded operations to be cancelled - // ConfigureAwait(false) ensures continuation on thread pool - await Task.Delay(_debounceDelay, cancellationToken) - .ConfigureAwait(false); - - // Step 2: Check cancellation after debounce - avoid wasted I/O work - // NOTE: We check IsCancellationRequested explicitly here rather than relying solely on the - // OperationCanceledException catch below. Task.Delay can complete normally just as cancellation - // is signalled (a race), so we may reach here with cancellation requested but no exception thrown. - // This explicit check provides a clean diagnostic event path (RebalanceExecutionCancelled) for - // that case, separate from the exception-based cancellation path in the catch block below. - if (cancellationToken.IsCancellationRequested) - { - _cacheDiagnostics.RebalanceExecutionCancelled(); - continue; - } - - // Step 3: Execute the rebalance - this is where CacheState mutation occurs - // This is the ONLY place in the entire system where cache state is written - await _executor.ExecuteAsync( - intent, - desiredRange, - desiredNoRebalanceRange, - cancellationToken) - .ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Expected when execution is cancelled or superseded - _cacheDiagnostics.RebalanceExecutionCancelled(); - } - catch (Exception ex) - { - // Execution failed - record diagnostic - // Applications MUST monitor RebalanceExecutionFailed events and implement - // appropriate error handling (logging, alerting, monitoring) - _cacheDiagnostics.RebalanceExecutionFailed(ex); - } - finally - { - // Dispose CancellationTokenSource - request.Dispose(); - - // Decrement activity counter for execution - // This ALWAYS happens after execution completes/cancels/fails - _activityCounter.DecrementActivity(); - } + await ExecuteRequestCoreAsync(request).ConfigureAwait(false); } } - /// - /// Disposes the execution controller and releases all managed resources. - /// Gracefully shuts down the execution loop and waits for completion. - /// - /// A ValueTask representing the asynchronous disposal operation. - /// - /// Disposal Sequence: - /// - /// Mark as disposed (prevents new execution requests) - /// Cancel last execution request (if present) - /// Complete the channel writer (signals loop to exit after current operation) - /// Wait for execution loop to complete gracefully - /// Dispose last execution request resources - /// - /// Thread Safety: - /// - /// This method is thread-safe and idempotent using lock-free Interlocked operations. - /// Multiple concurrent calls will execute disposal only once. - /// - /// Exception Handling: - /// - /// Uses best-effort cleanup. Exceptions during loop completion are logged via diagnostics - /// but do not prevent subsequent cleanup steps. - /// - /// - public async ValueTask DisposeAsync() + /// + private protected override async ValueTask DisposeAsyncCore() { - // Idempotent check using lock-free Interlocked.CompareExchange - if (Interlocked.CompareExchange(ref _disposeState, 1, 0) != 0) - { - return; // Already disposed - } - - Volatile.Read(ref _lastExecutionRequest)?.Cancel(); - // Complete the channel - signals execution loop to exit after current operation _executionChannel.Writer.Complete(); // Wait for execution loop to complete gracefully // No timeout needed per architectural decision: graceful shutdown with cancellation - try - { - await _executionLoopTask.ConfigureAwait(false); - } - catch (Exception ex) - { - // Log via diagnostics but don't throw - best-effort disposal - // Follows "Background Path Exceptions" pattern from AGENTS.md - _cacheDiagnostics.RebalanceExecutionFailed(ex); - } - - // Dispose last execution request if present - Volatile.Read(ref _lastExecutionRequest)?.Dispose(); + await _executionLoopTask.ConfigureAwait(false); } } diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/IRebalanceExecutionController.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/IRebalanceExecutionController.cs index f184c39..7aa780b 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/IRebalanceExecutionController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/IRebalanceExecutionController.cs @@ -1,6 +1,7 @@ using Intervals.NET; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Intent; +using SlidingWindowCache.Public.Cache; namespace SlidingWindowCache.Core.Rebalance.Execution; @@ -31,7 +32,7 @@ namespace SlidingWindowCache.Core.Rebalance.Execution; /// /// Strategy Selection: /// -/// The concrete implementation is selected by +/// The concrete implementation is selected by /// based on : /// /// diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutionControllerBase.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutionControllerBase.cs new file mode 100644 index 0000000..66b6c5f --- /dev/null +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutionControllerBase.cs @@ -0,0 +1,233 @@ +using Intervals.NET; +using Intervals.NET.Domain.Abstractions; +using SlidingWindowCache.Core.Rebalance.Intent; +using SlidingWindowCache.Core.State; +using SlidingWindowCache.Infrastructure.Concurrency; +using SlidingWindowCache.Public.Instrumentation; + +namespace SlidingWindowCache.Core.Rebalance.Execution; + +/// +/// Abstract base class providing the shared execution pipeline for rebalance execution controllers. +/// +/// The type representing the range boundaries. +/// The type of data being cached. +/// The type representing the domain of the ranges. +/// +/// Purpose: +/// +/// Centralizes the logic that is identical across all +/// implementations: +/// shared fields, the property, the per-request execution +/// pipeline (debounce → cancellation check → executor call → diagnostics → cleanup), and the +/// disposal guard. Each concrete subclass provides only the serialization mechanism +/// () and the strategy-specific teardown +/// (). +/// +/// Shared Execution Pipeline: +/// +/// contains the canonical execution body: +/// +/// Signal RebalanceExecutionStarted diagnostic +/// Snapshot DebounceDelay from the options holder ("next cycle" semantics) +/// Await Task.Delay(debounceDelay, cancellationToken) +/// Check IsCancellationRequested after debounce (Task.Delay race guard) +/// Call +/// Catch OperationCanceledExceptionRebalanceExecutionCancelled +/// Catch all other exceptions → RebalanceExecutionFailed +/// finally: dispose the request, decrement the activity counter +/// +/// +/// Disposal Protocol: +/// +/// handles the idempotent guard (Interlocked) and cancels the last +/// execution request. It then delegates to for strategy-specific +/// teardown (awaiting the task chain vs. completing the channel), and finally disposes the last +/// execution request. +/// +/// +internal abstract class RebalanceExecutionControllerBase + : IRebalanceExecutionController + where TRange : IComparable + where TDomain : IRangeDomain +{ + /// The executor that performs the actual cache mutation. + private protected readonly RebalanceExecutor Executor; + + /// Shared holder for the current runtime options snapshot. + private protected readonly RuntimeCacheOptionsHolder OptionsHolder; + + /// Diagnostics interface for recording rebalance events. + private protected readonly ICacheDiagnostics CacheDiagnostics; + + /// Activity counter for tracking active operations. + private protected readonly AsyncActivityCounter ActivityCounter; + + // Disposal state: 0 = not disposed, 1 = disposed (lock-free via Interlocked) + private int _disposeState; + + /// Most recent execution request; updated via Volatile.Write. + private ExecutionRequest? _lastExecutionRequest; + + /// + /// Initializes the shared fields. + /// + private protected RebalanceExecutionControllerBase( + RebalanceExecutor executor, + RuntimeCacheOptionsHolder optionsHolder, + ICacheDiagnostics cacheDiagnostics, + AsyncActivityCounter activityCounter) + { + Executor = executor; + OptionsHolder = optionsHolder; + CacheDiagnostics = cacheDiagnostics; + ActivityCounter = activityCounter; + } + + /// + public ExecutionRequest? LastExecutionRequest => + Volatile.Read(ref _lastExecutionRequest); + + /// + /// Sets the last execution request atomically (release fence). + /// + private protected void StoreLastExecutionRequest(ExecutionRequest request) => + Volatile.Write(ref _lastExecutionRequest, request); + + /// + public abstract ValueTask PublishExecutionRequest( + Intent intent, + Range desiredRange, + Range? desiredNoRebalanceRange, + CancellationToken loopCancellationToken); + + /// + /// Executes a single rebalance request: debounce, cancellation check, executor call, diagnostics, cleanup. + /// This is the canonical execution pipeline shared by all strategy implementations. + /// + /// + /// Execution Steps: + /// + /// Signal RebalanceExecutionStarted + /// Snapshot DebounceDelay from holder at execution time ("next cycle" semantics) + /// Await Task.Delay(debounceDelay, cancellationToken) + /// Explicit IsCancellationRequested check after debounce (Task.Delay race guard) + /// Call RebalanceExecutor.ExecuteAsync — the sole point of CacheState mutation + /// Catch OperationCanceledException → signal RebalanceExecutionCancelled + /// Catch other exceptions → signal RebalanceExecutionFailed + /// finally: dispose request, decrement activity counter + /// + /// + private protected async Task ExecuteRequestCoreAsync(ExecutionRequest request) + { + CacheDiagnostics.RebalanceExecutionStarted(); + + var intent = request.Intent; + var desiredRange = request.DesiredRange; + var desiredNoRebalanceRange = request.DesiredNoRebalanceRange; + var cancellationToken = request.CancellationToken; + + // Snapshot DebounceDelay from the options holder at execution time. + // This picks up any runtime update published via IWindowCache.UpdateRuntimeOptions + // since this execution request was enqueued ("next cycle" semantics). + var debounceDelay = OptionsHolder.Current.DebounceDelay; + + try + { + // Step 1: Apply debounce delay - allows superseded operations to be cancelled + // ConfigureAwait(false) ensures continuation on thread pool + await Task.Delay(debounceDelay, cancellationToken) + .ConfigureAwait(false); + + // Step 2: Check cancellation after debounce - avoid wasted I/O work + // NOTE: We check IsCancellationRequested explicitly here rather than relying solely on the + // OperationCanceledException catch below. Task.Delay can complete normally just as cancellation + // is signalled (a race), so we may reach here with cancellation requested but no exception thrown. + // This explicit check provides a clean diagnostic event path (RebalanceExecutionCancelled) for + // that case, separate from the exception-based cancellation path in the catch block below. + if (cancellationToken.IsCancellationRequested) + { + CacheDiagnostics.RebalanceExecutionCancelled(); + return; + } + + // Step 3: Execute the rebalance - this is where CacheState mutation occurs + // This is the ONLY place in the entire system where cache state is written + // (when this strategy is active) + await Executor.ExecuteAsync( + intent, + desiredRange, + desiredNoRebalanceRange, + cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected when execution is cancelled or superseded + CacheDiagnostics.RebalanceExecutionCancelled(); + } + catch (Exception ex) + { + // Execution failed - record diagnostic + // Applications MUST monitor RebalanceExecutionFailed events and implement + // appropriate error handling (logging, alerting, monitoring) + CacheDiagnostics.RebalanceExecutionFailed(ex); + } + finally + { + // Dispose CancellationTokenSource + request.Dispose(); + + // Decrement activity counter for execution + // This ALWAYS happens after execution completes/cancels/fails + ActivityCounter.DecrementActivity(); + } + } + + /// + /// Performs strategy-specific teardown during disposal. + /// Called by after the disposal guard has fired and the last request has been cancelled. + /// + /// + /// Implementations should stop the serialization mechanism here: + /// + /// Task-based: await the current task chain + /// Channel-based: complete the channel writer and await the loop task + /// + /// + private protected abstract ValueTask DisposeAsyncCore(); + + /// + /// Returns whether the controller has been disposed. + /// Subclasses use this to guard . + /// + private protected bool IsDisposed => Volatile.Read(ref _disposeState) != 0; + + /// + public async ValueTask DisposeAsync() + { + // Idempotent guard using lock-free Interlocked.CompareExchange + if (Interlocked.CompareExchange(ref _disposeState, 1, 0) != 0) + { + return; // Already disposed + } + + // Cancel last execution request (signals early exit from debounce / I/O) + Volatile.Read(ref _lastExecutionRequest)?.Cancel(); + + // Strategy-specific teardown (await task chain / complete channel + await loop) + try + { + await DisposeAsyncCore().ConfigureAwait(false); + } + catch (Exception ex) + { + // Log via diagnostics but don't throw - best-effort disposal + // Follows "Background Path Exceptions" pattern from AGENTS.md + CacheDiagnostics.RebalanceExecutionFailed(ex); + } + + // Dispose last execution request resources + Volatile.Read(ref _lastExecutionRequest)?.Dispose(); + } +} diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs index 5f5d45c..207cb99 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs @@ -1,6 +1,7 @@ using Intervals.NET; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Intent; +using SlidingWindowCache.Core.State; using SlidingWindowCache.Infrastructure.Concurrency; using SlidingWindowCache.Public.Instrumentation; @@ -86,35 +87,22 @@ namespace SlidingWindowCache.Core.Rebalance.Execution; /// See also: for bounded alternative with backpressure /// internal sealed class TaskBasedRebalanceExecutionController - : IRebalanceExecutionController + : RebalanceExecutionControllerBase where TRange : IComparable where TDomain : IRangeDomain { - private readonly RebalanceExecutor _executor; - private readonly TimeSpan _debounceDelay; - private readonly ICacheDiagnostics _cacheDiagnostics; - - // Activity counter for tracking active operations - private readonly AsyncActivityCounter _activityCounter; - // Task chaining state (volatile write for single-writer pattern) private Task _currentExecutionTask = Task.CompletedTask; - // Disposal state tracking (lock-free using Interlocked) - // 0 = not disposed, 1 = disposed - private int _disposeState; - - /// - /// Stores the most recent execution request submitted to the execution controller. - /// Used for tracking the current execution state, cancellation coordination, and testing/diagnostic purposes. - /// - private ExecutionRequest? _lastExecutionRequest; - /// /// Initializes a new instance of the class. /// /// The executor for performing rebalance operations. - /// The debounce delay before executing rebalance. + /// + /// Shared holder for the current runtime options snapshot. The controller reads + /// at the start of each execution to pick up + /// the latest DebounceDelay published via IWindowCache.UpdateRuntimeOptions. + /// /// The diagnostics interface for recording rebalance-related metrics and events. /// Activity counter for tracking active operations. /// @@ -131,24 +119,13 @@ internal sealed class TaskBasedRebalanceExecutionController public TaskBasedRebalanceExecutionController( RebalanceExecutor executor, - TimeSpan debounceDelay, + RuntimeCacheOptionsHolder optionsHolder, ICacheDiagnostics cacheDiagnostics, AsyncActivityCounter activityCounter - ) + ) : base(executor, optionsHolder, cacheDiagnostics, activityCounter) { - _executor = executor; - _debounceDelay = debounceDelay; - _cacheDiagnostics = cacheDiagnostics; - _activityCounter = activityCounter; } - /// - /// Gets the most recent execution request submitted to the execution controller. - /// Returns null if no execution request has been submitted yet. - /// - public ExecutionRequest? LastExecutionRequest => - Volatile.Read(ref _lastExecutionRequest); - /// /// Publishes a rebalance execution request by chaining it to the previous execution task. /// @@ -187,14 +164,14 @@ AsyncActivityCounter activityCounter /// after multi-stage validation confirms rebalance necessity. Never blocks - returns immediately. /// /// - public ValueTask PublishExecutionRequest( + public override ValueTask PublishExecutionRequest( Intent intent, Range desiredRange, Range? desiredNoRebalanceRange, CancellationToken loopCancellationToken) { - // Check disposal state using Volatile.Read (lock-free) - if (Volatile.Read(ref _disposeState) != 0) + // Check disposal state + if (IsDisposed) { throw new ObjectDisposedException( nameof(TaskBasedRebalanceExecutionController), @@ -202,11 +179,10 @@ public ValueTask PublishExecutionRequest( } // Increment activity counter for new execution request - _activityCounter.IncrementActivity(); + ActivityCounter.IncrementActivity(); // Cancel previous execution request (if exists) - var previousRequest = Volatile.Read(ref _lastExecutionRequest); - previousRequest?.Cancel(); + LastExecutionRequest?.Cancel(); // Create CancellationTokenSource for this execution request var cancellationTokenSource = new CancellationTokenSource(); @@ -220,7 +196,7 @@ public ValueTask PublishExecutionRequest( ); // Store as last request (for cancellation coordination and diagnostics) - Volatile.Write(ref _lastExecutionRequest, request); + StoreLastExecutionRequest(request); // Chain execution to previous task (lock-free using volatile write - single-writer context) // Read current task, create new chained task, and update atomically @@ -264,166 +240,29 @@ private async Task ChainExecutionAsync(Task previousTask, ExecutionRequest - /// Executes a rebalance request with debounce delay and cancellation support. - /// This is where the actual cache mutation occurs (via RebalanceExecutor). - /// - /// The execution request containing intent, desired range, and cancellation token. - /// - /// Execution Steps: - /// - /// Apply debounce delay (with cancellation check) - /// Check cancellation after debounce (before I/O) - /// Execute rebalance via RebalanceExecutor (CacheState mutation occurs here) - /// Handle exceptions and diagnostics - /// Cleanup: dispose request and decrement activity counter - /// - /// Thread Safety: - /// - /// This method runs sequentially due to task chaining (one execution at a time). - /// The single-writer architecture guarantee is maintained through serialization via the task chain. - /// - /// Exception Handling: - /// - /// All exceptions are captured and reported via diagnostics. This follows the "Background Path Exceptions" - /// pattern from AGENTS.md: background exceptions must not crash the application. - /// - /// - private async Task ExecuteRequestAsync(ExecutionRequest request) - { - _cacheDiagnostics.RebalanceExecutionStarted(); - - var intent = request.Intent; - var desiredRange = request.DesiredRange; - var desiredNoRebalanceRange = request.DesiredNoRebalanceRange; - var cancellationToken = request.CancellationToken; - - try - { - // Step 1: Apply debounce delay - allows superseded operations to be cancelled - // ConfigureAwait(false) ensures continuation on thread pool - await Task.Delay(_debounceDelay, cancellationToken) - .ConfigureAwait(false); - - // Step 2: Check cancellation after debounce - avoid wasted I/O work - // NOTE: We check IsCancellationRequested explicitly here rather than relying solely on the - // OperationCanceledException catch below. Task.Delay can complete normally just as cancellation - // is signalled (a race), so we may reach here with cancellation requested but no exception thrown. - // This explicit check provides a clean diagnostic event path (RebalanceExecutionCancelled) for - // that case, separate from the exception-based cancellation path in the catch block below. - if (cancellationToken.IsCancellationRequested) - { - _cacheDiagnostics.RebalanceExecutionCancelled(); - return; - } - - // Step 3: Execute the rebalance - this is where CacheState mutation occurs - // This is the ONLY place in the entire system where cache state is written - // (when this strategy is active) - await _executor.ExecuteAsync( - intent, - desiredRange, - desiredNoRebalanceRange, - cancellationToken) - .ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Expected when execution is cancelled or superseded - _cacheDiagnostics.RebalanceExecutionCancelled(); + // Execute current request via the shared pipeline + await ExecuteRequestCoreAsync(request).ConfigureAwait(false); } catch (Exception ex) { - // Execution failed - record diagnostic - // Applications MUST monitor RebalanceExecutionFailed events and implement - // appropriate error handling (logging, alerting, monitoring) - _cacheDiagnostics.RebalanceExecutionFailed(ex); - } - finally - { - // Dispose CancellationTokenSource - request.Dispose(); - - // Decrement activity counter for execution - // This ALWAYS happens after execution completes/cancels/fails - _activityCounter.DecrementActivity(); + // ExecuteRequestCoreAsync already handles exceptions internally, but catch here for safety + CacheDiagnostics.RebalanceExecutionFailed(ex); } } - /// - /// Disposes the execution controller and releases all managed resources. - /// Waits for the current execution task chain to complete gracefully. - /// - /// A ValueTask representing the asynchronous disposal operation. - /// - /// Disposal Sequence: - /// - /// Mark as disposed (prevents new execution requests) - /// Cancel last execution request (if present) - /// Capture current task chain reference (volatile read) - /// Wait for task chain to complete gracefully - /// Dispose last execution request resources - /// - /// Thread Safety: - /// - /// This method is thread-safe and idempotent using lock-free Interlocked operations. - /// Multiple concurrent calls will execute disposal only once. - /// - /// Graceful Shutdown: - /// - /// No timeout is enforced per architectural decision. Disposal waits for the current execution - /// to complete naturally (typically milliseconds). Cancellation signals early exit. - /// - /// Exception Handling: - /// - /// Uses best-effort cleanup. Exceptions during task completion are logged via diagnostics - /// but do not prevent subsequent cleanup steps. - /// - /// - public async ValueTask DisposeAsync() + /// + private protected override async ValueTask DisposeAsyncCore() { - // Idempotent check using lock-free Interlocked.CompareExchange - if (Interlocked.CompareExchange(ref _disposeState, 1, 0) != 0) - { - return; // Already disposed - } - - // Cancel last execution request (signals early exit) - Volatile.Read(ref _lastExecutionRequest)?.Cancel(); - // Capture current task chain reference (volatile read - no lock needed) var currentTask = Volatile.Read(ref _currentExecutionTask); - // Wait for current task chain to complete gracefully + // Wait for task chain to complete gracefully // No timeout needed per architectural decision: graceful shutdown with cancellation - try - { - await currentTask.ConfigureAwait(false); - } - catch (Exception ex) - { - // Log via diagnostics but don't throw - best-effort disposal - // Follows "Background Path Exceptions" pattern from AGENTS.md - _cacheDiagnostics.RebalanceExecutionFailed(ex); - } - - // Dispose last execution request if present - Volatile.Read(ref _lastExecutionRequest)?.Dispose(); + await currentTask.ConfigureAwait(false); } } diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index 27631bd..9ccb3c9 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -185,7 +185,7 @@ private async Task ProcessIntentsAsync() { // Track whether we successfully consumed a semaphore signal // This prevents activity counter imbalance when disposal cancels WaitAsync - bool consumedSignal = false; + var consumedSignal = false; try { diff --git a/src/SlidingWindowCache/Core/State/RuntimeCacheOptions.cs b/src/SlidingWindowCache/Core/State/RuntimeCacheOptions.cs new file mode 100644 index 0000000..bda5d52 --- /dev/null +++ b/src/SlidingWindowCache/Core/State/RuntimeCacheOptions.cs @@ -0,0 +1,104 @@ +using SlidingWindowCache.Public.Configuration; + +namespace SlidingWindowCache.Core.State; + +/// +/// An immutable snapshot of the runtime-updatable cache configuration values. +/// +/// +/// Architectural Context: +/// +/// holds the five configuration values that may be changed on a live +/// cache instance via IWindowCache.UpdateRuntimeOptions. It is always treated as an immutable +/// snapshot: updates create a new instance which is then atomically published via +/// . +/// +/// Snapshot Consistency: +/// +/// Because the holder swaps the entire reference atomically (Volatile.Write), all five values are always +/// observed as a consistent set by background threads reading . +/// There is never a window where some values belong to an old update and others to a new one. +/// +/// Validation: +/// +/// Applies the same validation rules as +/// : +/// cache sizes ≥ 0, thresholds in [0, 1], threshold sum ≤ 1.0. +/// +/// Threading: +/// +/// Instances are read-only after construction and therefore inherently thread-safe. +/// The holder manages the visibility of the current snapshot across threads. +/// +/// +internal sealed class RuntimeCacheOptions +{ + /// + /// Initializes a new snapshot and validates all values. + /// + /// The coefficient for the left cache size. Must be ≥ 0. + /// The coefficient for the right cache size. Must be ≥ 0. + /// The left no-rebalance threshold percentage. Must be in [0, 1] when not null. + /// The right no-rebalance threshold percentage. Must be in [0, 1] when not null. + /// The debounce delay applied before executing a rebalance. Must be non-negative. + /// + /// Thrown when or is less than 0, + /// when a threshold value is outside [0, 1], or when is negative. + /// + /// + /// Thrown when both thresholds are specified and their sum exceeds 1.0. + /// + public RuntimeCacheOptions( + double leftCacheSize, + double rightCacheSize, + double? leftThreshold, + double? rightThreshold, + TimeSpan debounceDelay) + { + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds( + leftCacheSize, rightCacheSize, leftThreshold, rightThreshold); + + if (debounceDelay < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(debounceDelay), + "DebounceDelay must be non-negative."); + } + + LeftCacheSize = leftCacheSize; + RightCacheSize = rightCacheSize; + LeftThreshold = leftThreshold; + RightThreshold = rightThreshold; + DebounceDelay = debounceDelay; + } + + /// + /// The coefficient for the left cache size relative to the requested range. + /// + public double LeftCacheSize { get; } + + /// + /// The coefficient for the right cache size relative to the requested range. + /// + public double RightCacheSize { get; } + + /// + /// The left no-rebalance threshold percentage, or null to disable the left threshold. + /// + public double? LeftThreshold { get; } + + /// + /// The right no-rebalance threshold percentage, or null to disable the right threshold. + /// + public double? RightThreshold { get; } + + /// + /// The debounce delay applied before executing a rebalance. + /// + public TimeSpan DebounceDelay { get; } + + /// + /// Projects this internal snapshot to a public DTO. + /// + internal RuntimeOptionsSnapshot ToSnapshot() => + new(LeftCacheSize, RightCacheSize, LeftThreshold, RightThreshold, DebounceDelay); +} \ No newline at end of file diff --git a/src/SlidingWindowCache/Core/State/RuntimeCacheOptionsHolder.cs b/src/SlidingWindowCache/Core/State/RuntimeCacheOptionsHolder.cs new file mode 100644 index 0000000..92e266b --- /dev/null +++ b/src/SlidingWindowCache/Core/State/RuntimeCacheOptionsHolder.cs @@ -0,0 +1,67 @@ +namespace SlidingWindowCache.Core.State; + +/// +/// Thread-safe holder for the current snapshot. +/// Supports atomic, lock-free reads and writes using memory barriers. +/// +/// +/// Architectural Context: +/// +/// is the shared configuration bridge between the user thread +/// (which calls IWindowCache.UpdateRuntimeOptions) and the background threads (intent loop, +/// execution controllers) that read the current options during decision and execution. +/// +/// Memory Model: +/// +/// Write (user thread): uses (release fence) — ensures the fully-constructed new snapshot is visible to all subsequent reads. +/// Read (background threads): uses (acquire fence) — ensures reads observe the latest published snapshot. +/// +/// Consistency Guarantee: +/// +/// Because the entire reference is swapped atomically, background threads +/// always observe a consistent set of all five values. There is never a partial-update window. +/// Updates take effect on the next background read cycle ("next cycle" semantics), which is compatible +/// with the system's eventual consistency model. +/// +/// Concurrent Updates: +/// +/// Multiple concurrent calls to are safe: last-writer-wins. This is acceptable +/// for configuration updates where the latest user intent should always prevail. +/// +/// +internal sealed class RuntimeCacheOptionsHolder +{ + // The currently active configuration snapshot. + // Written via Volatile.Write (release fence); read via Volatile.Read (acquire fence). + private RuntimeCacheOptions _current; + + /// + /// Initializes a new with the provided initial snapshot. + /// + /// The initial runtime options snapshot. Must not be null. + public RuntimeCacheOptionsHolder(RuntimeCacheOptions initial) + { + _current = initial; + } + + /// + /// Returns the currently active snapshot. + /// Uses to ensure the freshest published snapshot is observed. + /// + /// + /// Callers should snapshot this value at the start of a decision/execution unit of work + /// and use that snapshot consistently throughout, rather than calling this property multiple times. + /// + public RuntimeCacheOptions Current => Volatile.Read(ref _current); + + /// + /// Atomically replaces the current snapshot with . + /// Uses to publish the new reference with a release fence, + /// ensuring it is immediately visible to all subsequent reads. + /// + /// The new options snapshot. Must not be null. + public void Update(RuntimeCacheOptions newOptions) + { + Volatile.Write(ref _current, newOptions); + } +} diff --git a/src/SlidingWindowCache/Core/State/RuntimeOptionsValidator.cs b/src/SlidingWindowCache/Core/State/RuntimeOptionsValidator.cs new file mode 100644 index 0000000..b2d33c4 --- /dev/null +++ b/src/SlidingWindowCache/Core/State/RuntimeOptionsValidator.cs @@ -0,0 +1,102 @@ +namespace SlidingWindowCache.Core.State; + +/// +/// Provides shared validation logic for runtime-updatable cache option values. +/// +/// +/// Purpose: +/// +/// Centralizes the validation rules that are common to both +/// and +/// , +/// eliminating duplication and ensuring both classes enforce identical constraints. +/// +/// Validated Rules: +/// +/// leftCacheSize ≥ 0 +/// rightCacheSize ≥ 0 +/// leftThreshold in [0, 1] when not null +/// rightThreshold in [0, 1] when not null +/// Sum of both thresholds ≤ 1.0 when both are specified +/// +/// Not Validated Here: +/// +/// Creation-time-only options (rebalanceQueueCapacity) are validated directly +/// in +/// because they do not exist on . +/// DebounceDelay is validated on and +/// (must be ≥ 0); +/// this helper centralizes only cache size and threshold validation. +/// +/// +internal static class RuntimeOptionsValidator +{ + /// + /// Validates cache size and threshold values that are shared between + /// and + /// . + /// + /// Must be ≥ 0. + /// Must be ≥ 0. + /// Must be in [0, 1] when not null. + /// Must be in [0, 1] when not null. + /// + /// Thrown when any size or threshold value is outside its valid range. + /// + /// + /// Thrown when both thresholds are specified and their sum exceeds 1.0. + /// + internal static void ValidateCacheSizesAndThresholds( + double leftCacheSize, + double rightCacheSize, + double? leftThreshold, + double? rightThreshold) + { + if (leftCacheSize < 0) + { + throw new ArgumentOutOfRangeException(nameof(leftCacheSize), + "LeftCacheSize must be greater than or equal to 0."); + } + + if (rightCacheSize < 0) + { + throw new ArgumentOutOfRangeException(nameof(rightCacheSize), + "RightCacheSize must be greater than or equal to 0."); + } + + if (leftThreshold is < 0) + { + throw new ArgumentOutOfRangeException(nameof(leftThreshold), + "LeftThreshold must be greater than or equal to 0."); + } + + if (rightThreshold is < 0) + { + throw new ArgumentOutOfRangeException(nameof(rightThreshold), + "RightThreshold must be greater than or equal to 0."); + } + + if (leftThreshold is > 1.0) + { + throw new ArgumentOutOfRangeException(nameof(leftThreshold), + "LeftThreshold must not exceed 1.0."); + } + + if (rightThreshold is > 1.0) + { + throw new ArgumentOutOfRangeException(nameof(rightThreshold), + "RightThreshold must not exceed 1.0."); + } + + // Validate that thresholds don't overlap (sum must not exceed 1.0) + if (leftThreshold.HasValue && rightThreshold.HasValue && + (leftThreshold.Value + rightThreshold.Value) > 1.0) + { + throw new ArgumentException( + $"The sum of LeftThreshold ({leftThreshold.Value:F6}) and RightThreshold ({rightThreshold.Value:F6}) " + + $"must not exceed 1.0 (actual sum: {leftThreshold.Value + rightThreshold.Value:F6}). " + + "Thresholds represent percentages of the total cache window that are shrunk from each side. " + + "When their sum exceeds 1.0, the shrinkage zones would overlap, creating an invalid configuration."); + } + } +} diff --git a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs index b545390..e099366 100644 --- a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs +++ b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs @@ -145,6 +145,7 @@ public async ValueTask> HandleRequestAsync( RangeData? assembledData; Range? actualRange; ReadOnlyMemory resultData; + CacheInteraction cacheInteraction; if (!fullyInCache && !hasOverlap) { @@ -153,6 +154,7 @@ public async ValueTask> HandleRequestAsync( // Fetch ONLY the requested range from IDataSource. (assembledData, actualRange, resultData) = await FetchSingleRangeAsync(requestedRange, cancellationToken).ConfigureAwait(false); + cacheInteraction = CacheInteraction.FullMiss; _cacheDiagnostics.UserRequestFullCacheMiss(); } else if (fullyInCache) @@ -162,6 +164,7 @@ public async ValueTask> HandleRequestAsync( assembledData = cacheStorage.ToRangeData(); actualRange = requestedRange; // Fully in cache, so actual == requested resultData = cacheStorage.Read(requestedRange); + cacheInteraction = CacheInteraction.FullHit; _cacheDiagnostics.UserRequestFullCacheHit(); } else @@ -176,6 +179,7 @@ public async ValueTask> HandleRequestAsync( cancellationToken ).ConfigureAwait(false); + cacheInteraction = CacheInteraction.PartialHit; _cacheDiagnostics.UserRequestPartialCacheHit(); // Compute actual available range (intersection of requested and assembled). @@ -208,7 +212,7 @@ public async ValueTask> HandleRequestAsync( // where assembledData == null (full vacuum / out-of-physical-bounds). _cacheDiagnostics.UserRequestServed(); - return new RangeResult(actualRange, resultData); + return new RangeResult(actualRange, resultData, cacheInteraction); } /// diff --git a/src/SlidingWindowCache/Public/LayeredWindowCache.cs b/src/SlidingWindowCache/Public/Cache/LayeredWindowCache.cs similarity index 75% rename from src/SlidingWindowCache/Public/LayeredWindowCache.cs rename to src/SlidingWindowCache/Public/Cache/LayeredWindowCache.cs index 9ef9ecd..6321658 100644 --- a/src/SlidingWindowCache/Public/LayeredWindowCache.cs +++ b/src/SlidingWindowCache/Public/Cache/LayeredWindowCache.cs @@ -1,8 +1,9 @@ using Intervals.NET; using Intervals.NET.Domain.Abstractions; +using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Dto; -namespace SlidingWindowCache.Public; +namespace SlidingWindowCache.Public.Cache; /// /// A thin wrapper around a stack of instances @@ -98,6 +99,31 @@ internal LayeredWindowCache(IReadOnlyList> /// public int LayerCount => _layers.Count; + /// + /// Gets the ordered list of all cache layers, from deepest (index 0) to outermost (last index). + /// + /// + /// Layer Order: + /// + /// Index 0 is the deepest layer (closest to the real data source). The last index + /// (Layers.Count - 1) is the outermost, user-facing layer — the same layer that + /// delegates to. + /// + /// Per-Layer Operations: + /// + /// Each layer exposes the full interface. + /// Use this property to update options or inspect the current runtime options of a specific layer: + /// + /// + /// // Update options on the innermost (background) layer + /// layeredCache.Layers[0].UpdateRuntimeOptions(u => u.WithLeftCacheSize(8.0)); + /// + /// // Inspect options of the outermost (user-facing) layer + /// var outerOptions = layeredCache.Layers[^1].CurrentRuntimeOptions; + /// + /// + public IReadOnlyList> Layers => _layers; + /// /// /// Delegates to the outermost (user-facing) layer. Data is served from that layer's @@ -125,6 +151,23 @@ public async Task WaitForIdleAsync(CancellationToken cancellationToken = default } } + /// + /// + /// Delegates to the outermost (user-facing) layer. To update a specific inner layer, + /// access it via and call + /// on that layer directly. + /// + public void UpdateRuntimeOptions(Action configure) + => _userFacingLayer.UpdateRuntimeOptions(configure); + + /// + /// + /// Returns the runtime options of the outermost (user-facing) layer. To inspect a specific + /// inner layer's options, access it via and read + /// on that layer. + /// + public RuntimeOptionsSnapshot CurrentRuntimeOptions => _userFacingLayer.CurrentRuntimeOptions; + /// /// Disposes all layers from outermost to innermost, releasing all background resources. /// diff --git a/src/SlidingWindowCache/Public/LayeredWindowCacheBuilder.cs b/src/SlidingWindowCache/Public/Cache/LayeredWindowCacheBuilder.cs similarity index 55% rename from src/SlidingWindowCache/Public/LayeredWindowCacheBuilder.cs rename to src/SlidingWindowCache/Public/Cache/LayeredWindowCacheBuilder.cs index de96caa..10abbb0 100644 --- a/src/SlidingWindowCache/Public/LayeredWindowCacheBuilder.cs +++ b/src/SlidingWindowCache/Public/Cache/LayeredWindowCacheBuilder.cs @@ -2,7 +2,7 @@ using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Instrumentation; -namespace SlidingWindowCache.Public; +namespace SlidingWindowCache.Public.Cache; /// /// Fluent builder for constructing a multi-layer (L1/L2/L3/...) cache stack, where each @@ -19,11 +19,17 @@ namespace SlidingWindowCache.Public; /// The type representing the domain of the ranges. Must implement . /// /// +/// Construction: +/// +/// Obtain an instance via , which +/// enables full generic type inference — no explicit type parameters required at the call site. +/// /// Layer Ordering: /// -/// Layers are added from deepest (first call to ) to outermost (last call). -/// The first layer reads from the real passed to -/// . Each subsequent layer reads from the previous layer via an adapter. +/// Layers are added from deepest (first call to ) +/// to outermost (last call). The first layer reads from the real +/// passed to . Each subsequent layer +/// reads from the previous layer via an adapter. /// /// Recommended Configuration Patterns: /// @@ -49,36 +55,43 @@ namespace SlidingWindowCache.Public; /// /// /// -/// Example — Two-Layer Cache: +/// Example — Two-Layer Cache (inline options): +/// +/// await using var cache = WindowCacheBuilder.Layered(realDataSource, domain) +/// .AddLayer(o => o // L2: deep background cache +/// .WithCacheSize(10.0) +/// .WithReadMode(UserCacheReadMode.CopyOnRead) +/// .WithThresholds(0.3)) +/// .AddLayer(o => o // L1: user-facing cache +/// .WithCacheSize(0.5)) +/// .Build(); +/// +/// Example — Two-Layer Cache (pre-built options): /// -/// await using var cache = LayeredWindowCacheBuilder<int, byte[], IntegerFixedStepDomain> -/// .Create(realDataSource, domain) -/// .AddLayer(new WindowCacheOptions( // L2: deep background cache +/// await using var cache = WindowCacheBuilder.Layered(realDataSource, domain) +/// .AddLayer(new WindowCacheOptions( // L2: deep background cache /// leftCacheSize: 10.0, /// rightCacheSize: 10.0, /// readMode: UserCacheReadMode.CopyOnRead, /// leftThreshold: 0.3, /// rightThreshold: 0.3)) -/// .AddLayer(new WindowCacheOptions( // L1: user-facing cache +/// .AddLayer(new WindowCacheOptions( // L1: user-facing cache /// leftCacheSize: 0.5, /// rightCacheSize: 0.5, /// readMode: UserCacheReadMode.Snapshot)) /// .Build(); -/// -/// var result = await cache.GetDataAsync(range, ct); /// /// Example — Three-Layer Cache: /// -/// await using var cache = LayeredWindowCacheBuilder<int, byte[], IntegerFixedStepDomain> -/// .Create(realDataSource, domain) -/// .AddLayer(backgroundOptions) // L3: large distant cache (CopyOnRead, 10x) -/// .AddLayer(midOptions) // L2: medium intermediate cache (CopyOnRead, 2x) -/// .AddLayer(userOptions) // L1: small user-facing cache (Snapshot, 0.5x) +/// await using var cache = WindowCacheBuilder.Layered(realDataSource, domain) +/// .AddLayer(o => o.WithCacheSize(20.0).WithReadMode(UserCacheReadMode.CopyOnRead)) // L3 +/// .AddLayer(o => o.WithCacheSize(5.0).WithReadMode(UserCacheReadMode.CopyOnRead)) // L2 +/// .AddLayer(o => o.WithCacheSize(0.5)) // L1 /// .Build(); /// /// Disposal: /// -/// The returned by +/// The returned by /// owns all created cache layers and disposes them in reverse order (outermost first) when /// is called. /// @@ -92,92 +105,90 @@ public sealed class LayeredWindowCacheBuilder private readonly List _layers = new(); /// - /// Private constructor — use to instantiate. + /// Internal constructor — use + /// to obtain an instance. /// - private LayeredWindowCacheBuilder(IDataSource rootDataSource, TDomain domain) + internal LayeredWindowCacheBuilder(IDataSource rootDataSource, TDomain domain) { _rootDataSource = rootDataSource; _domain = domain; } /// - /// Creates a new rooted at - /// the specified real data source. + /// Adds a cache layer on top of all previously added layers, using a pre-built + /// instance. /// - /// - /// The real (bottom-most) data source from which raw data is fetched. - /// All cache layers sit above this source. + /// + /// Configuration options for this layer. + /// The first call adds the deepest layer (closest to the real data source); + /// each subsequent call adds a layer closer to the user. /// - /// - /// The range domain shared by all layers. + /// + /// Optional per-layer diagnostics. Pass an instance + /// to observe this layer's rebalance and data-source events independently from other layers. + /// When , diagnostics are disabled for this layer. /// - /// A new builder instance. - /// - /// Thrown when is null. - /// + /// This builder instance, for fluent chaining. /// - /// Thrown when is null. + /// Thrown when is null. /// - public static LayeredWindowCacheBuilder Create( - IDataSource dataSource, - TDomain domain) + public LayeredWindowCacheBuilder AddLayer( + WindowCacheOptions options, + ICacheDiagnostics? diagnostics = null) { - if (dataSource == null) - { - throw new ArgumentNullException(nameof(dataSource)); - } - - if (domain is null) + if (options is null) { - throw new ArgumentNullException(nameof(domain)); + throw new ArgumentNullException(nameof(options)); } - return new LayeredWindowCacheBuilder(dataSource, domain); + _layers.Add(new LayerDefinition(options, null, diagnostics)); + return this; } /// - /// Adds a cache layer on top of all previously added layers. + /// Adds a cache layer on top of all previously added layers, configuring options inline + /// via a fluent . /// - /// - /// Configuration options for this layer. + /// + /// A delegate that receives a and applies the desired settings. /// The first call adds the deepest layer (closest to the real data source); /// each subsequent call adds a layer closer to the user. /// /// - /// Optional per-layer diagnostics. Pass an instance - /// to observe this layer's rebalance and data-source events independently from other layers. - /// When , diagnostics are disabled for this layer. + /// Optional per-layer diagnostics. When , diagnostics are disabled for this layer. /// /// This builder instance, for fluent chaining. /// - /// Thrown when is null. + /// Thrown when is null. /// public LayeredWindowCacheBuilder AddLayer( - WindowCacheOptions options, + Action configure, ICacheDiagnostics? diagnostics = null) { - if (options == null) + if (configure is null) { - throw new ArgumentNullException(nameof(options)); + throw new ArgumentNullException(nameof(configure)); } - _layers.Add(new LayerDefinition(options, diagnostics)); + _layers.Add(new LayerDefinition(null, configure, diagnostics)); return this; } /// - /// Builds the layered cache stack and returns a + /// Builds the layered cache stack and returns an /// that owns all created layers. /// /// - /// A whose + /// An whose /// delegates to the outermost layer. + /// The concrete type is , which exposes + /// per-layer access via its property. /// Dispose the returned instance to release all layer resources. /// /// - /// Thrown when no layers have been added via . + /// Thrown when no layers have been added via . /// - public LayeredWindowCache Build() + public IWindowCache Build() { if (_layers.Count == 0) { @@ -187,14 +198,26 @@ public LayeredWindowCache Build() } var caches = new List>(_layers.Count); - IDataSource currentSource = _rootDataSource; + var currentSource = _rootDataSource; foreach (var layer in _layers) { + WindowCacheOptions options; + if (layer.Options is not null) + { + options = layer.Options; + } + else + { + var optionsBuilder = new WindowCacheOptionsBuilder(); + layer.Configure!(optionsBuilder); + options = optionsBuilder.Build(); + } + var cache = new WindowCache( currentSource, _domain, - layer.Options, + options, layer.Diagnostics); caches.Add(cache); @@ -209,5 +232,8 @@ public LayeredWindowCache Build() /// /// Captures the configuration for a single cache layer. /// - private sealed record LayerDefinition(WindowCacheOptions Options, ICacheDiagnostics? Diagnostics); + private sealed record LayerDefinition( + WindowCacheOptions? Options, + Action? Configure, + ICacheDiagnostics? Diagnostics); } diff --git a/src/SlidingWindowCache/Public/WindowCache.cs b/src/SlidingWindowCache/Public/Cache/WindowCache.cs similarity index 80% rename from src/SlidingWindowCache/Public/WindowCache.cs rename to src/SlidingWindowCache/Public/Cache/WindowCache.cs index 208b2a0..3941a83 100644 --- a/src/SlidingWindowCache/Public/WindowCache.cs +++ b/src/SlidingWindowCache/Public/Cache/WindowCache.cs @@ -12,7 +12,7 @@ using SlidingWindowCache.Public.Dto; using SlidingWindowCache.Public.Instrumentation; -namespace SlidingWindowCache.Public; +namespace SlidingWindowCache.Public.Cache; /// /// @@ -38,6 +38,9 @@ public sealed class WindowCache // Internal actors private readonly UserRequestHandler _userRequestHandler; + // Shared runtime options holder — updated via UpdateRuntimeOptions, read by planners and execution controllers + private readonly RuntimeCacheOptionsHolder _runtimeOptionsHolder; + // Activity counter for tracking active intents and executions private readonly AsyncActivityCounter _activityCounter = new(); @@ -77,13 +80,26 @@ public WindowCache( { // Initialize diagnostics (use NoOpDiagnostics if null to avoid null checks in actors) cacheDiagnostics ??= NoOpDiagnostics.Instance; - var cacheStorage = CreateCacheStorage(domain, options); + var cacheStorage = CreateCacheStorage(domain, options.ReadMode); var state = new CacheState(cacheStorage, domain); + // Create the shared runtime options holder from the initial WindowCacheOptions values. + // Planners and execution controllers hold a reference to this holder and read Current + // at invocation time, enabling runtime updates via UpdateRuntimeOptions. + _runtimeOptionsHolder = new RuntimeCacheOptionsHolder( + new RuntimeCacheOptions( + options.LeftCacheSize, + options.RightCacheSize, + options.LeftThreshold, + options.RightThreshold, + options.DebounceDelay + ) + ); + // Initialize all internal actors following corrected execution context model var rebalancePolicy = new NoRebalanceSatisfactionPolicy(); - var rangePlanner = new ProportionalRangePlanner(options, domain); - var noRebalancePlanner = new NoRebalanceRangePlanner(options, domain); + var rangePlanner = new ProportionalRangePlanner(_runtimeOptionsHolder, domain); + var noRebalancePlanner = new NoRebalanceRangePlanner(_runtimeOptionsHolder, domain); var cacheFetcher = new CacheDataExtensionService(dataSource, domain, cacheDiagnostics); var decisionEngine = @@ -95,7 +111,8 @@ public WindowCache( // Strategy selected based on RebalanceQueueCapacity configuration var executionController = CreateExecutionController( executor, - options, + _runtimeOptionsHolder, + options.RebalanceQueueCapacity, cacheDiagnostics, _activityCounter ); @@ -124,17 +141,18 @@ public WindowCache( /// private static IRebalanceExecutionController CreateExecutionController( RebalanceExecutor executor, - WindowCacheOptions options, + RuntimeCacheOptionsHolder optionsHolder, + int? rebalanceQueueCapacity, ICacheDiagnostics cacheDiagnostics, AsyncActivityCounter activityCounter ) { - if (options.RebalanceQueueCapacity == null) + if (rebalanceQueueCapacity == null) { // Unbounded strategy: Task-based serialization (default, recommended for most scenarios) return new TaskBasedRebalanceExecutionController( executor, - options.DebounceDelay, + optionsHolder, cacheDiagnostics, activityCounter ); @@ -143,10 +161,10 @@ AsyncActivityCounter activityCounter // Bounded strategy: Channel-based serialization with backpressure support return new ChannelBasedRebalanceExecutionController( executor, - options.DebounceDelay, + optionsHolder, cacheDiagnostics, activityCounter, - options.RebalanceQueueCapacity.Value + rebalanceQueueCapacity.Value ); } @@ -155,13 +173,13 @@ AsyncActivityCounter activityCounter /// private static ICacheStorage CreateCacheStorage( TDomain domain, - WindowCacheOptions windowCacheOptions - ) => windowCacheOptions.ReadMode switch + UserCacheReadMode readMode + ) => readMode switch { UserCacheReadMode.Snapshot => new SnapshotReadStorage(domain), UserCacheReadMode.CopyOnRead => new CopyOnReadStorage(domain), - _ => throw new ArgumentOutOfRangeException(nameof(windowCacheOptions.ReadMode), - windowCacheOptions.ReadMode, "Unknown read mode.") + _ => throw new ArgumentOutOfRangeException(nameof(readMode), + readMode, "Unknown read mode.") }; /// @@ -236,6 +254,56 @@ public Task WaitForIdleAsync(CancellationToken cancellationToken = default) return _activityCounter.WaitForIdleAsync(cancellationToken); } + /// + /// + /// Implementation: + /// + /// Reads the current snapshot from , applies the builder deltas, + /// validates the merged result (via constructor), then publishes + /// the new snapshot via using a Volatile.Write + /// (release fence). Background threads pick up the new snapshot on their next read cycle. + /// + /// + /// If validation throws, the holder is not updated and the current options remain active. + /// + /// + public void UpdateRuntimeOptions(Action configure) + { + // Check disposal state using Volatile.Read (lock-free) + if (Volatile.Read(ref _disposeState) != 0) + { + throw new ObjectDisposedException( + nameof(WindowCache), + "Cannot update runtime options on a disposed cache."); + } + + // ApplyTo reads the current snapshot, merges deltas, and validates — + // throws if validation fails (holder not updated in that case). + var builder = new RuntimeOptionsUpdateBuilder(); + configure(builder); + var newOptions = builder.ApplyTo(_runtimeOptionsHolder.Current); + + // Publish atomically; background threads see the new snapshot on next read. + _runtimeOptionsHolder.Update(newOptions); + } + + /// + public RuntimeOptionsSnapshot CurrentRuntimeOptions + { + get + { + // Check disposal state using Volatile.Read (lock-free) + if (Volatile.Read(ref _disposeState) != 0) + { + throw new ObjectDisposedException( + nameof(WindowCache), + "Cannot access runtime options on a disposed cache."); + } + + return _runtimeOptionsHolder.Current.ToSnapshot(); + } + } + /// /// Asynchronously disposes the WindowCache and releases all associated resources. /// diff --git a/src/SlidingWindowCache/Public/Cache/WindowCacheBuilder.cs b/src/SlidingWindowCache/Public/Cache/WindowCacheBuilder.cs new file mode 100644 index 0000000..920ff9a --- /dev/null +++ b/src/SlidingWindowCache/Public/Cache/WindowCacheBuilder.cs @@ -0,0 +1,251 @@ +using Intervals.NET.Domain.Abstractions; +using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Instrumentation; + +namespace SlidingWindowCache.Public.Cache; + +/// +/// Non-generic entry point for creating cache instances via fluent builders. +/// Enables full generic type inference so callers do not need to specify type parameters explicitly. +/// +/// +/// Entry Points: +/// +/// +/// +/// — returns a +/// for building a single +/// . +/// +/// +/// +/// +/// — returns a +/// for building a +/// multi-layer . +/// +/// +/// +/// Single-Cache Example: +/// +/// await using var cache = WindowCacheBuilder.For(dataSource, domain) +/// .WithOptions(o => o +/// .WithCacheSize(1.0) +/// .WithThresholds(0.2)) +/// .Build(); +/// +/// Layered-Cache Example: +/// +/// await using var cache = WindowCacheBuilder.Layered(dataSource, domain) +/// .AddLayer(o => o.WithCacheSize(10.0).WithReadMode(UserCacheReadMode.CopyOnRead)) +/// .AddLayer(o => o.WithCacheSize(0.5)) +/// .Build(); +/// +/// +public static class WindowCacheBuilder +{ + /// + /// Creates a for building a single + /// instance. + /// + /// The type representing range boundaries. Must implement . + /// The type of data being cached. + /// The range domain type. Must implement . + /// The data source from which to fetch data. + /// The domain defining range characteristics. + /// A new instance. + /// + /// Thrown when or is null. + /// + public static WindowCacheBuilder For( + IDataSource dataSource, + TDomain domain) + where TRange : IComparable + where TDomain : IRangeDomain + { + if (dataSource is null) + { + throw new ArgumentNullException(nameof(dataSource)); + } + + if (domain is null) + { + throw new ArgumentNullException(nameof(domain)); + } + + return new WindowCacheBuilder(dataSource, domain); + } + + /// + /// Creates a for building a + /// multi-layer cache stack. + /// + /// The type representing range boundaries. Must implement . + /// The type of data being cached. + /// The range domain type. Must implement . + /// The real (bottom-most) data source from which raw data is fetched. + /// The range domain shared by all layers. + /// A new instance. + /// + /// Thrown when or is null. + /// + public static LayeredWindowCacheBuilder Layered( + IDataSource dataSource, + TDomain domain) + where TRange : IComparable + where TDomain : IRangeDomain + { + if (dataSource is null) + { + throw new ArgumentNullException(nameof(dataSource)); + } + + if (domain is null) + { + throw new ArgumentNullException(nameof(domain)); + } + + return new LayeredWindowCacheBuilder(dataSource, domain); + } +} + +/// +/// Fluent builder for constructing a single instance. +/// +/// +/// The type representing range boundaries. Must implement . +/// +/// +/// The type of data being cached. +/// +/// +/// The type representing the domain of the ranges. Must implement . +/// +/// +/// Construction: +/// +/// Obtain an instance via , which enables +/// full generic type inference — no explicit type parameters required at the call site. +/// +/// Options: +/// +/// Call to supply a pre-built +/// instance, or +/// to configure options inline using a fluent . +/// Options are required; throws if they have not been set. +/// +/// Example — Inline Options: +/// +/// await using var cache = WindowCacheBuilder.For(dataSource, domain) +/// .WithOptions(o => o +/// .WithCacheSize(1.0) +/// .WithReadMode(UserCacheReadMode.Snapshot) +/// .WithThresholds(0.2)) +/// .WithDiagnostics(myDiagnostics) +/// .Build(); +/// +/// Example — Pre-built Options: +/// +/// var options = new WindowCacheOptions(1.0, 2.0, UserCacheReadMode.Snapshot, 0.2, 0.2); +/// +/// await using var cache = WindowCacheBuilder.For(dataSource, domain) +/// .WithOptions(options) +/// .Build(); +/// +/// +public sealed class WindowCacheBuilder + where TRange : IComparable + where TDomain : IRangeDomain +{ + private readonly IDataSource _dataSource; + private readonly TDomain _domain; + private WindowCacheOptions? _options; + private Action? _configurePending; + private ICacheDiagnostics? _diagnostics; + + internal WindowCacheBuilder(IDataSource dataSource, TDomain domain) + { + _dataSource = dataSource; + _domain = domain; + } + + /// + /// Configures the cache with a pre-built instance. + /// + /// The options to use. + /// This builder instance, for fluent chaining. + /// + /// Thrown when is null. + /// + public WindowCacheBuilder WithOptions(WindowCacheOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _configurePending = null; + return this; + } + + /// + /// Configures the cache options inline using a fluent . + /// + /// + /// A delegate that receives a and applies the desired settings. + /// + /// This builder instance, for fluent chaining. + /// + /// Thrown when is null. + /// + public WindowCacheBuilder WithOptions( + Action configure) + { + _options = null; + _configurePending = configure ?? throw new ArgumentNullException(nameof(configure)); + return this; + } + + /// + /// Attaches a diagnostics implementation to observe cache events. + /// When not called, is used. + /// + /// The diagnostics implementation to use. + /// This builder instance, for fluent chaining. + /// + /// Thrown when is null. + /// + public WindowCacheBuilder WithDiagnostics(ICacheDiagnostics diagnostics) + { + _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + return this; + } + + /// + /// Builds and returns a configured instance. + /// + /// + /// A fully wired ready for use. + /// Dispose the returned instance (via await using) to release background resources. + /// + /// + /// Thrown when or + /// has not been called. + /// + public IWindowCache Build() + { + var resolvedOptions = _options; + + if (resolvedOptions is null && _configurePending is not null) + { + var optionsBuilder = new WindowCacheOptionsBuilder(); + _configurePending(optionsBuilder); + resolvedOptions = optionsBuilder.Build(); + } + + if (resolvedOptions is null) + { + throw new InvalidOperationException( + "Options must be configured before calling Build(). " + + "Use WithOptions() to supply a WindowCacheOptions instance or configure options inline."); + } + + return new WindowCache(_dataSource, _domain, resolvedOptions, _diagnostics); + } +} diff --git a/src/SlidingWindowCache/Public/WindowCacheDataSourceAdapter.cs b/src/SlidingWindowCache/Public/Cache/WindowCacheDataSourceAdapter.cs similarity index 97% rename from src/SlidingWindowCache/Public/WindowCacheDataSourceAdapter.cs rename to src/SlidingWindowCache/Public/Cache/WindowCacheDataSourceAdapter.cs index 81dbd3f..0d19e33 100644 --- a/src/SlidingWindowCache/Public/WindowCacheDataSourceAdapter.cs +++ b/src/SlidingWindowCache/Public/Cache/WindowCacheDataSourceAdapter.cs @@ -3,7 +3,7 @@ using SlidingWindowCache.Infrastructure.Collections; using SlidingWindowCache.Public.Dto; -namespace SlidingWindowCache.Public; +namespace SlidingWindowCache.Public.Cache; /// /// Adapts an instance to the @@ -62,8 +62,7 @@ namespace SlidingWindowCache.Public; /// /// Typical Usage (via Builder): /// -/// await using var cache = LayeredWindowCacheBuilder<int, byte[], IntegerFixedStepDomain> -/// .Create(realDataSource, domain) +/// await using var cache = WindowCacheBuilder.Layered(realDataSource, domain) /// .AddLayer(new WindowCacheOptions(10.0, 10.0, UserCacheReadMode.CopyOnRead, 0.3, 0.3)) /// .AddLayer(new WindowCacheOptions(0.5, 0.5, UserCacheReadMode.Snapshot)) /// .Build(); diff --git a/src/SlidingWindowCache/Public/Configuration/RuntimeOptionsSnapshot.cs b/src/SlidingWindowCache/Public/Configuration/RuntimeOptionsSnapshot.cs new file mode 100644 index 0000000..87eead8 --- /dev/null +++ b/src/SlidingWindowCache/Public/Configuration/RuntimeOptionsSnapshot.cs @@ -0,0 +1,74 @@ +namespace SlidingWindowCache.Public.Configuration; + +/// +/// A read-only snapshot of the current runtime-updatable cache option values. +/// +/// +/// Purpose: +/// +/// Exposes the current values of the five runtime-updatable options on a live cache instance. +/// Obtained via . +/// +/// Usage: +/// +/// // Inspect current values +/// var current = cache.CurrentRuntimeOptions; +/// Console.WriteLine($"Left: {current.LeftCacheSize}, Right: {current.RightCacheSize}"); +/// +/// // Perform a relative update (e.g. double the left size) +/// var current = cache.CurrentRuntimeOptions; +/// cache.UpdateRuntimeOptions(u => u.WithLeftCacheSize(current.LeftCacheSize * 2)); +/// +/// Snapshot Semantics: +/// +/// This object captures the option values at the moment the property was read. +/// It is not updated if +/// is called afterward — obtain a new snapshot to see updated values. +/// +/// Relationship to RuntimeCacheOptions: +/// +/// This is a public projection of the internal RuntimeCacheOptions snapshot. +/// It contains the same five values but is exposed as a public, user-facing type. +/// +/// +public sealed class RuntimeOptionsSnapshot +{ + internal RuntimeOptionsSnapshot( + double leftCacheSize, + double rightCacheSize, + double? leftThreshold, + double? rightThreshold, + TimeSpan debounceDelay) + { + LeftCacheSize = leftCacheSize; + RightCacheSize = rightCacheSize; + LeftThreshold = leftThreshold; + RightThreshold = rightThreshold; + DebounceDelay = debounceDelay; + } + + /// + /// The coefficient for the left cache size relative to the requested range. + /// + public double LeftCacheSize { get; } + + /// + /// The coefficient for the right cache size relative to the requested range. + /// + public double RightCacheSize { get; } + + /// + /// The left no-rebalance threshold percentage, or null if the left threshold is disabled. + /// + public double? LeftThreshold { get; } + + /// + /// The right no-rebalance threshold percentage, or null if the right threshold is disabled. + /// + public double? RightThreshold { get; } + + /// + /// The debounce delay applied before executing a rebalance. + /// + public TimeSpan DebounceDelay { get; } +} diff --git a/src/SlidingWindowCache/Public/Configuration/RuntimeOptionsUpdateBuilder.cs b/src/SlidingWindowCache/Public/Configuration/RuntimeOptionsUpdateBuilder.cs new file mode 100644 index 0000000..7091691 --- /dev/null +++ b/src/SlidingWindowCache/Public/Configuration/RuntimeOptionsUpdateBuilder.cs @@ -0,0 +1,168 @@ +namespace SlidingWindowCache.Public.Configuration; + +/// +/// Fluent builder for specifying runtime option updates on a live instance. +/// +/// +/// Usage: +/// +/// cache.UpdateRuntimeOptions(update => +/// update.WithLeftCacheSize(2.0) +/// .WithRightCacheSize(3.0) +/// .WithDebounceDelay(TimeSpan.FromMilliseconds(50))); +/// +/// Partial Updates: +/// +/// Only the fields explicitly set on the builder are changed. All other fields retain their current values. +/// For example, calling only WithLeftCacheSize leaves RightCacheSize, thresholds, and +/// DebounceDelay unchanged. +/// +/// Double-Nullable Thresholds: +/// +/// Because LeftThreshold and RightThreshold are double?, three states must be +/// distinguishable for each: +/// +/// Not specified — keep existing value (default) +/// Set to a value — use / +/// Set to null (disabled) — use / +/// +/// +/// Validation: +/// +/// Validation of the merged options (current + deltas) is performed inside +/// IWindowCache.UpdateRuntimeOptions before publishing. If validation fails, an exception is thrown +/// and the current options are left unchanged. +/// +/// "Next Cycle" Semantics: +/// +/// Published updates take effect on the next rebalance decision/execution cycle. In-flight operations +/// continue with the options that were active when they started. +/// +/// +public sealed class RuntimeOptionsUpdateBuilder +{ + private double? _leftCacheSize; + private double? _rightCacheSize; + + // For thresholds we need three states: not set, set to a value, cleared to null. + // We use a bool flag + nullable value pair: flag=false means "not specified", flag=true means "specified". + private bool _leftThresholdSet; + private double? _leftThresholdValue; + private bool _rightThresholdSet; + private double? _rightThresholdValue; + + private TimeSpan? _debounceDelay; + + internal RuntimeOptionsUpdateBuilder() { } + + /// + /// Sets the left cache size coefficient. + /// + /// Must be ≥ 0. + /// This builder, for fluent chaining. + public RuntimeOptionsUpdateBuilder WithLeftCacheSize(double value) + { + _leftCacheSize = value; + return this; + } + + /// + /// Sets the right cache size coefficient. + /// + /// Must be ≥ 0. + /// This builder, for fluent chaining. + public RuntimeOptionsUpdateBuilder WithRightCacheSize(double value) + { + _rightCacheSize = value; + return this; + } + + /// + /// Sets the left no-rebalance threshold to the specified value. + /// + /// Must be in [0, 1]. + /// This builder, for fluent chaining. + public RuntimeOptionsUpdateBuilder WithLeftThreshold(double value) + { + _leftThresholdSet = true; + _leftThresholdValue = value; + return this; + } + + /// + /// Clears (disables) the left no-rebalance threshold by setting it to null. + /// + /// This builder, for fluent chaining. + public RuntimeOptionsUpdateBuilder ClearLeftThreshold() + { + _leftThresholdSet = true; + _leftThresholdValue = null; + return this; + } + + /// + /// Sets the right no-rebalance threshold to the specified value. + /// + /// Must be in [0, 1]. + /// This builder, for fluent chaining. + public RuntimeOptionsUpdateBuilder WithRightThreshold(double value) + { + _rightThresholdSet = true; + _rightThresholdValue = value; + return this; + } + + /// + /// Clears (disables) the right no-rebalance threshold by setting it to null. + /// + /// This builder, for fluent chaining. + public RuntimeOptionsUpdateBuilder ClearRightThreshold() + { + _rightThresholdSet = true; + _rightThresholdValue = null; + return this; + } + + /// + /// Sets the debounce delay applied before executing a rebalance. + /// + /// Any non-negative . disables debouncing. + /// This builder, for fluent chaining. + public RuntimeOptionsUpdateBuilder WithDebounceDelay(TimeSpan value) + { + if (value < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(value), + "DebounceDelay must be non-negative."); + } + + _debounceDelay = value; + return this; + } + + /// + /// Applies the accumulated deltas to and returns a new + /// snapshot. + /// Fields that were not explicitly set on the builder retain their values from . + /// + /// The snapshot to merge deltas onto. + /// A new validated snapshot. + /// Thrown when any merged value fails validation. + /// Thrown when the merged threshold sum exceeds 1.0. + internal Core.State.RuntimeCacheOptions ApplyTo(Core.State.RuntimeCacheOptions current) + { + var leftCacheSize = _leftCacheSize ?? current.LeftCacheSize; + var rightCacheSize = _rightCacheSize ?? current.RightCacheSize; + var leftThreshold = _leftThresholdSet ? _leftThresholdValue : current.LeftThreshold; + var rightThreshold = _rightThresholdSet ? _rightThresholdValue : current.RightThreshold; + var debounceDelay = _debounceDelay ?? current.DebounceDelay; + + return new Core.State.RuntimeCacheOptions( + leftCacheSize, + rightCacheSize, + leftThreshold, + rightThreshold, + debounceDelay + ); + } +} diff --git a/src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs b/src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs index b917629..395ae93 100644 --- a/src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs +++ b/src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs @@ -1,21 +1,25 @@ -namespace SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Core.State; + +namespace SlidingWindowCache.Public.Configuration; /// /// Options for configuring the behavior of the sliding window cache. /// /// -/// Warning — record with-expressions bypass constructor validation: -/// -/// Because is a record type, C# allows creating mutated copies -/// via with-expressions (e.g., options with { LeftThreshold = 0.9, RightThreshold = 0.9 }). -/// with-expressions do NOT invoke the constructor, so all validation guards are bypassed. -/// +/// Immutability: /// -/// Always construct using the primary constructor to ensure -/// all invariants (range sizes ≥ 0, threshold sum ≤ 1.0, queue capacity > 0) are enforced. +/// is a sealed class with get-only properties. All values +/// are validated at construction time and cannot be changed on this object afterwards. +/// Runtime-updatable options (cache sizes, thresholds, debounce delay) may be changed on a live +/// cache instance via . /// +/// Creation-time vs Runtime options: +/// +/// Creation-time only, : determine which concrete classes are instantiated and cannot change after construction. +/// Runtime-updatable, , , , : configure sliding window geometry and execution timing; may be updated on a live cache instance. +/// /// -public record WindowCacheOptions +public sealed class WindowCacheOptions : IEquatable { /// /// Initializes a new instance of the class. @@ -37,7 +41,7 @@ public record WindowCacheOptions /// /// /// Thrown when LeftCacheSize, RightCacheSize, LeftThreshold, RightThreshold is less than 0, - /// or when RebalanceQueueCapacity is less than or equal to 0. + /// when DebounceDelay is negative, or when RebalanceQueueCapacity is less than or equal to 0. /// /// /// Thrown when the sum of LeftThreshold and RightThreshold exceeds 1.0. @@ -52,52 +56,8 @@ public WindowCacheOptions( int? rebalanceQueueCapacity = null ) { - if (leftCacheSize < 0) - { - throw new ArgumentOutOfRangeException(nameof(leftCacheSize), - "LeftCacheSize must be greater than or equal to 0."); - } - - if (rightCacheSize < 0) - { - throw new ArgumentOutOfRangeException(nameof(rightCacheSize), - "RightCacheSize must be greater than or equal to 0."); - } - - if (leftThreshold is < 0) - { - throw new ArgumentOutOfRangeException(nameof(leftThreshold), - "LeftThreshold must be greater than or equal to 0."); - } - - if (rightThreshold is < 0) - { - throw new ArgumentOutOfRangeException(nameof(rightThreshold), - "RightThreshold must be greater than or equal to 0."); - } - - if (leftThreshold is > 1.0) - { - throw new ArgumentOutOfRangeException(nameof(leftThreshold), - "LeftThreshold must not exceed 1.0."); - } - - if (rightThreshold is > 1.0) - { - throw new ArgumentOutOfRangeException(nameof(rightThreshold), - "RightThreshold must not exceed 1.0."); - } - - // Validate that thresholds don't overlap (sum must not exceed 1.0) - if (leftThreshold.HasValue && rightThreshold.HasValue && - (leftThreshold.Value + rightThreshold.Value) > 1.0) - { - throw new ArgumentException( - $"The sum of LeftThreshold ({leftThreshold.Value:F6}) and RightThreshold ({rightThreshold.Value:F6}) " + - $"must not exceed 1.0 (actual sum: {leftThreshold.Value + rightThreshold.Value:F6}). " + - "Thresholds represent percentages of the total cache window that are shrunk from each side. " + - "When their sum exceeds 1.0, the shrinkage zones would overlap, creating an invalid configuration."); - } + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds( + leftCacheSize, rightCacheSize, leftThreshold, rightThreshold); if (rebalanceQueueCapacity is <= 0) { @@ -105,6 +65,12 @@ public WindowCacheOptions( "RebalanceQueueCapacity must be greater than 0 or null."); } + if (debounceDelay.HasValue && debounceDelay.Value < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(debounceDelay), + "DebounceDelay must be non-negative."); + } + LeftCacheSize = leftCacheSize; RightCacheSize = rightCacheSize; ReadMode = readMode; @@ -194,4 +160,40 @@ public WindowCacheOptions( /// /// public int? RebalanceQueueCapacity { get; } + + /// + public bool Equals(WindowCacheOptions? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return LeftCacheSize.Equals(other.LeftCacheSize) + && RightCacheSize.Equals(other.RightCacheSize) + && ReadMode == other.ReadMode + && Nullable.Equals(LeftThreshold, other.LeftThreshold) + && Nullable.Equals(RightThreshold, other.RightThreshold) + && DebounceDelay == other.DebounceDelay + && RebalanceQueueCapacity == other.RebalanceQueueCapacity; + } + + /// + public override bool Equals(object? obj) => Equals(obj as WindowCacheOptions); + + /// + public override int GetHashCode() => + HashCode.Combine(LeftCacheSize, RightCacheSize, ReadMode, LeftThreshold, RightThreshold, DebounceDelay, RebalanceQueueCapacity); + + /// Determines whether two instances are equal. + public static bool operator ==(WindowCacheOptions? left, WindowCacheOptions? right) => + left?.Equals(right) ?? right is null; + + /// Determines whether two instances are not equal. + public static bool operator !=(WindowCacheOptions? left, WindowCacheOptions? right) => !(left == right); } diff --git a/src/SlidingWindowCache/Public/Configuration/WindowCacheOptionsBuilder.cs b/src/SlidingWindowCache/Public/Configuration/WindowCacheOptionsBuilder.cs new file mode 100644 index 0000000..11a169f --- /dev/null +++ b/src/SlidingWindowCache/Public/Configuration/WindowCacheOptionsBuilder.cs @@ -0,0 +1,240 @@ +namespace SlidingWindowCache.Public.Configuration; + +/// +/// Fluent builder for constructing instances with a clean, +/// discoverable API. +/// +/// +/// Purpose: +/// +/// Provides a fluent alternative to the constructor, especially +/// useful for inline configuration via and +/// . +/// +/// Required Fields: +/// +/// and (or a convenience overload +/// such as ) must be called before . +/// All other fields have sensible defaults. +/// +/// Defaults: +/// +/// ReadMode: +/// LeftThreshold / RightThreshold: null (disabled) +/// DebounceDelay: 100 ms (applied by ) +/// RebalanceQueueCapacity: null (unbounded task-based) +/// +/// Standalone Usage: +/// +/// var options = new WindowCacheOptionsBuilder() +/// .WithCacheSize(1.0) +/// .WithReadMode(UserCacheReadMode.Snapshot) +/// .WithThresholds(0.2) +/// .Build(); +/// +/// Inline Usage (via cache builder): +/// +/// var cache = WindowCacheBuilder.For(dataSource, domain) +/// .WithOptions(o => o +/// .WithCacheSize(1.0) +/// .WithThresholds(0.2)) +/// .Build(); +/// +/// +public sealed class WindowCacheOptionsBuilder +{ + private double? _leftCacheSize; + private double? _rightCacheSize; + private UserCacheReadMode _readMode = UserCacheReadMode.Snapshot; + private double? _leftThreshold; + private double? _rightThreshold; + private bool _leftThresholdSet; + private bool _rightThresholdSet; + private TimeSpan? _debounceDelay; + private int? _rebalanceQueueCapacity; + + /// + /// Initializes a new instance of the class. + /// + public WindowCacheOptionsBuilder() { } + + /// + /// Sets the left cache size coefficient. + /// + /// + /// Multiplier of the requested range size for the left buffer. Must be >= 0. + /// A value of 0 disables left-side caching. + /// + /// This builder instance, for fluent chaining. + public WindowCacheOptionsBuilder WithLeftCacheSize(double value) + { + _leftCacheSize = value; + return this; + } + + /// + /// Sets the right cache size coefficient. + /// + /// + /// Multiplier of the requested range size for the right buffer. Must be >= 0. + /// A value of 0 disables right-side caching. + /// + /// This builder instance, for fluent chaining. + public WindowCacheOptionsBuilder WithRightCacheSize(double value) + { + _rightCacheSize = value; + return this; + } + + /// + /// Sets both left and right cache size coefficients to the same value. + /// + /// + /// Multiplier applied symmetrically to both left and right buffers. Must be >= 0. + /// + /// This builder instance, for fluent chaining. + public WindowCacheOptionsBuilder WithCacheSize(double value) + { + _leftCacheSize = value; + _rightCacheSize = value; + return this; + } + + /// + /// Sets left and right cache size coefficients to different values. + /// + /// Multiplier for the left buffer. Must be >= 0. + /// Multiplier for the right buffer. Must be >= 0. + /// This builder instance, for fluent chaining. + public WindowCacheOptionsBuilder WithCacheSize(double left, double right) + { + _leftCacheSize = left; + _rightCacheSize = right; + return this; + } + + /// + /// Sets the read mode that determines how materialized cache data is exposed to users. + /// Default is . + /// + /// The read mode to use. + /// This builder instance, for fluent chaining. + public WindowCacheOptionsBuilder WithReadMode(UserCacheReadMode value) + { + _readMode = value; + return this; + } + + /// + /// Sets the left no-rebalance threshold percentage. + /// + /// + /// Percentage of total cache window size. Must be >= 0. + /// The sum of left and right thresholds must not exceed 1.0. + /// + /// This builder instance, for fluent chaining. + public WindowCacheOptionsBuilder WithLeftThreshold(double value) + { + _leftThresholdSet = true; + _leftThreshold = value; + return this; + } + + /// + /// Sets the right no-rebalance threshold percentage. + /// + /// + /// Percentage of total cache window size. Must be >= 0. + /// The sum of left and right thresholds must not exceed 1.0. + /// + /// This builder instance, for fluent chaining. + public WindowCacheOptionsBuilder WithRightThreshold(double value) + { + _rightThresholdSet = true; + _rightThreshold = value; + return this; + } + + /// + /// Sets both left and right no-rebalance threshold percentages to the same value. + /// + /// + /// Percentage applied symmetrically. Must be >= 0. + /// The combined sum (i.e. 2 × ) must not exceed 1.0. + /// + /// This builder instance, for fluent chaining. + public WindowCacheOptionsBuilder WithThresholds(double value) + { + _leftThresholdSet = true; + _leftThreshold = value; + _rightThresholdSet = true; + _rightThreshold = value; + return this; + } + + /// + /// Sets the debounce delay applied before executing a rebalance. + /// Default is 100 ms. + /// + /// + /// Any non-negative . disables debouncing. + /// + /// This builder instance, for fluent chaining. + public WindowCacheOptionsBuilder WithDebounceDelay(TimeSpan value) + { + if (value < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(value), + "DebounceDelay must be non-negative."); + } + + _debounceDelay = value; + return this; + } + + /// + /// Sets the rebalance execution queue capacity, selecting the bounded channel-based strategy. + /// Default is null (unbounded task-based serialization). + /// + /// The bounded channel capacity. Must be >= 1. + /// This builder instance, for fluent chaining. + public WindowCacheOptionsBuilder WithRebalanceQueueCapacity(int value) + { + _rebalanceQueueCapacity = value; + return this; + } + + /// + /// Builds a instance from the configured values. + /// + /// A validated instance. + /// + /// Thrown when neither / nor + /// a overload has been called. + /// + /// + /// Thrown when any value fails validation (negative sizes, thresholds, or queue capacity <= 0). + /// + /// + /// Thrown when the sum of left and right thresholds exceeds 1.0. + /// + public WindowCacheOptions Build() + { + if (_leftCacheSize is null || _rightCacheSize is null) + { + throw new InvalidOperationException( + "LeftCacheSize and RightCacheSize must be configured. " + + "Use WithLeftCacheSize()/WithRightCacheSize() or WithCacheSize() to set them."); + } + + return new WindowCacheOptions( + _leftCacheSize.Value, + _rightCacheSize.Value, + _readMode, + _leftThresholdSet ? _leftThreshold : null, + _rightThresholdSet ? _rightThreshold : null, + _debounceDelay, + _rebalanceQueueCapacity + ); + } +} diff --git a/src/SlidingWindowCache/Public/Dto/CacheInteraction.cs b/src/SlidingWindowCache/Public/Dto/CacheInteraction.cs new file mode 100644 index 0000000..f43153c --- /dev/null +++ b/src/SlidingWindowCache/Public/Dto/CacheInteraction.cs @@ -0,0 +1,50 @@ +namespace SlidingWindowCache.Public.Dto; + +/// +/// Describes how a data request was fulfilled relative to the current cache state. +/// +/// +/// +/// is reported on every returned +/// by . It tells the caller whether the +/// requested range was served entirely from the cache, assembled from a mix of cached and live +/// data-source data, or fetched entirely from the data source with no cache participation. +/// +/// Relationship to consistency modes: +/// +/// The value is the foundation for the opt-in hybrid consistency extension method +/// GetDataAndWaitOnMissAsync: that method awaits background rebalance completion only when the +/// interaction is or , ensuring the cache is warm around +/// the new position before returning. A returns immediately (eventual consistency). +/// +/// Diagnostics relationship: +/// +/// The same classification is reported through the optional ICacheDiagnostics callbacks +/// (UserRequestFullCacheHit, UserRequestPartialCacheHit, UserRequestFullCacheMiss). +/// provides per-request, programmatic access to the same information +/// without requiring a diagnostics implementation. +/// +/// +public enum CacheInteraction +{ + /// + /// The requested range was fully contained within the current cache. No IDataSource call was + /// made on the user path. This is the fastest path and indicates the cache is well-positioned + /// relative to the current access pattern. + /// + FullHit, + + /// + /// The requested range partially overlapped the current cache. The cached portion was served from + /// memory; the missing segments were fetched from IDataSource on the user path. + /// Background rebalance has been triggered to expand the cache around the new position. + /// + PartialHit, + + /// + /// The requested range had no overlap with the current cache, or the cache was uninitialized + /// (cold start). The entire range was fetched directly from IDataSource on the user path. + /// Background rebalance has been triggered to build or replace the cache around the new position. + /// + FullMiss, +} diff --git a/src/SlidingWindowCache/Public/Dto/RangeResult.cs b/src/SlidingWindowCache/Public/Dto/RangeResult.cs index 395909b..a00915d 100644 --- a/src/SlidingWindowCache/Public/Dto/RangeResult.cs +++ b/src/SlidingWindowCache/Public/Dto/RangeResult.cs @@ -3,39 +3,89 @@ namespace SlidingWindowCache.Public.Dto; /// -/// Represents the result of a cache data request, containing the actual available range and data. +/// Represents the result of a cache data request, containing the actual available range, data, +/// and a description of how the request was fulfilled relative to the cache state. /// /// The type representing range boundaries. /// The type of cached data. /// -/// The actual range of data available. -/// Null if no data is available for the requested range. +/// The actual range of data available. +/// Null if no data is available for the requested range (physical boundary miss). /// May be a subset of the requested range if data is truncated at boundaries. /// /// /// The data for the available range. -/// Empty if Range is null. +/// Empty if is null. +/// +/// +/// Describes how the request was fulfilled relative to the current cache state. +/// See for the three possible values and their semantics. +/// This field is the foundation for the opt-in hybrid consistency mode: +/// GetDataAndWaitOnMissAsync awaits idle only when this is +/// or . /// /// -/// ActualRange Semantics: +/// Range Semantics: /// Range = RequestedRange ∩ PhysicallyAvailableDataRange -/// When DataSource has bounded data (e.g., database with min/max IDs), -/// Range indicates what portion of the request was actually available. +/// When the data source has bounded data (e.g., a database with min/max IDs), +/// indicates what portion of the request was actually available. +/// Constructor Visibility: +/// +/// The primary constructor is internal. instances +/// are produced exclusively by UserRequestHandler and are consumed publicly. This prevents +/// external code from constructing results with inconsistent field combinations. +/// /// Example Usage: /// /// var result = await cache.GetDataAsync(Range.Closed(50, 600), ct); /// if (result.Range.HasValue) /// { /// Console.WriteLine($"Available: {result.Range.Value}"); +/// Console.WriteLine($"Cache interaction: {result.CacheInteraction}"); /// ProcessData(result.Data); /// } /// else /// { -/// Console.WriteLine("No data available"); +/// Console.WriteLine("No data available for the requested range."); /// } /// /// -public sealed record RangeResult( - Range? Range, - ReadOnlyMemory Data -) where TRange : IComparable; +public sealed record RangeResult + where TRange : IComparable +{ + /// + /// Initializes a new . + /// + /// The actual available range, or null for a physical boundary miss. + /// The data for the available range. + /// How the request was fulfilled relative to cache state. + internal RangeResult(Range? range, ReadOnlyMemory data, CacheInteraction cacheInteraction) + { + Range = range; + Data = data; + CacheInteraction = cacheInteraction; + } + + /// + /// The actual range of data available. + /// Null if no data is available for the requested range (physical boundary miss). + /// May be a subset of the requested range if data is truncated at boundaries. + /// + public Range? Range { get; internal init; } + + /// + /// The data for the available range. Empty if is null. + /// + public ReadOnlyMemory Data { get; internal init; } + + /// + /// Describes how this request was fulfilled relative to the current cache state. + /// + /// + /// Use this property to implement conditional consistency strategies. + /// For example, GetDataAndWaitOnMissAsync awaits background rebalance completion + /// only when this value is or + /// , ensuring the cache is warm before returning. + /// + public CacheInteraction CacheInteraction { get; internal init; } +} diff --git a/src/SlidingWindowCache/Public/Extensions/WindowCacheConsistencyExtensions.cs b/src/SlidingWindowCache/Public/Extensions/WindowCacheConsistencyExtensions.cs new file mode 100644 index 0000000..8512190 --- /dev/null +++ b/src/SlidingWindowCache/Public/Extensions/WindowCacheConsistencyExtensions.cs @@ -0,0 +1,423 @@ +using Intervals.NET; +using Intervals.NET.Domain.Abstractions; +using SlidingWindowCache.Public.Dto; + +namespace SlidingWindowCache.Public.Extensions; + +/// +/// Extension methods for providing +/// opt-in consistency modes on top of the default eventual consistency model. +/// +/// +/// Three Consistency Modes: +/// +/// +/// Eventual (default) +/// returns data immediately. The cache converges in the background without blocking the caller. +/// Suitable for sequential access patterns and hot paths. +/// +/// +/// Hybrid +/// returns immediately on a full cache hit; waits for rebalance on a partial hit or full miss. +/// Suitable for random access patterns where the requested range may be far from the current +/// cache position, ensuring the cache is warm for subsequent nearby requests. +/// +/// +/// Strong +/// always waits for the cache to reach an idle state before returning. +/// Suitable for testing, cold-start synchronization, and diagnostics. +/// +/// +/// Cancellation Graceful Degradation: +/// +/// Both and +/// degrade gracefully on +/// cancellation during the idle wait: if WaitForIdleAsync throws +/// , the already-obtained +/// is returned instead of propagating the exception. +/// The background rebalance continues unaffected. This preserves valid user data even when the +/// caller no longer needs to wait for convergence. +/// Other exceptions from WaitForIdleAsync (e.g., ) +/// still propagate normally. +/// +/// Serialized Access Requirement for Hybrid and Strong Modes: +/// +/// and +/// provide their semantic guarantees +/// — "cache is warm for my next call" — only under serialized (one-at-a-time) access. +/// +/// +/// Under parallel access (multiple threads concurrently calling these methods on the same cache +/// instance), the methods remain fully safe: no crashes, no hangs, no data corruption. +/// However, the consistency guarantee may degrade: +/// +/// +/// Due to the AsyncActivityCounter's "was idle at some point" semantics (Invariant H.49), +/// a thread that calls WaitForIdleAsync during the window between +/// Interlocked.Increment (counter 0→1) and the subsequent Volatile.Write of the +/// new TaskCompletionSource will observe the previous (already-completed) TCS and return +/// immediately, even though work is in-flight. +/// +/// +/// Under "latest intent wins" semantics in the intent pipeline, one thread's rebalance may be +/// superseded by another's, so a thread may wait for a different rebalance than the one triggered +/// by its own request. +/// +/// +/// These behaviours are consistent with the WindowCache design model: one logical consumer +/// per cache instance with coherent, non-concurrent access patterns. +/// +/// +public static class WindowCacheConsistencyExtensions +{ + /// + /// Retrieves data for the specified range and — if the request resulted in a cache miss or + /// partial cache hit — waits for the cache to reach an idle state before returning. + /// This provides hybrid consistency semantics. + /// + /// + /// The type representing the range boundaries. Must implement . + /// + /// + /// The type of data being cached. + /// + /// + /// The type representing the domain of the ranges. Must implement . + /// + /// + /// The cache instance to retrieve data from. + /// + /// + /// The range for which to retrieve data. + /// + /// + /// A cancellation token to cancel the operation. Passed to both + /// and, when applicable, + /// . + /// Cancelling the token during the idle wait stops the wait and causes the method + /// to return the already-obtained gracefully + /// (eventual consistency degradation). The background rebalance continues to completion. + /// + /// + /// A task that represents the asynchronous operation. The task result contains a + /// with the actual available range, data, and + /// , identical to what + /// returns directly. + /// The task completes immediately on a full cache hit; on a partial hit or full miss the + /// task completes only after the cache has reached an idle state (or immediately if the + /// idle wait is cancelled). + /// + /// + /// Motivation — Avoiding Double Miss on Random Access: + /// + /// When the default eventual consistency model is used and the requested range is far from + /// the current cache position (a "jump"), the caller receives correct data but the cache is + /// still converging in the background. If the caller immediately makes another nearby request, + /// that second request may encounter another cache miss before rebalance has completed. + /// + /// + /// This method eliminates the "double miss" problem: by waiting for idle on a miss, the + /// cache is guaranteed to be warm around the new position before the method returns, so + /// subsequent nearby requests will hit the cache. + /// + /// Behavior by Cache Interaction Type: + /// + /// + /// — returns immediately (eventual consistency). + /// The cache is already correctly positioned; no idle wait is needed. + /// + /// + /// — awaits + /// before returning. + /// Missing segments were already fetched from IDataSource on the user path; the wait + /// ensures the background rebalance fully populates the cache around the new position. + /// + /// + /// — awaits + /// before returning. + /// The entire range was fetched from IDataSource (cold start or non-intersecting jump); + /// the wait ensures the background rebalance builds the cache window around the new position. + /// + /// + /// Idle Semantics (Invariant H.49): + /// + /// The idle wait uses "was idle at some point" semantics inherited from + /// . This is sufficient for + /// the hybrid consistency use case: after the await, the cache has converged at least once since + /// the request. New activity may begin immediately after, but the next nearby request will find + /// a warm cache. + /// + /// Debounce Latency Note: + /// + /// When the idle wait is triggered, the caller pays the full rebalance latency including any + /// configured debounce delay. On a miss path, the caller has already paid an IDataSource + /// round-trip; the additional wait is proportionally less significant. + /// + /// Serialized Access Requirement: + /// + /// This method provides its "cache will be warm for the next call" guarantee only under + /// serialized (one-at-a-time) access. See class remarks + /// for a detailed explanation of parallel access behaviour. + /// + /// When to Use: + /// + /// + /// Random access patterns where the requested range may be far from the current cache position + /// and the caller will immediately make subsequent nearby requests. + /// + /// + /// Paging or viewport scenarios where a "jump" to a new position should result in a warm + /// cache before continuing to scroll or page. + /// + /// + /// When NOT to Use: + /// + /// + /// Sequential access hot paths: if the access pattern is sequential and the cache is + /// well-positioned, full hits will dominate and this method behaves identically to + /// with no overhead. + /// However, on the rare miss case it will add latency that is unnecessary for sequential access. + /// Use the default eventual consistency model instead. + /// + /// + /// Tests or diagnostics requiring unconditional idle wait — prefer + /// (strong consistency). + /// + /// + /// Exception Propagation: + /// + /// + /// If GetDataAsync throws (e.g., , + /// ), the exception propagates immediately and + /// WaitForIdleAsync is never called. + /// + /// + /// If WaitForIdleAsync throws , the + /// already-obtained result is returned (graceful degradation to eventual consistency). + /// The background rebalance continues; only the wait is abandoned. + /// + /// + /// If WaitForIdleAsync throws any other exception (e.g., + /// , ), + /// the exception propagates normally. + /// + /// + /// Cancellation Graceful Degradation: + /// + /// Cancelling during the idle wait (after + /// GetDataAsync has already succeeded) does not discard the obtained data. + /// The method catches from WaitForIdleAsync + /// and returns the that was already retrieved, + /// degrading to eventual consistency semantics for this call. + /// + /// Example: + /// + /// // Hybrid consistency: only waits on miss/partial hit, returns immediately on full hit + /// var result = await cache.GetDataAndWaitOnMissAsync( + /// Range.Closed(5000, 5100), // Far from current cache position — full miss + /// cancellationToken); + /// + /// // Cache is now warm around [5000, 5100]. + /// // The next nearby request will be a full cache hit. + /// Console.WriteLine($"Interaction: {result.CacheInteraction}"); // FullMiss + /// + /// var nextResult = await cache.GetDataAsync( + /// Range.Closed(5050, 5150), // Within rebalanced cache — full hit + /// cancellationToken); + /// + /// + public static async ValueTask> GetDataAndWaitOnMissAsync( + this IWindowCache cache, + Range requestedRange, + CancellationToken cancellationToken = default) + where TRange : IComparable + where TDomain : IRangeDomain + { + var result = await cache.GetDataAsync(requestedRange, cancellationToken); + + // Wait for idle only on cache miss scenarios (full miss or partial hit) to ensure + // the cache is rebalanced around the new position before returning. + // Full cache hits return immediately — the cache is already correctly positioned. + // If the idle wait is cancelled, return the already-obtained result gracefully + // (degrade to eventual consistency) rather than discarding valid data. + if (result.CacheInteraction != CacheInteraction.FullHit) + { + try + { + await cache.WaitForIdleAsync(cancellationToken); + } + catch (OperationCanceledException) + { + // Graceful degradation: cancellation during the idle wait does not + // discard the data already obtained from GetDataAsync. The background + // rebalance continues; we simply stop waiting for it. + } + } + + return result; + } + + /// + /// Retrieves data for the specified range and waits for the cache to reach an idle + /// state before returning, providing strong consistency semantics. + /// + /// + /// The type representing the range boundaries. Must implement . + /// + /// + /// The type of data being cached. + /// + /// + /// The type representing the domain of the ranges. Must implement . + /// + /// + /// The cache instance to retrieve data from. + /// + /// + /// The range for which to retrieve data. + /// + /// + /// A cancellation token to cancel the operation. Passed to both + /// and + /// . + /// Cancelling the token during the idle wait stops the wait and causes the method + /// to return the already-obtained gracefully + /// (eventual consistency degradation). The background rebalance continues to completion. + /// + /// + /// A task that represents the asynchronous operation. The task result contains a + /// with the actual available range and data, + /// identical to what returns. + /// The task completes only after the cache has reached an idle state (no pending intent, + /// no executing rebalance). + /// + /// + /// Default vs. Strong Consistency: + /// + /// By default, returns data + /// immediately under an eventual consistency model: the user always receives correct data, + /// but the cache window may still be converging toward its optimal configuration in the background. + /// + /// + /// This method extends that with an unconditional wait: it calls GetDataAsync first + /// (user data returned immediately from cache or IDataSource), then always awaits + /// before returning — + /// regardless of whether the request was a full hit, partial hit, or full miss. + /// + /// + /// For a conditional wait that only blocks on misses, prefer + /// (hybrid consistency). + /// + /// Composition: + /// + /// // Equivalent to: + /// var result = await cache.GetDataAsync(requestedRange, cancellationToken); + /// await cache.WaitForIdleAsync(cancellationToken); + /// return result; + /// + /// When to Use: + /// + /// + /// When the caller needs to assert or inspect the cache geometry after the request + /// (e.g., verifying that a rebalance occurred or that the window has shifted). + /// + /// + /// Cold start synchronization: waiting for the initial rebalance to complete before + /// proceeding with subsequent operations. + /// + /// + /// Integration tests that need deterministic cache state before making assertions. + /// + /// + /// When NOT to Use: + /// + /// + /// Hot paths: the idle wait adds latency proportional to the rebalance execution time + /// (debounce delay + data fetching + cache update). For normal usage, prefer the default + /// eventual consistency model via . + /// + /// + /// Rapid sequential requests: calling this method back-to-back means each call waits + /// for the prior rebalance to complete, eliminating the debounce and work-avoidance + /// benefits of the cache. + /// + /// + /// Random access patterns where waiting only on misses is sufficient — prefer + /// (hybrid consistency). + /// + /// + /// Idle Semantics (Invariant H.49): + /// + /// The idle wait uses "was idle at some point" semantics inherited from + /// . This is sufficient + /// for the strong consistency use cases above: after the await, the cache has converged at + /// least once since the request. New activity may begin immediately after, but the + /// cache state observed at the idle point reflects the completed rebalance. + /// + /// Serialized Access Requirement: + /// + /// This method provides its consistency guarantee only under serialized (one-at-a-time) access. + /// See class remarks for a detailed explanation of + /// parallel access behaviour. + /// + /// Exception Propagation: + /// + /// + /// If GetDataAsync throws (e.g., , + /// ), the exception propagates immediately and + /// WaitForIdleAsync is never called. + /// + /// + /// If WaitForIdleAsync throws , the + /// already-obtained result is returned (graceful degradation to eventual consistency). + /// The background rebalance continues; only the wait is abandoned. + /// + /// + /// If WaitForIdleAsync throws any other exception (e.g., + /// , ), + /// the exception propagates normally. + /// + /// + /// Cancellation Graceful Degradation: + /// + /// Cancelling during the idle wait (after + /// GetDataAsync has already succeeded) does not discard the obtained data. + /// The method catches from WaitForIdleAsync + /// and returns the that was already retrieved, + /// degrading to eventual consistency semantics for this call. + /// + /// Example: + /// + /// // Strong consistency: returns only after cache has converged + /// var result = await cache.GetDataAndWaitForIdleAsync( + /// Range.Closed(100, 200), + /// cancellationToken); + /// + /// // Cache geometry is now fully converged — safe to inspect or assert + /// if (result.Range.HasValue) + /// ProcessData(result.Data); + /// + /// + public static async ValueTask> GetDataAndWaitForIdleAsync( + this IWindowCache cache, + Range requestedRange, + CancellationToken cancellationToken = default) + where TRange : IComparable + where TDomain : IRangeDomain + { + var result = await cache.GetDataAsync(requestedRange, cancellationToken); + + try + { + await cache.WaitForIdleAsync(cancellationToken); + } + catch (OperationCanceledException) + { + // Graceful degradation: cancellation during the idle wait does not + // discard the data already obtained from GetDataAsync. The background + // rebalance continues; we simply stop waiting for it. + } + + return result; + } +} diff --git a/src/SlidingWindowCache/Public/FuncDataSource.cs b/src/SlidingWindowCache/Public/FuncDataSource.cs new file mode 100644 index 0000000..7848ced --- /dev/null +++ b/src/SlidingWindowCache/Public/FuncDataSource.cs @@ -0,0 +1,83 @@ +using Intervals.NET; +using SlidingWindowCache.Public.Dto; + +namespace SlidingWindowCache.Public; + +/// +/// An implementation that delegates +/// to a caller-supplied +/// asynchronous function, enabling data sources to be created inline without +/// defining a dedicated class. +/// +/// +/// The type representing range boundaries. Must implement . +/// +/// +/// The type of data being fetched. +/// +/// +/// Purpose: +/// +/// Use when the fetch logic is simple enough +/// to express as a lambda or method reference and a full +/// subclass would add unnecessary ceremony. +/// +/// Batch Fetching: +/// +/// The batch FetchAsync overload is not overridden here; it falls through to the +/// default implementation, which parallelizes +/// calls to the single-range delegate via Task.WhenAll. +/// +/// Example — unbounded integer source: +/// +/// IDataSource<int, string> source = new FuncDataSource<int, string>( +/// async (range, ct) => +/// { +/// var data = await myService.QueryAsync(range, ct); +/// return new RangeChunk<int, string>(range, data); +/// }); +/// +/// Example — bounded source with null-range contract: +/// +/// IDataSource<int, string> bounded = new FuncDataSource<int, string>( +/// async (range, ct) => +/// { +/// var available = range.Intersect(Range.Closed(minId, maxId)); +/// if (available is null) +/// return new RangeChunk<int, string>(null, []); +/// +/// var data = await myService.QueryAsync(available, ct); +/// return new RangeChunk<int, string>(available, data); +/// }); +/// +/// +public sealed class FuncDataSource : IDataSource + where TRange : IComparable +{ + private readonly Func, CancellationToken, Task>> _fetchFunc; + + /// + /// Initializes a new with the specified fetch delegate. + /// + /// + /// The asynchronous function invoked for every single-range fetch. Must not be . + /// The function receives the requested and a + /// , and must return a + /// that satisfies the boundary contract. + /// + /// + /// Thrown when is . + /// + public FuncDataSource( + Func, CancellationToken, Task>> fetchFunc) + { + ArgumentNullException.ThrowIfNull(fetchFunc); + _fetchFunc = fetchFunc; + } + + /// + public Task> FetchAsync( + Range range, + CancellationToken cancellationToken) + => _fetchFunc(range, cancellationToken); +} diff --git a/src/SlidingWindowCache/Public/IDataSource.cs b/src/SlidingWindowCache/Public/IDataSource.cs index b0b7a5e..e1a5765 100644 --- a/src/SlidingWindowCache/Public/IDataSource.cs +++ b/src/SlidingWindowCache/Public/IDataSource.cs @@ -15,16 +15,30 @@ namespace SlidingWindowCache.Public; /// The type of data being fetched. /// /// -/// Basic Implementation: +/// Quick Setup — FuncDataSource: +/// +/// Use to create a data source from a delegate +/// without defining a class: +/// +/// +/// IDataSource<int, MyData> source = new FuncDataSource<int, MyData>( +/// async (range, ct) => +/// { +/// var data = await Database.QueryAsync(range, ct); +/// return new RangeChunk<int, MyData>(range, data); +/// }); +/// +/// Full Class Implementation: /// /// public class MyDataSource : IDataSource<int, MyData> /// { -/// public async Task<IEnumerable<MyData>> FetchAsync( +/// public async Task<RangeChunk<int, MyData>> FetchAsync( /// Range<int> range, /// CancellationToken ct) /// { /// // Fetch data for single range -/// return await Database.QueryAsync(range, ct); +/// var data = await Database.QueryAsync(range, ct); +/// return new RangeChunk<int, MyData>(range, data); /// } /// /// // Batch method uses default parallel implementation automatically diff --git a/src/SlidingWindowCache/Public/IWindowCache.cs b/src/SlidingWindowCache/Public/IWindowCache.cs index eaf3d3b..e99e984 100644 --- a/src/SlidingWindowCache/Public/IWindowCache.cs +++ b/src/SlidingWindowCache/Public/IWindowCache.cs @@ -1,5 +1,7 @@ using Intervals.NET; using Intervals.NET.Domain.Abstractions; +using SlidingWindowCache.Public.Cache; +using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Dto; namespace SlidingWindowCache.Public; @@ -125,4 +127,81 @@ ValueTask> GetDataAsync( /// /// Task WaitForIdleAsync(CancellationToken cancellationToken = default); + + /// + /// Atomically updates one or more runtime configuration values on the live cache instance. + /// + /// + /// A delegate that receives a and applies the desired changes. + /// Only the fields explicitly set on the builder are changed; all others retain their current values. + /// + /// + /// Partial Updates: + /// + /// You only need to specify the fields you want to change: + /// + /// + /// cache.UpdateRuntimeOptions(update => + /// update.WithLeftCacheSize(2.0) + /// .WithDebounceDelay(TimeSpan.FromMilliseconds(50))); + /// + /// Threshold Handling: + /// + /// Because thresholds are double?, use explicit clear methods to set a threshold to null: + /// + /// + /// cache.UpdateRuntimeOptions(update => update.ClearLeftThreshold()); + /// + /// Validation: + /// + /// The merged options are validated before publishing. If validation fails (e.g. negative cache size, + /// threshold sum > 1.0), an exception is thrown and the current options are left unchanged. + /// + /// "Next Cycle" Semantics: + /// + /// Updates take effect on the next rebalance decision/execution cycle. In-flight rebalance operations + /// continue with the options that were active when they started. + /// + /// Thread Safety: + /// + /// This method is thread-safe. Concurrent calls follow last-writer-wins semantics, which is acceptable + /// for configuration updates where the latest user intent should prevail. + /// + /// + /// Thrown when called on a disposed cache instance. + /// Thrown when any updated value fails validation. + /// Thrown when the merged threshold sum exceeds 1.0. + void UpdateRuntimeOptions(Action configure); + + /// + /// Gets a snapshot of the current runtime-updatable option values on this cache instance. + /// + /// + /// Snapshot Semantics: + /// + /// The returned captures the option values at the moment + /// this property is read. It is not updated if + /// is called afterward — obtain a new snapshot to see + /// updated values. + /// + /// Usage: + /// + /// // Inspect current options + /// var current = cache.CurrentRuntimeOptions; + /// Console.WriteLine($"LeftCacheSize={current.LeftCacheSize}"); + /// + /// // Perform a relative update (e.g. double the left cache size) + /// var snapshot = cache.CurrentRuntimeOptions; + /// cache.UpdateRuntimeOptions(u => u.WithLeftCacheSize(snapshot.LeftCacheSize * 2)); + /// + /// Layered Caches: + /// + /// On a , this property returns the + /// options of the outermost (user-facing) layer. To inspect the options of a specific inner + /// layer, access that layer directly via + /// . + /// + /// + /// Thrown when called on a disposed cache instance. + RuntimeOptionsSnapshot CurrentRuntimeOptions { get; } } diff --git a/src/SlidingWindowCache/Public/WindowCacheExtensions.cs b/src/SlidingWindowCache/Public/WindowCacheExtensions.cs deleted file mode 100644 index 6d2eaba..0000000 --- a/src/SlidingWindowCache/Public/WindowCacheExtensions.cs +++ /dev/null @@ -1,136 +0,0 @@ -using Intervals.NET; -using Intervals.NET.Domain.Abstractions; -using SlidingWindowCache.Public.Dto; - -namespace SlidingWindowCache.Public; - -/// -/// Extension methods for providing -/// opt-in strong consistency mode on top of the default eventual consistency model. -/// -public static class WindowCacheExtensions -{ - /// - /// Retrieves data for the specified range and waits for the cache to reach an idle - /// state before returning, providing strong consistency semantics. - /// - /// - /// The type representing the range boundaries. Must implement . - /// - /// - /// The type of data being cached. - /// - /// - /// The type representing the domain of the ranges. Must implement . - /// - /// - /// The cache instance to retrieve data from. - /// - /// - /// The range for which to retrieve data. - /// - /// - /// A cancellation token to cancel the operation. Passed to both - /// and - /// . - /// - /// - /// A task that represents the asynchronous operation. The task result contains a - /// with the actual available range and data, - /// identical to what returns. - /// The task completes only after the cache has reached an idle state (no pending intent, - /// no executing rebalance). - /// - /// - /// Default vs. Strong Consistency: - /// - /// By default, returns data - /// immediately under an eventual consistency model: the user always receives correct data, - /// but the cache window may still be converging toward its optimal configuration in the background. - /// - /// - /// This method extends that with a wait: it calls GetDataAsync first (user data returned - /// immediately from cache or IDataSource), then awaits - /// before returning. - /// The caller receives the same as GetDataAsync - /// would return, but the method does not complete until the cache has converged. - /// - /// Composition: - /// - /// // Equivalent to: - /// var result = await cache.GetDataAsync(requestedRange, cancellationToken); - /// await cache.WaitForIdleAsync(cancellationToken); - /// return result; - /// - /// When to Use: - /// - /// - /// When the caller needs to assert or inspect the cache geometry after the request - /// (e.g., verifying that a rebalance occurred or that the window has shifted). - /// - /// - /// Cold start synchronization: waiting for the initial rebalance to complete before - /// proceeding with subsequent operations. - /// - /// - /// Integration tests that need deterministic cache state before making assertions. - /// - /// - /// When NOT to Use: - /// - /// - /// Hot paths: the idle wait adds latency proportional to the rebalance execution time - /// (debounce delay + data fetching + cache update). For normal usage, prefer the default - /// eventual consistency model via . - /// - /// - /// Rapid sequential requests: calling this method back-to-back means each call waits - /// for the prior rebalance to complete, eliminating the debounce and work-avoidance - /// benefits of the cache. - /// - /// - /// Idle Semantics (Invariant H.49): - /// - /// The idle wait uses "was idle at some point" semantics inherited from - /// . This is sufficient - /// for the strong consistency use cases above: after the await, the cache has converged at - /// least once since the request. New activity may begin immediately after, but the - /// cache state observed at the idle point reflects the completed rebalance. - /// - /// Exception Propagation: - /// - /// - /// If GetDataAsync throws (e.g., , - /// ), the exception propagates immediately and - /// WaitForIdleAsync is never called. - /// - /// - /// If WaitForIdleAsync throws (e.g., via - /// ), the exception propagates. The data returned by - /// GetDataAsync is discarded. - /// - /// - /// Example: - /// - /// // Strong consistency: returns only after cache has converged - /// var result = await cache.GetDataAndWaitForIdleAsync( - /// Range.Closed(100, 200), - /// cancellationToken); - /// - /// // Cache geometry is now fully converged — safe to inspect or assert - /// if (result.Range.HasValue) - /// ProcessData(result.Data); - /// - /// - public static async ValueTask> GetDataAndWaitForIdleAsync( - this IWindowCache cache, - Range requestedRange, - CancellationToken cancellationToken = default) - where TRange : IComparable - where TDomain : IRangeDomain - { - var result = await cache.GetDataAsync(requestedRange, cancellationToken); - await cache.WaitForIdleAsync(cancellationToken); - return result; - } -} diff --git a/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs index e1b5654..53899df 100644 --- a/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs @@ -1,6 +1,7 @@ using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Instrumentation; diff --git a/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs b/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs index 9bb4748..3787d39 100644 --- a/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs @@ -2,6 +2,7 @@ using Intervals.NET.Domain.Extensions.Fixed; using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Instrumentation; diff --git a/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs b/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs index a3dd306..5cc3eb9 100644 --- a/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs @@ -2,6 +2,7 @@ using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Instrumentation; diff --git a/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs b/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs index addf416..04d33de 100644 --- a/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs @@ -1,6 +1,7 @@ using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Instrumentation; diff --git a/tests/SlidingWindowCache.Integration.Tests/ExecutionStrategySelectionTests.cs b/tests/SlidingWindowCache.Integration.Tests/ExecutionStrategySelectionTests.cs index 249cf0c..08b1def 100644 --- a/tests/SlidingWindowCache.Integration.Tests/ExecutionStrategySelectionTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/ExecutionStrategySelectionTests.cs @@ -1,5 +1,6 @@ using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Dto; using SlidingWindowCache.Tests.Infrastructure.DataSources; @@ -97,10 +98,10 @@ public async Task TaskBasedStrategy_UnderLoad_MaintainsSerialExecution() // ACT - Rapid sequential requests (should trigger multiple rebalances) var tasks = new List>>(); - for (int i = 0; i < 10; i++) + for (var i = 0; i < 10; i++) { - int start = i * 10; - int end = start + 10; + var start = i * 10; + var end = start + 10; tasks.Add(cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(start, end), CancellationToken.None).AsTask()); } @@ -173,10 +174,10 @@ public async Task ChannelBasedStrategy_UnderLoad_MaintainsSerialExecution() // ACT - Rapid sequential requests (may experience backpressure) var tasks = new List>>(); - for (int i = 0; i < 10; i++) + for (var i = 0; i < 10; i++) { - int start = i * 10; - int end = start + 10; + var start = i * 10; + var end = start + 10; tasks.Add(cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(start, end), CancellationToken.None).AsTask()); } diff --git a/tests/SlidingWindowCache.Integration.Tests/LayeredCacheIntegrationTests.cs b/tests/SlidingWindowCache.Integration.Tests/LayeredCacheIntegrationTests.cs index b600fc1..a35b2bb 100644 --- a/tests/SlidingWindowCache.Integration.Tests/LayeredCacheIntegrationTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/LayeredCacheIntegrationTests.cs @@ -1,6 +1,8 @@ using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Extensions; using SlidingWindowCache.Public.Instrumentation; using SlidingWindowCache.Tests.Infrastructure.DataSources; @@ -57,8 +59,7 @@ private static IDataSource CreateRealDataSource() public async Task TwoLayerCache_GetData_ReturnsCorrectValues() { // ARRANGE - await using var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions()) .AddLayer(UserLayerOptions()) .Build(); @@ -79,8 +80,7 @@ public async Task TwoLayerCache_GetData_ReturnsCorrectValues() public async Task ThreeLayerCache_GetData_ReturnsCorrectValues() { // ARRANGE - await using var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions()) .AddLayer(MidLayerOptions()) .AddLayer(UserLayerOptions()) @@ -102,8 +102,7 @@ public async Task ThreeLayerCache_GetData_ReturnsCorrectValues() public async Task TwoLayerCache_SubsequentRequests_ReturnCorrectValues() { // ARRANGE - await using var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions()) .AddLayer(UserLayerOptions()) .Build(); @@ -131,8 +130,7 @@ public async Task TwoLayerCache_SubsequentRequests_ReturnCorrectValues() public async Task TwoLayerCache_SingleElementRange_ReturnsCorrectValue() { // ARRANGE - await using var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions()) .AddLayer(UserLayerOptions()) .Build(); @@ -155,29 +153,27 @@ public async Task TwoLayerCache_SingleElementRange_ReturnsCorrectValue() public async Task TwoLayerCache_LayerCount_IsTwo() { // ARRANGE - await using var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + await using var layered = (LayeredWindowCache)WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions()) .AddLayer(UserLayerOptions()) .Build(); // ASSERT - Assert.Equal(2, cache.LayerCount); + Assert.Equal(2, layered.LayerCount); } [Fact] public async Task ThreeLayerCache_LayerCount_IsThree() { // ARRANGE - await using var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + await using var layered = (LayeredWindowCache)WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions()) .AddLayer(MidLayerOptions()) .AddLayer(UserLayerOptions()) .Build(); // ASSERT - Assert.Equal(3, cache.LayerCount); + Assert.Equal(3, layered.LayerCount); } #endregion @@ -188,8 +184,7 @@ public async Task ThreeLayerCache_LayerCount_IsThree() public async Task TwoLayerCache_WaitForIdleAsync_ConvergesWithoutException() { // ARRANGE - await using var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions()) .AddLayer(UserLayerOptions()) .Build(); @@ -208,8 +203,7 @@ public async Task TwoLayerCache_WaitForIdleAsync_ConvergesWithoutException() public async Task TwoLayerCache_AfterConvergence_DataStillCorrect() { // ARRANGE - await using var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions()) .AddLayer(UserLayerOptions()) .Build(); @@ -237,8 +231,7 @@ public async Task TwoLayerCache_WaitForIdleAsync_AllLayersHaveConverged() var deepDiagnostics = new EventCounterCacheDiagnostics(); var userDiagnostics = new EventCounterCacheDiagnostics(); - await using var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions(), deepDiagnostics) .AddLayer(UserLayerOptions(), userDiagnostics) .Build(); @@ -264,8 +257,7 @@ public async Task TwoLayerCache_WaitForIdleAsync_AllLayersHaveConverged() public async Task TwoLayerCache_GetDataAndWaitForIdleAsync_ReturnsCorrectData() { // ARRANGE — verify that the strong consistency extension method works on a LayeredWindowCache - await using var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions()) .AddLayer(UserLayerOptions()) .Build(); @@ -286,8 +278,7 @@ public async Task TwoLayerCache_GetDataAndWaitForIdleAsync_ReturnsCorrectData() public async Task TwoLayerCache_GetDataAndWaitForIdleAsync_SubsequentRequestIsFullHit() { // ARRANGE - await using var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions()) .AddLayer(UserLayerOptions()) .Build(); @@ -316,8 +307,7 @@ public async Task TwoLayerCache_GetDataAndWaitForIdleAsync_SubsequentRequestIsFu public async Task TwoLayerCache_DisposeAsync_CompletesWithoutException() { // ARRANGE - var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + var cache = WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions()) .AddLayer(UserLayerOptions()) .Build(); @@ -335,8 +325,7 @@ public async Task TwoLayerCache_DisposeAsync_CompletesWithoutException() public async Task TwoLayerCache_DisposeWithoutAnyRequests_CompletesWithoutException() { // ARRANGE — build but never use - var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + var cache = WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions()) .AddLayer(UserLayerOptions()) .Build(); @@ -352,8 +341,7 @@ public async Task TwoLayerCache_DisposeWithoutAnyRequests_CompletesWithoutExcept public async Task ThreeLayerCache_DisposeAsync_CompletesWithoutException() { // ARRANGE - var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + var cache = WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions()) .AddLayer(MidLayerOptions()) .AddLayer(UserLayerOptions()) @@ -411,8 +399,7 @@ public async Task TwoLayerCache_WithPerLayerDiagnostics_EachLayerTracksIndepende var deepDiagnostics = new EventCounterCacheDiagnostics(); var userDiagnostics = new EventCounterCacheDiagnostics(); - await using var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions(), deepDiagnostics) .AddLayer(UserLayerOptions(), userDiagnostics) .Build(); @@ -443,8 +430,7 @@ public async Task TwoLayerCache_WithPerLayerDiagnostics_EachLayerTracksIndepende public async Task TwoLayerCache_LargeRange_ReturnsCorrectData() { // ARRANGE - await using var cache = LayeredWindowCacheBuilder - .Create(CreateRealDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateRealDataSource(), Domain) .AddLayer(DeepLayerOptions()) .AddLayer(UserLayerOptions()) .Build(); diff --git a/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs b/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs index 583616a..95db41a 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs @@ -3,6 +3,7 @@ using Intervals.NET.Domain.Extensions.Fixed; using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Instrumentation; diff --git a/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs b/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs index 6499bc9..a17067c 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs @@ -2,6 +2,7 @@ using Intervals.NET.Domain.Extensions.Fixed; using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Instrumentation; diff --git a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs index 01f4db4..5724127 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs @@ -1,6 +1,7 @@ using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Instrumentation; diff --git a/tests/SlidingWindowCache.Integration.Tests/RuntimeOptionsUpdateTests.cs b/tests/SlidingWindowCache.Integration.Tests/RuntimeOptionsUpdateTests.cs new file mode 100644 index 0000000..2635885 --- /dev/null +++ b/tests/SlidingWindowCache.Integration.Tests/RuntimeOptionsUpdateTests.cs @@ -0,0 +1,536 @@ +using Intervals.NET.Domain.Default.Numeric; +using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; +using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Tests.Infrastructure.DataSources; + +namespace SlidingWindowCache.Integration.Tests; + +/// +/// Integration tests for . +/// Verifies partial updates, validation rejection, disposal guard, and behavioral effect on rebalancing. +/// +public class RuntimeOptionsUpdateTests +{ + private static IDataSource CreateDataSource() => + new SimpleTestDataSource(i => $"Item_{i}"); + + private static WindowCacheOptions DefaultOptions() => new( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot + ); + + #region Partial Update Tests + + [Fact] + public async Task UpdateRuntimeOptions_PartialUpdate_OnlyChangesSpecifiedFields() + { + // ARRANGE + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), + new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 2.0, + readMode: UserCacheReadMode.Snapshot, + leftThreshold: 0.1, + rightThreshold: 0.2, + debounceDelay: TimeSpan.FromMilliseconds(50) + ) + ); + + // ACT — only change LeftCacheSize + cache.UpdateRuntimeOptions(update => update.WithLeftCacheSize(3.0)); + + // ASSERT — after next rebalance the cache window should be larger on the left + // Trigger rebalance and wait for idle + var range = Intervals.NET.Factories.Range.Closed(100, 110); + await cache.GetDataAsync(range, CancellationToken.None); + await cache.WaitForIdleAsync(); + + // The cache should have expanded at least leftCacheSize * span (3.0 * 10 = 30 units) to the left + var result = await cache.GetDataAsync(range, CancellationToken.None); + Assert.True(result.Data.Length > 0); + } + + [Fact] + public async Task UpdateRuntimeOptions_WithNoBuilderCalls_LeavesAllFieldsUnchanged() + { + // ARRANGE + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), DefaultOptions() + ); + + // ACT — call with empty builder (no changes) + var exception = Record.Exception(() => + cache.UpdateRuntimeOptions(_ => { })); + + // ASSERT — no exception; options unchanged + Assert.Null(exception); + } + + #endregion + + #region Threshold Update Tests + + [Fact] + public async Task UpdateRuntimeOptions_WithLeftThreshold_SetsThreshold() + { + // ARRANGE + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), DefaultOptions() + ); + + // ACT + var exception = Record.Exception(() => + cache.UpdateRuntimeOptions(update => update.WithLeftThreshold(0.3))); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public async Task UpdateRuntimeOptions_ClearLeftThreshold_SetsThresholdToNull() + { + // ARRANGE + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), + new WindowCacheOptions(1.0, 1.0, UserCacheReadMode.Snapshot, leftThreshold: 0.2) + ); + + // ACT + var exception = Record.Exception(() => + cache.UpdateRuntimeOptions(update => update.ClearLeftThreshold())); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public async Task UpdateRuntimeOptions_ClearRightThreshold_SetsThresholdToNull() + { + // ARRANGE + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), + new WindowCacheOptions(1.0, 1.0, UserCacheReadMode.Snapshot, rightThreshold: 0.2) + ); + + // ACT + var exception = Record.Exception(() => + cache.UpdateRuntimeOptions(update => update.ClearRightThreshold())); + + // ASSERT + Assert.Null(exception); + } + + #endregion + + #region Validation Rejection Tests + + [Fact] + public async Task UpdateRuntimeOptions_WithNegativeLeftCacheSize_ThrowsAndLeavesOptionsUnchanged() + { + // ARRANGE + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), DefaultOptions() + ); + + // ACT + var exception = Record.Exception(() => + cache.UpdateRuntimeOptions(update => update.WithLeftCacheSize(-1.0))); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public async Task UpdateRuntimeOptions_WithNegativeRightCacheSize_ThrowsAndLeavesOptionsUnchanged() + { + // ARRANGE + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), DefaultOptions() + ); + + // ACT + var exception = Record.Exception(() => + cache.UpdateRuntimeOptions(update => update.WithRightCacheSize(-0.5))); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public async Task UpdateRuntimeOptions_WithThresholdSumExceedingOne_ThrowsArgumentException() + { + // ARRANGE — start with left=0.4, then set right=0.7 → sum=1.1 + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), + new WindowCacheOptions(1.0, 1.0, UserCacheReadMode.Snapshot, leftThreshold: 0.4) + ); + + // ACT + var exception = Record.Exception(() => + cache.UpdateRuntimeOptions(update => update.WithRightThreshold(0.7))); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public async Task UpdateRuntimeOptions_ValidationFailure_DoesNotPublishPartialUpdate() + { + // ARRANGE — valid initial state + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), + new WindowCacheOptions( + leftCacheSize: 2.0, + rightCacheSize: 3.0, + readMode: UserCacheReadMode.Snapshot + ) + ); + + // ACT — attempt invalid update (negative right cache size) + _ = Record.Exception(() => + cache.UpdateRuntimeOptions(update => + update.WithLeftCacheSize(5.0).WithRightCacheSize(-1.0))); + + // ASSERT — cache still accepts requests (options unchanged, not partially applied) + var exception = Record.Exception(() => + cache.UpdateRuntimeOptions(update => update.WithLeftCacheSize(1.0))); + Assert.Null(exception); + } + + #endregion + + #region Disposal Guard Tests + + [Fact] + public async Task UpdateRuntimeOptions_OnDisposedCache_ThrowsObjectDisposedException() + { + // ARRANGE + var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), DefaultOptions() + ); + await cache.DisposeAsync(); + + // ACT + var exception = Record.Exception(() => + cache.UpdateRuntimeOptions(update => update.WithLeftCacheSize(2.0))); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + #endregion + + #region Behavioral Effect Tests + + [Fact] + public async Task UpdateRuntimeOptions_IncreasedCacheSize_LeadsToLargerCacheAfterRebalance() + { + // ARRANGE — start with small cache sizes + var domain = new IntegerFixedStepDomain(); + await using var cache = new WindowCache( + CreateDataSource(), domain, + new WindowCacheOptions( + leftCacheSize: 0.5, + rightCacheSize: 0.5, + readMode: UserCacheReadMode.Snapshot, + debounceDelay: TimeSpan.Zero + ) + ); + + var range = Intervals.NET.Factories.Range.Closed(100, 110); + + // Prime cache with small sizes and wait for convergence + await cache.GetDataAsync(range, CancellationToken.None); + await cache.WaitForIdleAsync(); + + // ACT — dramatically increase cache sizes + cache.UpdateRuntimeOptions(update => + update.WithLeftCacheSize(5.0).WithRightCacheSize(5.0)); + + // Trigger a new rebalance cycle + var adjacentRange = Intervals.NET.Factories.Range.Closed(111, 120); + await cache.GetDataAsync(adjacentRange, CancellationToken.None); + await cache.WaitForIdleAsync(); + + // ASSERT — no exceptions; cache operated normally after runtime update + var result = await cache.GetDataAsync(range, CancellationToken.None); + Assert.True(result.Data.Length > 0); + } + + [Fact] + public async Task UpdateRuntimeOptions_FluentChaining_AllChangesApplied() + { + // ARRANGE + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), DefaultOptions() + ); + + // ACT — chain multiple updates + var exception = Record.Exception(() => + cache.UpdateRuntimeOptions(update => + update + .WithLeftCacheSize(2.0) + .WithRightCacheSize(3.0) + .WithLeftThreshold(0.1) + .WithRightThreshold(0.2) + .WithDebounceDelay(TimeSpan.FromMilliseconds(10)))); + + // ASSERT + Assert.Null(exception); + + // Confirm cache still works after chained update + var result = await cache.GetDataAsync( + Intervals.NET.Factories.Range.Closed(0, 10), CancellationToken.None); + Assert.True(result.Data.Length > 0); + } + + [Fact] + public async Task UpdateRuntimeOptions_DebounceDelayUpdate_TakesEffectOnNextExecution() + { + // ARRANGE + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), + new WindowCacheOptions(1.0, 1.0, UserCacheReadMode.Snapshot, + debounceDelay: TimeSpan.FromMilliseconds(100)) + ); + + // ACT — reduce debounce delay to zero + cache.UpdateRuntimeOptions(update => update.WithDebounceDelay(TimeSpan.Zero)); + + // Trigger rebalance after the update + await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(50, 60), CancellationToken.None); + + // Wait should complete quickly (debounce is now zero) + var completed = await Task.WhenAny( + cache.WaitForIdleAsync(), + Task.Delay(TimeSpan.FromSeconds(5)) + ); + + // ASSERT + Assert.Equal(TaskStatus.RanToCompletion, completed.Status); + } + + #endregion + + #region Channel-Based Strategy Tests + + [Fact] + public async Task UpdateRuntimeOptions_WithChannelBasedStrategy_WorksIdentically() + { + // ARRANGE — use bounded channel strategy + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), + new WindowCacheOptions(1.0, 1.0, UserCacheReadMode.Snapshot, + rebalanceQueueCapacity: 5) + ); + + // ACT + var exception = Record.Exception(() => + cache.UpdateRuntimeOptions(update => + update.WithLeftCacheSize(2.0).WithDebounceDelay(TimeSpan.Zero))); + + // ASSERT + Assert.Null(exception); + } + + #endregion + + #region CurrentRuntimeOptions Tests + + [Fact] + public async Task CurrentRuntimeOptions_ReflectsInitialOptions() + { + // ARRANGE + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), + new WindowCacheOptions( + leftCacheSize: 1.5, + rightCacheSize: 2.5, + readMode: UserCacheReadMode.Snapshot, + leftThreshold: 0.1, + rightThreshold: 0.2, + debounceDelay: TimeSpan.FromMilliseconds(50) + ) + ); + + // ACT + var snapshot = cache.CurrentRuntimeOptions; + + // ASSERT + Assert.Equal(1.5, snapshot.LeftCacheSize); + Assert.Equal(2.5, snapshot.RightCacheSize); + Assert.Equal(0.1, snapshot.LeftThreshold); + Assert.Equal(0.2, snapshot.RightThreshold); + Assert.Equal(TimeSpan.FromMilliseconds(50), snapshot.DebounceDelay); + } + + [Fact] + public async Task CurrentRuntimeOptions_AfterUpdate_ReflectsNewValues() + { + // ARRANGE + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), + new WindowCacheOptions(1.0, 1.0, UserCacheReadMode.Snapshot) + ); + + // ACT + cache.UpdateRuntimeOptions(update => + update.WithLeftCacheSize(3.0).WithRightCacheSize(4.0)); + + var snapshot = cache.CurrentRuntimeOptions; + + // ASSERT + Assert.Equal(3.0, snapshot.LeftCacheSize); + Assert.Equal(4.0, snapshot.RightCacheSize); + } + + [Fact] + public async Task CurrentRuntimeOptions_AfterPartialUpdate_UnchangedFieldsRetainOldValues() + { + // ARRANGE + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), + new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 2.0, + readMode: UserCacheReadMode.Snapshot, + debounceDelay: TimeSpan.FromMilliseconds(75) + ) + ); + + // ACT — only change left cache size + cache.UpdateRuntimeOptions(update => update.WithLeftCacheSize(5.0)); + + var snapshot = cache.CurrentRuntimeOptions; + + // ASSERT — left changed, right and debounce unchanged + Assert.Equal(5.0, snapshot.LeftCacheSize); + Assert.Equal(2.0, snapshot.RightCacheSize); + Assert.Equal(TimeSpan.FromMilliseconds(75), snapshot.DebounceDelay); + } + + [Fact] + public async Task CurrentRuntimeOptions_AfterThresholdCleared_ThresholdIsNull() + { + // ARRANGE + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), + new WindowCacheOptions(1.0, 1.0, UserCacheReadMode.Snapshot, + leftThreshold: 0.3, rightThreshold: 0.3) + ); + + // ACT + cache.UpdateRuntimeOptions(update => update.ClearLeftThreshold()); + + var snapshot = cache.CurrentRuntimeOptions; + + // ASSERT + Assert.Null(snapshot.LeftThreshold); + Assert.Equal(0.3, snapshot.RightThreshold); + } + + [Fact] + public async Task CurrentRuntimeOptions_OnDisposedCache_ThrowsObjectDisposedException() + { + // ARRANGE + var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), DefaultOptions() + ); + await cache.DisposeAsync(); + + // ACT + var exception = Record.Exception(() => _ = cache.CurrentRuntimeOptions); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public async Task CurrentRuntimeOptions_ReturnedSnapshot_IsImmutable() + { + // ARRANGE + await using var cache = new WindowCache( + CreateDataSource(), new IntegerFixedStepDomain(), + new WindowCacheOptions(1.0, 2.0, UserCacheReadMode.Snapshot) + ); + var snapshot1 = cache.CurrentRuntimeOptions; + + // ACT — update the cache + cache.UpdateRuntimeOptions(update => update.WithLeftCacheSize(9.9)); + + var snapshot2 = cache.CurrentRuntimeOptions; + + // ASSERT — snapshot1 still reflects old values (it is a snapshot, not a live view) + Assert.Equal(1.0, snapshot1.LeftCacheSize); + Assert.Equal(9.9, snapshot2.LeftCacheSize); + Assert.NotSame(snapshot1, snapshot2); + } + + #endregion + + #region Layered Cache - Per-Layer Layers[] Update Tests + + [Fact] + public async Task LayeredCache_LayersProperty_AllowsPerLayerOptionsUpdate() + { + // ARRANGE — build a 2-layer cache + await using var layeredCache = (LayeredWindowCache)WindowCacheBuilder.Layered(CreateDataSource(), new IntegerFixedStepDomain()) + .AddLayer(new WindowCacheOptions(1.0, 1.0, UserCacheReadMode.Snapshot)) + .AddLayer(new WindowCacheOptions(1.0, 1.0, UserCacheReadMode.Snapshot)) + .Build(); + + // ACT — update the innermost layer's options via Layers[0] + var exception = Record.Exception(() => + layeredCache.Layers[0].UpdateRuntimeOptions(u => u.WithLeftCacheSize(3.0))); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public async Task LayeredCache_LayersProperty_InnerLayerCurrentRuntimeOptions_ReflectsUpdate() + { + // ARRANGE + await using var layeredCache = (LayeredWindowCache)WindowCacheBuilder.Layered(CreateDataSource(), new IntegerFixedStepDomain()) + .AddLayer(new WindowCacheOptions(1.0, 1.0, UserCacheReadMode.Snapshot)) + .AddLayer(new WindowCacheOptions(1.0, 1.0, UserCacheReadMode.Snapshot)) + .Build(); + + // ACT + layeredCache.Layers[0].UpdateRuntimeOptions(u => u.WithRightCacheSize(5.0)); + var innerSnapshot = layeredCache.Layers[0].CurrentRuntimeOptions; + + // ASSERT — inner layer reflects its own update + Assert.Equal(5.0, innerSnapshot.RightCacheSize); + } + + [Fact] + public async Task LayeredCache_LayersProperty_OuterLayerUpdateDoesNotAffectInnerLayer() + { + // ARRANGE + await using var layeredCache = (LayeredWindowCache)WindowCacheBuilder.Layered(CreateDataSource(), new IntegerFixedStepDomain()) + .AddLayer(new WindowCacheOptions(1.0, 1.0, UserCacheReadMode.Snapshot)) + .AddLayer(new WindowCacheOptions(1.0, 1.0, UserCacheReadMode.Snapshot)) + .Build(); + + // ACT — update outer layer only + layeredCache.UpdateRuntimeOptions(u => u.WithLeftCacheSize(7.0)); + + var outerSnapshot = layeredCache.CurrentRuntimeOptions; + var innerSnapshot = layeredCache.Layers[0].CurrentRuntimeOptions; + + // ASSERT — outer changed, inner unchanged + Assert.Equal(7.0, outerSnapshot.LeftCacheSize); + Assert.Equal(1.0, innerSnapshot.LeftCacheSize); + } + + #endregion +} diff --git a/tests/SlidingWindowCache.Integration.Tests/StrongConsistencyModeTests.cs b/tests/SlidingWindowCache.Integration.Tests/StrongConsistencyModeTests.cs index 004ab8f..e8f06a5 100644 --- a/tests/SlidingWindowCache.Integration.Tests/StrongConsistencyModeTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/StrongConsistencyModeTests.cs @@ -1,14 +1,16 @@ using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Tests.Infrastructure.Helpers; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Extensions; using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Integration.Tests; /// /// Integration tests for the strong consistency mode exposed by -/// . +/// . /// /// Goal: Verify that the extension method behaves correctly end-to-end with a real /// instance: @@ -333,11 +335,13 @@ public async Task GetDataAndWaitForIdleAsync_SequentialOverlappingRequests_DataA #region Cancellation Integration Tests /// - /// Verifies that a pre-cancelled token causes the operation to fail (either - /// GetDataAsync or WaitForIdleAsync will see the cancellation). + /// Verifies that a pre-cancelled token causes graceful degradation: if GetDataAsync + /// succeeds before observing the cancellation, WaitForIdleAsync's OperationCanceledException + /// is caught and the already-obtained result is returned (eventual consistency degradation). + /// The background rebalance is not affected. /// [Fact] - public async Task GetDataAndWaitForIdleAsync_PreCancelledToken_ThrowsOperationCanceledException() + public async Task GetDataAndWaitForIdleAsync_PreCancelledToken_ReturnsResultGracefully() { // ARRANGE var cache = CreateCache(); @@ -345,14 +349,20 @@ public async Task GetDataAndWaitForIdleAsync_PreCancelledToken_ThrowsOperationCa using var cts = new CancellationTokenSource(); cts.Cancel(); - // ACT + // ACT — GetDataAsync may succeed even with a pre-cancelled token (no explicit + // ThrowIfCancellationRequested on the user path when data is already in cache or + // when the fetch completes before observing cancellation). If WaitForIdleAsync + // then throws OperationCanceledException it is caught and the result is returned. var exception = await Record.ExceptionAsync( async () => await cache.GetDataAndWaitForIdleAsync(range, cts.Token)); - // ASSERT - Assert.NotNull(exception); - Assert.True(exception is OperationCanceledException, - $"Expected OperationCanceledException (or subclass), got {exception.GetType().Name}"); + // ASSERT — graceful degradation: either no exception (WaitForIdleAsync cancelled + // and caught), or OperationCanceledException from GetDataAsync itself (if the + // data source fetch observed the cancellation). Both outcomes are valid. + if (exception is not null) + { + Assert.IsAssignableFrom(exception); + } } #endregion diff --git a/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs index c71b131..a50493e 100644 --- a/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs @@ -1,6 +1,7 @@ using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Instrumentation; diff --git a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs index 5250d75..9170157 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs @@ -3,7 +3,9 @@ using Intervals.NET.Extensions; using SlidingWindowCache.Tests.Infrastructure.Helpers; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Extensions; using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Invariants.Tests; diff --git a/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs b/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs index 09f6011..6d77a6f 100644 --- a/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs +++ b/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs @@ -3,6 +3,7 @@ using Intervals.NET.Domain.Extensions.Fixed; using Moq; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Dto; using SlidingWindowCache.Public.Instrumentation; diff --git a/tests/SlidingWindowCache.Unit.Tests/Core/State/RuntimeCacheOptionsHolderTests.cs b/tests/SlidingWindowCache.Unit.Tests/Core/State/RuntimeCacheOptionsHolderTests.cs new file mode 100644 index 0000000..e05e2fc --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Core/State/RuntimeCacheOptionsHolderTests.cs @@ -0,0 +1,138 @@ +using SlidingWindowCache.Core.State; + +namespace SlidingWindowCache.Unit.Tests.Core.State; + +/// +/// Unit tests for verifying atomic read/write semantics. +/// +public class RuntimeCacheOptionsHolderTests +{ + #region Constructor Tests + + [Fact] + public void Constructor_WithInitialOptions_CurrentReturnsInitialSnapshot() + { + // ARRANGE + var initial = new RuntimeCacheOptions(1.0, 2.0, 0.1, 0.2, TimeSpan.FromMilliseconds(50)); + + // ACT + var holder = new RuntimeCacheOptionsHolder(initial); + + // ASSERT + Assert.Same(initial, holder.Current); + } + + #endregion + + #region Update Tests + + [Fact] + public void Update_WithNewOptions_CurrentReturnsNewSnapshot() + { + // ARRANGE + var initial = new RuntimeCacheOptions(1.0, 1.0, null, null, TimeSpan.Zero); + var updated = new RuntimeCacheOptions(3.0, 4.0, 0.1, 0.2, TimeSpan.FromMilliseconds(100)); + var holder = new RuntimeCacheOptionsHolder(initial); + + // ACT + holder.Update(updated); + + // ASSERT + Assert.Same(updated, holder.Current); + } + + [Fact] + public void Update_MultipleTimes_CurrentReturnsLatestSnapshot() + { + // ARRANGE + var initial = new RuntimeCacheOptions(1.0, 1.0, null, null, TimeSpan.Zero); + var second = new RuntimeCacheOptions(2.0, 2.0, null, null, TimeSpan.Zero); + var third = new RuntimeCacheOptions(3.0, 3.0, null, null, TimeSpan.Zero); + var holder = new RuntimeCacheOptionsHolder(initial); + + // ACT + holder.Update(second); + holder.Update(third); + + // ASSERT + Assert.Same(third, holder.Current); + Assert.Equal(3.0, holder.Current.LeftCacheSize); + } + + [Fact] + public void Update_DoesNotMutateInitialSnapshot() + { + // ARRANGE + var initial = new RuntimeCacheOptions(1.0, 1.0, null, null, TimeSpan.Zero); + var holder = new RuntimeCacheOptionsHolder(initial); + + // ACT + holder.Update(new RuntimeCacheOptions(5.0, 5.0, null, null, TimeSpan.Zero)); + + // ASSERT — initial object is unchanged + Assert.Equal(1.0, initial.LeftCacheSize); + } + + #endregion + + #region Concurrent Access Tests + + [Fact] + public async Task Update_ConcurrentWrites_CurrentIsOneOfThePublishedSnapshots() + { + // ARRANGE + var initial = new RuntimeCacheOptions(0.0, 0.0, null, null, TimeSpan.Zero); + var holder = new RuntimeCacheOptionsHolder(initial); + var snapshots = new RuntimeCacheOptions[10]; + for (var i = 0; i < snapshots.Length; i++) + { + snapshots[i] = new RuntimeCacheOptions(i, i, null, null, TimeSpan.Zero); + } + + // ACT — ten concurrent writers + await Task.WhenAll(snapshots.Select(s => Task.Run(() => holder.Update(s)))); + + // ASSERT — current must be one of the published snapshots (last-writer-wins) + var current = holder.Current; + Assert.Contains(current, snapshots); + } + + [Fact] + public async Task Current_ConcurrentReadsWhileWriting_NeverReturnsNull() + { + // ARRANGE + var holder = new RuntimeCacheOptionsHolder( + new RuntimeCacheOptions(1.0, 1.0, null, null, TimeSpan.Zero)); + + var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + var readResults = new System.Collections.Concurrent.ConcurrentBag(); + + // ACT — concurrent reads and writes + var readerTask = Task.Run(async () => + { + while (!cts.Token.IsCancellationRequested) + { + readResults.Add(holder.Current); + await Task.Yield(); + } + }); + + var writerTask = Task.Run(async () => + { + var i = 0; + while (!cts.Token.IsCancellationRequested) + { + holder.Update(new RuntimeCacheOptions(i % 10, i % 10, null, null, TimeSpan.Zero)); + i++; + await Task.Yield(); + } + }); + + await Task.WhenAll(readerTask, writerTask); + + // ASSERT — reader never observed null + Assert.All(readResults, r => Assert.NotNull(r)); + } + + #endregion +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Core/State/RuntimeCacheOptionsTests.cs b/tests/SlidingWindowCache.Unit.Tests/Core/State/RuntimeCacheOptionsTests.cs new file mode 100644 index 0000000..a8406a8 --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Core/State/RuntimeCacheOptionsTests.cs @@ -0,0 +1,311 @@ +using SlidingWindowCache.Core.State; + +namespace SlidingWindowCache.Unit.Tests.Core.State; + +/// +/// Unit tests for that verify validation logic and property initialization. +/// +public class RuntimeCacheOptionsTests +{ + #region Constructor - Valid Parameters Tests + + [Fact] + public void Constructor_WithValidParameters_InitializesAllProperties() + { + // ARRANGE & ACT + var options = new RuntimeCacheOptions( + leftCacheSize: 1.5, + rightCacheSize: 2.0, + leftThreshold: 0.3, + rightThreshold: 0.4, + debounceDelay: TimeSpan.FromMilliseconds(200) + ); + + // ASSERT + Assert.Equal(1.5, options.LeftCacheSize); + Assert.Equal(2.0, options.RightCacheSize); + Assert.Equal(0.3, options.LeftThreshold); + Assert.Equal(0.4, options.RightThreshold); + Assert.Equal(TimeSpan.FromMilliseconds(200), options.DebounceDelay); + } + + [Fact] + public void Constructor_WithNullThresholds_IsValid() + { + // ARRANGE & ACT + var options = new RuntimeCacheOptions(1.0, 1.0, null, null, TimeSpan.Zero); + + // ASSERT + Assert.Null(options.LeftThreshold); + Assert.Null(options.RightThreshold); + } + + [Fact] + public void Constructor_WithZeroCacheSizes_IsValid() + { + // ARRANGE & ACT + var options = new RuntimeCacheOptions(0.0, 0.0, null, null, TimeSpan.Zero); + + // ASSERT + Assert.Equal(0.0, options.LeftCacheSize); + Assert.Equal(0.0, options.RightCacheSize); + } + + [Fact] + public void Constructor_WithZeroThresholds_IsValid() + { + // ARRANGE & ACT + var options = new RuntimeCacheOptions(1.0, 1.0, 0.0, 0.0, TimeSpan.Zero); + + // ASSERT + Assert.Equal(0.0, options.LeftThreshold); + Assert.Equal(0.0, options.RightThreshold); + } + + [Fact] + public void Constructor_WithMaxThresholds_SummingToExactlyOne_IsValid() + { + // ARRANGE & ACT + var options = new RuntimeCacheOptions(1.0, 1.0, 0.5, 0.5, TimeSpan.Zero); + + // ASSERT + Assert.Equal(0.5, options.LeftThreshold); + Assert.Equal(0.5, options.RightThreshold); + } + + [Fact] + public void Constructor_WithZeroDebounceDelay_IsValid() + { + // ARRANGE & ACT + var options = new RuntimeCacheOptions(1.0, 1.0, null, null, TimeSpan.Zero); + + // ASSERT + Assert.Equal(TimeSpan.Zero, options.DebounceDelay); + } + + #endregion + + #region Constructor - Invalid LeftCacheSize Tests + + [Theory] + [InlineData(-0.001)] + [InlineData(-1.0)] + [InlineData(double.MinValue)] + public void Constructor_WithNegativeLeftCacheSize_ThrowsArgumentOutOfRangeException(double leftCacheSize) + { + // ARRANGE & ACT + var exception = Record.Exception(() => + new RuntimeCacheOptions(leftCacheSize, 1.0, null, null, TimeSpan.Zero)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("LeftCacheSize", exception.Message); + } + + #endregion + + #region Constructor - Invalid RightCacheSize Tests + + [Theory] + [InlineData(-0.001)] + [InlineData(-1.0)] + [InlineData(double.MinValue)] + public void Constructor_WithNegativeRightCacheSize_ThrowsArgumentOutOfRangeException(double rightCacheSize) + { + // ARRANGE & ACT + var exception = Record.Exception(() => + new RuntimeCacheOptions(1.0, rightCacheSize, null, null, TimeSpan.Zero)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("RightCacheSize", exception.Message); + } + + #endregion + + #region Constructor - Invalid LeftThreshold Tests + + [Theory] + [InlineData(-0.001)] + [InlineData(-1.0)] + public void Constructor_WithNegativeLeftThreshold_ThrowsArgumentOutOfRangeException(double leftThreshold) + { + // ARRANGE & ACT + var exception = Record.Exception(() => + new RuntimeCacheOptions(1.0, 1.0, leftThreshold, null, TimeSpan.Zero)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("LeftThreshold", exception.Message); + } + + [Theory] + [InlineData(1.001)] + [InlineData(2.0)] + public void Constructor_WithLeftThresholdExceedingOne_ThrowsArgumentOutOfRangeException(double leftThreshold) + { + // ARRANGE & ACT + var exception = Record.Exception(() => + new RuntimeCacheOptions(1.0, 1.0, leftThreshold, null, TimeSpan.Zero)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("LeftThreshold", exception.Message); + } + + #endregion + + #region Constructor - Invalid RightThreshold Tests + + [Theory] + [InlineData(-0.001)] + [InlineData(-1.0)] + public void Constructor_WithNegativeRightThreshold_ThrowsArgumentOutOfRangeException(double rightThreshold) + { + // ARRANGE & ACT + var exception = Record.Exception(() => + new RuntimeCacheOptions(1.0, 1.0, null, rightThreshold, TimeSpan.Zero)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("RightThreshold", exception.Message); + } + + [Theory] + [InlineData(1.001)] + [InlineData(2.0)] + public void Constructor_WithRightThresholdExceedingOne_ThrowsArgumentOutOfRangeException(double rightThreshold) + { + // ARRANGE & ACT + var exception = Record.Exception(() => + new RuntimeCacheOptions(1.0, 1.0, null, rightThreshold, TimeSpan.Zero)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("RightThreshold", exception.Message); + } + + #endregion + + #region ToSnapshot Tests + + [Fact] + public void ToSnapshot_ReturnsSnapshotWithMatchingValues() + { + // ARRANGE + var options = new RuntimeCacheOptions( + leftCacheSize: 1.5, + rightCacheSize: 2.0, + leftThreshold: 0.3, + rightThreshold: 0.4, + debounceDelay: TimeSpan.FromMilliseconds(200) + ); + + // ACT + var snapshot = options.ToSnapshot(); + + // ASSERT + Assert.Equal(1.5, snapshot.LeftCacheSize); + Assert.Equal(2.0, snapshot.RightCacheSize); + Assert.Equal(0.3, snapshot.LeftThreshold); + Assert.Equal(0.4, snapshot.RightThreshold); + Assert.Equal(TimeSpan.FromMilliseconds(200), snapshot.DebounceDelay); + } + + [Fact] + public void ToSnapshot_WithNullThresholds_ReturnsSnapshotWithNullThresholds() + { + // ARRANGE + var options = new RuntimeCacheOptions(1.0, 1.0, null, null, TimeSpan.Zero); + + // ACT + var snapshot = options.ToSnapshot(); + + // ASSERT + Assert.Null(snapshot.LeftThreshold); + Assert.Null(snapshot.RightThreshold); + } + + [Fact] + public void ToSnapshot_CalledTwice_ReturnsTwoIndependentInstances() + { + // ARRANGE + var options = new RuntimeCacheOptions(1.0, 1.0, null, null, TimeSpan.Zero); + + // ACT + var snapshot1 = options.ToSnapshot(); + var snapshot2 = options.ToSnapshot(); + + // ASSERT — each call returns a new object + Assert.NotSame(snapshot1, snapshot2); + } + + [Fact] + public void ToSnapshot_WithZeroValues_ReturnsSnapshotWithZeroValues() + { + // ARRANGE + var options = new RuntimeCacheOptions(0.0, 0.0, 0.0, 0.0, TimeSpan.Zero); + + // ACT + var snapshot = options.ToSnapshot(); + + // ASSERT + Assert.Equal(0.0, snapshot.LeftCacheSize); + Assert.Equal(0.0, snapshot.RightCacheSize); + Assert.Equal(0.0, snapshot.LeftThreshold); + Assert.Equal(0.0, snapshot.RightThreshold); + Assert.Equal(TimeSpan.Zero, snapshot.DebounceDelay); + } + + #endregion + + #region Constructor - Invalid Threshold Sum Tests + + [Theory] + [InlineData(0.6, 0.5)] + [InlineData(0.5, 0.6)] + [InlineData(1.0, 0.001)] + [InlineData(0.001, 1.0)] + public void Constructor_WithThresholdSumExceedingOne_ThrowsArgumentException( + double leftThreshold, double rightThreshold) + { + // ARRANGE & ACT + var exception = Record.Exception(() => + new RuntimeCacheOptions(1.0, 1.0, leftThreshold, rightThreshold, TimeSpan.Zero)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("sum", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_WithOnlyLeftThreshold_DoesNotValidateSum() + { + // ARRANGE & ACT — only one threshold; no sum validation applies + var exception = Record.Exception(() => + new RuntimeCacheOptions(1.0, 1.0, 0.99, null, TimeSpan.Zero)); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public void Constructor_WithOnlyRightThreshold_DoesNotValidateSum() + { + // ARRANGE & ACT + var exception = Record.Exception(() => + new RuntimeCacheOptions(1.0, 1.0, null, 0.99, TimeSpan.Zero)); + + // ASSERT + Assert.Null(exception); + } + + #endregion +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Core/State/RuntimeOptionsValidatorTests.cs b/tests/SlidingWindowCache.Unit.Tests/Core/State/RuntimeOptionsValidatorTests.cs new file mode 100644 index 0000000..f92bc16 --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Core/State/RuntimeOptionsValidatorTests.cs @@ -0,0 +1,212 @@ +using SlidingWindowCache.Core.State; + +namespace SlidingWindowCache.Unit.Tests.Core.State; + +/// +/// Unit tests for that verify all shared validation rules +/// are enforced correctly for both cache sizes and thresholds. +/// +public class RuntimeOptionsValidatorTests +{ + #region Valid Parameters Tests + + [Fact] + public void Validate_WithAllValidParameters_DoesNotThrow() + { + // ARRANGE & ACT + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(1.0, 2.0, 0.2, 0.3)); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public void Validate_WithZeroCacheSizes_DoesNotThrow() + { + // ARRANGE & ACT + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(0.0, 0.0, null, null)); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public void Validate_WithNullThresholds_DoesNotThrow() + { + // ARRANGE & ACT + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(1.0, 1.0, null, null)); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public void Validate_WithThresholdsSummingToExactlyOne_DoesNotThrow() + { + // ARRANGE & ACT + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(1.0, 1.0, 0.5, 0.5)); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public void Validate_WithZeroThresholds_DoesNotThrow() + { + // ARRANGE & ACT + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(1.0, 1.0, 0.0, 0.0)); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public void Validate_WithMaxThresholdValues_DoesNotThrow() + { + // ARRANGE & ACT + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(1.0, 1.0, 1.0, null)); + + // ASSERT + Assert.Null(exception); + } + + #endregion + + #region LeftCacheSize Validation Tests + + [Fact] + public void Validate_WithNegativeLeftCacheSize_ThrowsArgumentOutOfRangeException() + { + // ACT + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(-0.1, 1.0, null, null)); + + // ASSERT + var ex = Assert.IsType(exception); + Assert.Equal("leftCacheSize", ex.ParamName); + Assert.Contains("LeftCacheSize must be greater than or equal to 0.", ex.Message); + } + + #endregion + + #region RightCacheSize Validation Tests + + [Fact] + public void Validate_WithNegativeRightCacheSize_ThrowsArgumentOutOfRangeException() + { + // ACT + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(1.0, -0.1, null, null)); + + // ASSERT + var ex = Assert.IsType(exception); + Assert.Equal("rightCacheSize", ex.ParamName); + Assert.Contains("RightCacheSize must be greater than or equal to 0.", ex.Message); + } + + #endregion + + #region LeftThreshold Validation Tests + + [Fact] + public void Validate_WithNegativeLeftThreshold_ThrowsArgumentOutOfRangeException() + { + // ACT + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(1.0, 1.0, -0.1, null)); + + // ASSERT + var ex = Assert.IsType(exception); + Assert.Equal("leftThreshold", ex.ParamName); + Assert.Contains("LeftThreshold must be greater than or equal to 0.", ex.Message); + } + + [Fact] + public void Validate_WithLeftThresholdExceedingOne_ThrowsArgumentOutOfRangeException() + { + // ACT + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(1.0, 1.0, 1.1, null)); + + // ASSERT + var ex = Assert.IsType(exception); + Assert.Equal("leftThreshold", ex.ParamName); + Assert.Contains("LeftThreshold must not exceed 1.0.", ex.Message); + } + + #endregion + + #region RightThreshold Validation Tests + + [Fact] + public void Validate_WithNegativeRightThreshold_ThrowsArgumentOutOfRangeException() + { + // ACT + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(1.0, 1.0, null, -0.1)); + + // ASSERT + var ex = Assert.IsType(exception); + Assert.Equal("rightThreshold", ex.ParamName); + Assert.Contains("RightThreshold must be greater than or equal to 0.", ex.Message); + } + + [Fact] + public void Validate_WithRightThresholdExceedingOne_ThrowsArgumentOutOfRangeException() + { + // ACT + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(1.0, 1.0, null, 1.1)); + + // ASSERT + var ex = Assert.IsType(exception); + Assert.Equal("rightThreshold", ex.ParamName); + Assert.Contains("RightThreshold must not exceed 1.0.", ex.Message); + } + + #endregion + + #region Threshold Sum Validation Tests + + [Fact] + public void Validate_WithThresholdSumExceedingOne_ThrowsArgumentException() + { + // ACT + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(1.0, 1.0, 0.6, 0.5)); + + // ASSERT + var ex = Assert.IsType(exception); + Assert.Contains("sum", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Validate_WithOnlyLeftThresholdSet_NoSumValidation() + { + // ARRANGE & ACT — single threshold at 1.0 is valid; sum check only applies when both are non-null + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(1.0, 1.0, 1.0, null)); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public void Validate_WithOnlyRightThresholdSet_NoSumValidation() + { + // ARRANGE & ACT + var exception = Record.Exception(() => + RuntimeOptionsValidator.ValidateCacheSizesAndThresholds(1.0, 1.0, null, 1.0)); + + // ASSERT + Assert.Null(exception); + } + + #endregion +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/TaskBasedRebalanceExecutionControllerTests.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/TaskBasedRebalanceExecutionControllerTests.cs index 966c3c0..8c59d5f 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/TaskBasedRebalanceExecutionControllerTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/TaskBasedRebalanceExecutionControllerTests.cs @@ -40,7 +40,7 @@ public async Task PublishExecutionRequest_ContinuesAfterFaultedPreviousTask() var controller = new TaskBasedRebalanceExecutionController( executor, - TimeSpan.Zero, + new RuntimeCacheOptionsHolder(new RuntimeCacheOptions(0, 0, null, null, TimeSpan.Zero)), diagnostics, activityCounter ); diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/LayeredWindowCacheBuilderTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/Cache/LayeredWindowCacheBuilderTests.cs similarity index 59% rename from tests/SlidingWindowCache.Unit.Tests/Public/LayeredWindowCacheBuilderTests.cs rename to tests/SlidingWindowCache.Unit.Tests/Public/Cache/LayeredWindowCacheBuilderTests.cs index 1b6db37..18b76ae 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Public/LayeredWindowCacheBuilderTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Public/Cache/LayeredWindowCacheBuilderTests.cs @@ -1,17 +1,19 @@ using Intervals.NET.Domain.Abstractions; using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Instrumentation; using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Tests.Infrastructure.Helpers; -namespace SlidingWindowCache.Unit.Tests.Public; +namespace SlidingWindowCache.Unit.Tests.Public.Cache; /// /// Unit tests for . -/// Validates the builder API: construction, layer addition, build validation, -/// layer ordering, and the resulting . +/// Validates the builder API: construction via , +/// layer addition (pre-built options and inline lambda), build validation, layer ordering, +/// and the resulting . /// Uses as a lightweight real data source to avoid /// mocking the complex interface for these tests. /// @@ -30,15 +32,14 @@ private static WindowCacheOptions DefaultOptions( #endregion - #region Create() Tests + #region WindowCacheBuilder.Layered() — Null Guard Tests [Fact] - public void Create_WithNullDataSource_ThrowsArgumentNullException() + public void Layered_WithNullDataSource_ThrowsArgumentNullException() { // ACT var exception = Record.Exception(() => - LayeredWindowCacheBuilder - .Create(null!, Domain)); + WindowCacheBuilder.Layered(null!, Domain)); // ASSERT Assert.NotNull(exception); @@ -47,7 +48,7 @@ public void Create_WithNullDataSource_ThrowsArgumentNullException() } [Fact] - public void Create_WithNullDomain_ThrowsArgumentNullException() + public void Layered_WithNullDomain_ThrowsArgumentNullException() { // ARRANGE — TDomain must be a reference type to accept null; // use IRangeDomain as the type parameter (interface = reference type) @@ -55,8 +56,7 @@ public void Create_WithNullDomain_ThrowsArgumentNullException() // ACT var exception = Record.Exception(() => - LayeredWindowCacheBuilder> - .Create(dataSource, null!)); + WindowCacheBuilder.Layered>(dataSource, null!)); // ASSERT Assert.NotNull(exception); @@ -65,11 +65,10 @@ public void Create_WithNullDomain_ThrowsArgumentNullException() } [Fact] - public void Create_WithValidArguments_ReturnsBuilder() + public void Layered_WithValidArguments_ReturnsBuilder() { // ACT - var builder = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain); + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain); // ASSERT Assert.NotNull(builder); @@ -77,17 +76,16 @@ public void Create_WithValidArguments_ReturnsBuilder() #endregion - #region AddLayer() Tests + #region AddLayer(WindowCacheOptions) Tests [Fact] public void AddLayer_WithNullOptions_ThrowsArgumentNullException() { // ARRANGE - var builder = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain); + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain); // ACT - var exception = Record.Exception(() => builder.AddLayer(null!)); + var exception = Record.Exception(() => builder.AddLayer((WindowCacheOptions)null!)); // ASSERT Assert.NotNull(exception); @@ -99,8 +97,7 @@ public void AddLayer_WithNullOptions_ThrowsArgumentNullException() public void AddLayer_ReturnsBuilderForFluentChaining() { // ARRANGE - var builder = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain); + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain); // ACT var returned = builder.AddLayer(DefaultOptions()); @@ -113,8 +110,7 @@ public void AddLayer_ReturnsBuilderForFluentChaining() public void AddLayer_MultipleCallsReturnSameBuilder() { // ARRANGE - var builder = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain); + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain); // ACT var b1 = builder.AddLayer(DefaultOptions()); @@ -131,8 +127,7 @@ public void AddLayer_MultipleCallsReturnSameBuilder() public void AddLayer_AcceptsDiagnosticsParameter() { // ARRANGE - var builder = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain); + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain); var diagnostics = new EventCounterCacheDiagnostics(); // ACT @@ -147,8 +142,7 @@ public void AddLayer_AcceptsDiagnosticsParameter() public void AddLayer_WithNullDiagnostics_DoesNotThrow() { // ARRANGE - var builder = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain); + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain); // ACT var exception = Record.Exception(() => @@ -160,14 +154,101 @@ public void AddLayer_WithNullDiagnostics_DoesNotThrow() #endregion + #region AddLayer(Action) Tests + + [Fact] + public void AddLayer_WithNullDelegate_ThrowsArgumentNullException() + { + // ARRANGE + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain); + + // ACT + var exception = Record.Exception(() => + builder.AddLayer((Action)null!)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("configure", ((ArgumentNullException)exception).ParamName); + } + + [Fact] + public void AddLayer_WithInlineDelegate_ReturnsBuilderForFluentChaining() + { + // ARRANGE + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain); + + // ACT + var returned = builder.AddLayer(o => o.WithCacheSize(1.0)); + + // ASSERT + Assert.Same(builder, returned); + } + + [Fact] + public void AddLayer_WithInlineDelegateAndDiagnostics_DoesNotThrow() + { + // ARRANGE + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain); + var diagnostics = new EventCounterCacheDiagnostics(); + + // ACT + var exception = Record.Exception(() => + builder.AddLayer(o => o.WithCacheSize(1.0), diagnostics)); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public void AddLayer_WithInlineDelegateMissingCacheSize_ThrowsInvalidOperationException() + { + // ARRANGE — delegate does not call WithCacheSize; Build() on the inner builder throws + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain) + .AddLayer(o => o.WithReadMode(UserCacheReadMode.Snapshot)); + + // ACT — Build() on the LayeredWindowCacheBuilder triggers the options Build(), which throws + var exception = Record.Exception(() => builder.Build()); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public async Task AddLayer_InlineTwoLayers_CanFetchData() + { + // ARRANGE + await using var cache = WindowCacheBuilder.Layered(CreateDataSource(), Domain) + .AddLayer(o => o + .WithCacheSize(2.0) + .WithReadMode(UserCacheReadMode.CopyOnRead) + .WithDebounceDelay(TimeSpan.FromMilliseconds(50))) + .AddLayer(o => o + .WithCacheSize(0.5) + .WithDebounceDelay(TimeSpan.FromMilliseconds(50))) + .Build(); + + var range = Intervals.NET.Factories.Range.Closed(1, 10); + + // ACT + var result = await cache.GetDataAsync(range, CancellationToken.None); + + // ASSERT + Assert.NotNull(result); + Assert.True(result.Range.HasValue); + Assert.Equal(10, result.Data.Length); + } + + #endregion + #region Build() Tests [Fact] public void Build_WithNoLayers_ThrowsInvalidOperationException() { // ARRANGE - var builder = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain); + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain); // ACT var exception = Record.Exception(() => builder.Build()); @@ -181,63 +262,60 @@ public void Build_WithNoLayers_ThrowsInvalidOperationException() public async Task Build_WithSingleLayer_ReturnsLayeredCacheWithOneLayer() { // ARRANGE - var builder = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain); + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain); // ACT - await using var cache = builder + await using var layered = (LayeredWindowCache)builder .AddLayer(DefaultOptions()) .Build(); // ASSERT - Assert.Equal(1, cache.LayerCount); + Assert.Equal(1, layered.LayerCount); } [Fact] public async Task Build_WithTwoLayers_ReturnsLayeredCacheWithTwoLayers() { // ARRANGE - var builder = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain); + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain); // ACT - await using var cache = builder + await using var layered = (LayeredWindowCache)builder .AddLayer(new WindowCacheOptions(2.0, 2.0, UserCacheReadMode.CopyOnRead)) .AddLayer(new WindowCacheOptions(0.5, 0.5, UserCacheReadMode.Snapshot)) .Build(); // ASSERT - Assert.Equal(2, cache.LayerCount); + Assert.Equal(2, layered.LayerCount); } [Fact] public async Task Build_WithThreeLayers_ReturnsLayeredCacheWithThreeLayers() { // ARRANGE - var builder = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain); + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain); // ACT - await using var cache = builder + await using var layered = (LayeredWindowCache)builder .AddLayer(new WindowCacheOptions(5.0, 5.0, UserCacheReadMode.CopyOnRead)) .AddLayer(new WindowCacheOptions(2.0, 2.0, UserCacheReadMode.CopyOnRead)) .AddLayer(new WindowCacheOptions(0.5, 0.5, UserCacheReadMode.Snapshot)) .Build(); // ASSERT - Assert.Equal(3, cache.LayerCount); + Assert.Equal(3, layered.LayerCount); } [Fact] - public async Task Build_ReturnsLayeredWindowCacheType() + public async Task Build_ReturnsIWindowCacheImplementedByLayeredWindowCacheType() { // ARRANGE & ACT - await using var cache = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateDataSource(), Domain) .AddLayer(DefaultOptions()) .Build(); - // ASSERT + // ASSERT — Build() returns IWindowCache<>; concrete type is LayeredWindowCache<> + Assert.IsAssignableFrom>(cache); Assert.IsType>(cache); } @@ -245,8 +323,7 @@ public async Task Build_ReturnsLayeredWindowCacheType() public async Task Build_ReturnedCacheImplementsIWindowCache() { // ARRANGE & ACT - await using var cache = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateDataSource(), Domain) .AddLayer(DefaultOptions()) .Build(); @@ -258,8 +335,7 @@ public async Task Build_ReturnedCacheImplementsIWindowCache() public async Task Build_CanBeCalledMultipleTimes_ReturnsDifferentInstances() { // ARRANGE - var builder = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain) + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain) .AddLayer(DefaultOptions()); // ACT @@ -284,8 +360,7 @@ public async Task Build_SingleLayer_CanFetchData() readMode: UserCacheReadMode.Snapshot, debounceDelay: TimeSpan.FromMilliseconds(50)); - await using var cache = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateDataSource(), Domain) .AddLayer(options) .Build(); @@ -316,8 +391,7 @@ public async Task Build_TwoLayers_CanFetchData() readMode: UserCacheReadMode.Snapshot, debounceDelay: TimeSpan.FromMilliseconds(50)); - await using var cache = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateDataSource(), Domain) .AddLayer(deepOptions) .AddLayer(userOptions) .Build(); @@ -340,8 +414,7 @@ public async Task Build_WithPerLayerDiagnostics_DoesNotThrowOnFetch() var deepDiagnostics = new EventCounterCacheDiagnostics(); var userDiagnostics = new EventCounterCacheDiagnostics(); - await using var cache = LayeredWindowCacheBuilder - .Create(CreateDataSource(), Domain) + await using var cache = WindowCacheBuilder.Layered(CreateDataSource(), Domain) .AddLayer(new WindowCacheOptions(2.0, 2.0, UserCacheReadMode.CopyOnRead, debounceDelay: TimeSpan.FromMilliseconds(50)), deepDiagnostics) .AddLayer(new WindowCacheOptions(0.5, 0.5, UserCacheReadMode.Snapshot, diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/LayeredWindowCacheTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/Cache/LayeredWindowCacheTests.cs similarity index 75% rename from tests/SlidingWindowCache.Unit.Tests/Public/LayeredWindowCacheTests.cs rename to tests/SlidingWindowCache.Unit.Tests/Public/Cache/LayeredWindowCacheTests.cs index f8ba3cd..308e74e 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Public/LayeredWindowCacheTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Public/Cache/LayeredWindowCacheTests.cs @@ -1,9 +1,11 @@ using Intervals.NET.Domain.Default.Numeric; using Moq; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; +using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Dto; -namespace SlidingWindowCache.Unit.Tests.Public; +namespace SlidingWindowCache.Unit.Tests.Public.Cache; /// /// Unit tests for . @@ -15,8 +17,7 @@ public sealed class LayeredWindowCacheTests { #region Test Infrastructure - private static Mock> CreateLayerMock() - => new Mock>(MockBehavior.Strict); + private static Mock> CreateLayerMock() => new(MockBehavior.Strict); private static LayeredWindowCache CreateLayeredCache( params IWindowCache[] layers) @@ -40,7 +41,7 @@ private static RangeResult MakeResult(int start, int end) { var range = MakeRange(start, end); var data = new ReadOnlyMemory(Enumerable.Range(start, end - start + 1).ToArray()); - return new RangeResult(range, data); + return new RangeResult(range, data, CacheInteraction.FullHit); } #endregion @@ -139,7 +140,7 @@ public async Task GetDataAsync_PropagatesCancellationToken() var outerLayer = CreateLayerMock(); var range = MakeRange(10, 20); var cts = new CancellationTokenSource(); - CancellationToken capturedToken = CancellationToken.None; + var capturedToken = CancellationToken.None; var expectedResult = MakeResult(10, 20); outerLayer.Setup(c => c.GetDataAsync(range, It.IsAny())) @@ -470,6 +471,163 @@ public async Task DisposeAsync_ThreeLayers_DisposesOuterToInner() #endregion + #region Layers Property Tests + + [Fact] + public void Layers_SingleLayer_ReturnsSingleElementList() + { + // ARRANGE + var layer = CreateLayerMock(); + var cache = CreateLayeredCache(layer.Object); + + // ACT + var layers = cache.Layers; + + // ASSERT + Assert.NotNull(layers); + Assert.Single(layers); + Assert.Same(layer.Object, layers[0]); + } + + [Fact] + public void Layers_TwoLayers_ReturnsBothLayersInOrder() + { + // ARRANGE + var layer0 = CreateLayerMock(); // deepest (index 0) + var layer1 = CreateLayerMock(); // outermost (index 1) + var cache = CreateLayeredCache(layer0.Object, layer1.Object); + + // ACT + var layers = cache.Layers; + + // ASSERT + Assert.Equal(2, layers.Count); + Assert.Same(layer0.Object, layers[0]); + Assert.Same(layer1.Object, layers[1]); + } + + [Fact] + public void Layers_ThreeLayers_ReturnsAllThreeInOrder() + { + // ARRANGE + var layer0 = CreateLayerMock(); // deepest + var layer1 = CreateLayerMock(); // middle + var layer2 = CreateLayerMock(); // outermost + var cache = CreateLayeredCache(layer0.Object, layer1.Object, layer2.Object); + + // ACT + var layers = cache.Layers; + + // ASSERT + Assert.Equal(3, layers.Count); + Assert.Same(layer0.Object, layers[0]); + Assert.Same(layer1.Object, layers[1]); + Assert.Same(layer2.Object, layers[2]); + } + + [Fact] + public void Layers_CountMatchesLayerCount() + { + // ARRANGE + var layer0 = CreateLayerMock(); + var layer1 = CreateLayerMock(); + var cache = CreateLayeredCache(layer0.Object, layer1.Object); + + // ASSERT + Assert.Equal(cache.LayerCount, cache.Layers.Count); + } + + [Fact] + public async Task Layers_OutermostLayerIsUserFacing() + { + // ARRANGE — the outermost layer (last index) should be the one that GetDataAsync delegates to + var innerLayer = CreateLayerMock(); + var outerLayer = CreateLayerMock(); + var range = MakeRange(1, 10); + var expectedResult = MakeResult(1, 10); + + outerLayer.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(expectedResult); + + var cache = CreateLayeredCache(innerLayer.Object, outerLayer.Object); + + // ACT + var result = await cache.GetDataAsync(range, CancellationToken.None); + + // ASSERT — Layers[^1] is the outermost (user-facing) layer that received the call + Assert.Same(outerLayer.Object, cache.Layers[^1]); + outerLayer.Verify(c => c.GetDataAsync(range, It.IsAny()), Times.Once); + innerLayer.VerifyNoOtherCalls(); + } + + #endregion + + #region CurrentRuntimeOptions Delegation Tests + + [Fact] + public void CurrentRuntimeOptions_DelegatesToOutermostLayer() + { + // ARRANGE + var innerLayer = CreateLayerMock(); + var outerLayer = CreateLayerMock(); + var expectedSnapshot = new RuntimeOptionsSnapshot(1.5, 2.0, 0.3, 0.4, + TimeSpan.FromMilliseconds(100)); + + outerLayer.Setup(c => c.CurrentRuntimeOptions).Returns(expectedSnapshot); + + var cache = CreateLayeredCache(innerLayer.Object, outerLayer.Object); + + // ACT + var result = cache.CurrentRuntimeOptions; + + // ASSERT + Assert.Same(expectedSnapshot, result); + outerLayer.Verify(c => c.CurrentRuntimeOptions, Times.Once); + innerLayer.VerifyNoOtherCalls(); + } + + [Fact] + public void CurrentRuntimeOptions_SingleLayer_DelegatesToThatLayer() + { + // ARRANGE + var onlyLayer = CreateLayerMock(); + var expectedSnapshot = new RuntimeOptionsSnapshot(1.0, 1.0, null, null, TimeSpan.Zero); + + onlyLayer.Setup(c => c.CurrentRuntimeOptions).Returns(expectedSnapshot); + + var cache = CreateLayeredCache(onlyLayer.Object); + + // ACT + var result = cache.CurrentRuntimeOptions; + + // ASSERT + Assert.Same(expectedSnapshot, result); + onlyLayer.Verify(c => c.CurrentRuntimeOptions, Times.Once); + } + + [Fact] + public void CurrentRuntimeOptions_DoesNotReadInnerLayers() + { + // ARRANGE — only the outermost layer should be queried + var innerLayer = CreateLayerMock(); + var middleLayer = CreateLayerMock(); + var outerLayer = CreateLayerMock(); + var expectedSnapshot = new RuntimeOptionsSnapshot(2.0, 3.0, null, null, TimeSpan.Zero); + + outerLayer.Setup(c => c.CurrentRuntimeOptions).Returns(expectedSnapshot); + + var cache = CreateLayeredCache(innerLayer.Object, middleLayer.Object, outerLayer.Object); + + // ACT + _ = cache.CurrentRuntimeOptions; + + // ASSERT — inner and middle layers must not be touched + innerLayer.VerifyNoOtherCalls(); + middleLayer.VerifyNoOtherCalls(); + } + + #endregion + #region IWindowCache Interface Tests [Fact] diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/Cache/WindowCacheBuilderTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/Cache/WindowCacheBuilderTests.cs new file mode 100644 index 0000000..c50376a --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Public/Cache/WindowCacheBuilderTests.cs @@ -0,0 +1,402 @@ +using Intervals.NET.Domain.Abstractions; +using Intervals.NET.Domain.Default.Numeric; +using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; +using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Instrumentation; +using SlidingWindowCache.Tests.Infrastructure.DataSources; +using SlidingWindowCache.Tests.Infrastructure.Helpers; + +namespace SlidingWindowCache.Unit.Tests.Public.Cache; + +/// +/// Unit tests for (static entry point) and +/// (single-cache builder). +/// Validates construction, null-guard enforcement, options configuration (pre-built and inline), +/// diagnostics wiring, and the resulting . +/// Uses to avoid mocking the complex +/// interface for these tests. +/// +public sealed class WindowCacheBuilderTests +{ + #region Test Infrastructure + + private static IntegerFixedStepDomain Domain => new(); + + private static IDataSource CreateDataSource() + => new SimpleTestDataSource(i => i); + + private static WindowCacheOptions DefaultOptions( + UserCacheReadMode mode = UserCacheReadMode.Snapshot) + => TestHelpers.CreateDefaultOptions(readMode: mode); + + #endregion + + #region WindowCacheBuilder.For() — Null Guard Tests + + [Fact] + public void For_WithNullDataSource_ThrowsArgumentNullException() + { + // ACT + var exception = Record.Exception(() => + WindowCacheBuilder.For(null!, Domain)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("dataSource", ((ArgumentNullException)exception).ParamName); + } + + [Fact] + public void For_WithNullDomain_ThrowsArgumentNullException() + { + // ARRANGE — use a reference-type TDomain to allow null + var dataSource = CreateDataSource(); + + // ACT + var exception = Record.Exception(() => + WindowCacheBuilder.For>(dataSource, null!)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("domain", ((ArgumentNullException)exception).ParamName); + } + + [Fact] + public void For_WithValidArguments_ReturnsBuilder() + { + // ACT + var builder = WindowCacheBuilder.For(CreateDataSource(), Domain); + + // ASSERT + Assert.NotNull(builder); + } + + #endregion + + #region WindowCacheBuilder.Layered() — Null Guard Tests + + [Fact] + public void Layered_WithNullDataSource_ThrowsArgumentNullException() + { + // ACT + var exception = Record.Exception(() => + WindowCacheBuilder.Layered(null!, Domain)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("dataSource", ((ArgumentNullException)exception).ParamName); + } + + [Fact] + public void Layered_WithNullDomain_ThrowsArgumentNullException() + { + // ARRANGE — use a reference-type TDomain to allow null + var dataSource = CreateDataSource(); + + // ACT + var exception = Record.Exception(() => + WindowCacheBuilder.Layered>(dataSource, null!)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("domain", ((ArgumentNullException)exception).ParamName); + } + + [Fact] + public void Layered_WithValidArguments_ReturnsLayeredBuilder() + { + // ACT + var builder = WindowCacheBuilder.Layered(CreateDataSource(), Domain); + + // ASSERT + Assert.NotNull(builder); + Assert.IsType>(builder); + } + + #endregion + + #region WithOptions(WindowCacheOptions) Tests + + [Fact] + public void WithOptions_WithNullOptions_ThrowsArgumentNullException() + { + // ARRANGE + var builder = WindowCacheBuilder.For(CreateDataSource(), Domain); + + // ACT + var exception = Record.Exception(() => builder.WithOptions((WindowCacheOptions)null!)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("options", ((ArgumentNullException)exception).ParamName); + } + + [Fact] + public void WithOptions_WithValidOptions_ReturnsBuilderForFluentChaining() + { + // ARRANGE + var builder = WindowCacheBuilder.For(CreateDataSource(), Domain); + + // ACT + var returned = builder.WithOptions(DefaultOptions()); + + // ASSERT — same instance for fluent chaining + Assert.Same(builder, returned); + } + + #endregion + + #region WithOptions(Action) Tests + + [Fact] + public void WithOptions_WithNullDelegate_ThrowsArgumentNullException() + { + // ARRANGE + var builder = WindowCacheBuilder.For(CreateDataSource(), Domain); + + // ACT + var exception = Record.Exception(() => + builder.WithOptions((Action)null!)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("configure", ((ArgumentNullException)exception).ParamName); + } + + [Fact] + public void WithOptions_WithInlineDelegate_ReturnsBuilderForFluentChaining() + { + // ARRANGE + var builder = WindowCacheBuilder.For(CreateDataSource(), Domain); + + // ACT + var returned = builder.WithOptions(o => o.WithCacheSize(1.0)); + + // ASSERT + Assert.Same(builder, returned); + } + + [Fact] + public void WithOptions_WithInlineDelegateMissingCacheSize_ThrowsInvalidOperationException() + { + // ARRANGE — configure delegate that does not set cache size + var builder = WindowCacheBuilder.For(CreateDataSource(), Domain) + .WithOptions(o => o.WithReadMode(UserCacheReadMode.Snapshot)); + + // ACT — Build() internally calls delegate's Build(), which throws + var exception = Record.Exception(() => builder.Build()); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + #endregion + + #region WithDiagnostics Tests + + [Fact] + public void WithDiagnostics_WithNullDiagnostics_ThrowsArgumentNullException() + { + // ARRANGE + var builder = WindowCacheBuilder.For(CreateDataSource(), Domain); + + // ACT + var exception = Record.Exception(() => builder.WithDiagnostics(null!)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("diagnostics", ((ArgumentNullException)exception).ParamName); + } + + [Fact] + public void WithDiagnostics_WithValidDiagnostics_ReturnsBuilderForFluentChaining() + { + // ARRANGE + var builder = WindowCacheBuilder.For(CreateDataSource(), Domain); + var diagnostics = new EventCounterCacheDiagnostics(); + + // ACT + var returned = builder.WithDiagnostics(diagnostics); + + // ASSERT + Assert.Same(builder, returned); + } + + [Fact] + public void WithDiagnostics_WithoutCallingIt_DoesNotThrowOnBuild() + { + // ARRANGE — diagnostics is optional; NoOpDiagnostics.Instance should be used + var builder = WindowCacheBuilder.For(CreateDataSource(), Domain) + .WithOptions(DefaultOptions()); + + // ACT + var exception = Record.Exception(() => builder.Build()); + + // ASSERT + Assert.Null(exception); + } + + #endregion + + #region Build() Tests + + [Fact] + public void Build_WithoutOptions_ThrowsInvalidOperationException() + { + // ARRANGE + var builder = WindowCacheBuilder.For(CreateDataSource(), Domain); + + // ACT + var exception = Record.Exception(() => builder.Build()); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void Build_WithPreBuiltOptions_ReturnsNonNull() + { + // ARRANGE & ACT + var cache = WindowCacheBuilder.For(CreateDataSource(), Domain) + .WithOptions(DefaultOptions()) + .Build(); + + // ASSERT + Assert.NotNull(cache); + } + + [Fact] + public void Build_WithInlineOptions_ReturnsNonNull() + { + // ARRANGE & ACT + var cache = WindowCacheBuilder.For(CreateDataSource(), Domain) + .WithOptions(o => o.WithCacheSize(1.0)) + .Build(); + + // ASSERT + Assert.NotNull(cache); + } + + [Fact] + public async Task Build_ReturnsWindowCacheType() + { + // ARRANGE & ACT + await using var cache = WindowCacheBuilder.For(CreateDataSource(), Domain) + .WithOptions(DefaultOptions()) + .Build(); + + // ASSERT + Assert.IsType>(cache); + } + + [Fact] + public async Task Build_ReturnedCacheImplementsIWindowCache() + { + // ARRANGE & ACT + await using var cache = WindowCacheBuilder.For(CreateDataSource(), Domain) + .WithOptions(DefaultOptions()) + .Build(); + + // ASSERT + Assert.IsAssignableFrom>(cache); + } + + [Fact] + public async Task Build_CanBeCalledMultipleTimes_ReturnsDifferentInstances() + { + // ARRANGE + var builder = WindowCacheBuilder.For(CreateDataSource(), Domain) + .WithOptions(DefaultOptions()); + + // ACT + await using var cache1 = builder.Build(); + await using var cache2 = builder.Build(); + + // ASSERT — each Build() call creates a new independent instance + Assert.NotSame(cache1, cache2); + } + + #endregion + + #region End-to-End Tests + + [Fact] + public async Task Build_WithPreBuiltOptions_CanFetchData() + { + // ARRANGE + var options = new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot, + debounceDelay: TimeSpan.FromMilliseconds(50)); + + await using var cache = WindowCacheBuilder.For(CreateDataSource(), Domain) + .WithOptions(options) + .Build(); + + var range = Intervals.NET.Factories.Range.Closed(1, 10); + + // ACT + var result = await cache.GetDataAsync(range, CancellationToken.None); + + // ASSERT + Assert.NotNull(result); + Assert.True(result.Range.HasValue); + Assert.Equal(10, result.Data.Length); + } + + [Fact] + public async Task Build_WithInlineOptions_CanFetchData() + { + // ARRANGE + await using var cache = WindowCacheBuilder.For(CreateDataSource(), Domain) + .WithOptions(o => o + .WithCacheSize(1.0) + .WithReadMode(UserCacheReadMode.Snapshot) + .WithDebounceDelay(TimeSpan.FromMilliseconds(50))) + .Build(); + + var range = Intervals.NET.Factories.Range.Closed(50, 60); + + // ACT + var result = await cache.GetDataAsync(range, CancellationToken.None); + + // ASSERT + Assert.NotNull(result); + Assert.True(result.Range.HasValue); + Assert.Equal(11, result.Data.Length); + } + + [Fact] + public async Task Build_WithDiagnostics_DiagnosticsReceiveEvents() + { + // ARRANGE + var diagnostics = new EventCounterCacheDiagnostics(); + + await using var cache = WindowCacheBuilder.For(CreateDataSource(), Domain) + .WithOptions(DefaultOptions()) + .WithDiagnostics(diagnostics) + .Build(); + + var range = Intervals.NET.Factories.Range.Closed(1, 10); + + // ACT + await cache.GetDataAsync(range, CancellationToken.None); + await cache.WaitForIdleAsync(); + + // ASSERT — at least one rebalance intent must have been published + Assert.True(diagnostics.RebalanceIntentPublished >= 1, + "Diagnostics should have received at least one rebalance intent event."); + } + + #endregion +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDataSourceAdapterTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/Cache/WindowCacheDataSourceAdapterTests.cs similarity index 96% rename from tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDataSourceAdapterTests.cs rename to tests/SlidingWindowCache.Unit.Tests/Public/Cache/WindowCacheDataSourceAdapterTests.cs index c4f5536..d32ff6c 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDataSourceAdapterTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Public/Cache/WindowCacheDataSourceAdapterTests.cs @@ -2,9 +2,10 @@ using Moq; using SlidingWindowCache.Infrastructure.Collections; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Dto; -namespace SlidingWindowCache.Unit.Tests.Public; +namespace SlidingWindowCache.Unit.Tests.Public.Cache; /// /// Unit tests for . @@ -17,8 +18,7 @@ public sealed class WindowCacheDataSourceAdapterTests { #region Test Infrastructure - private static Mock> CreateCacheMock() - => new Mock>(MockBehavior.Strict); + private static Mock> CreateCacheMock() => new(MockBehavior.Strict); private static WindowCacheDataSourceAdapter CreateAdapter( IWindowCache cache) @@ -31,7 +31,7 @@ private static RangeResult MakeResult(int start, int end) { var range = MakeRange(start, end); var data = new ReadOnlyMemory(Enumerable.Range(start, end - start + 1).ToArray()); - return new RangeResult(range, data); + return new RangeResult(range, data, CacheInteraction.FullHit); } #endregion @@ -118,7 +118,7 @@ public async Task FetchAsync_DataIsLazyEnumerable_NotEagerCopy() var mock = CreateCacheMock(); var range = MakeRange(1, 5); var innerArray = new[] { 1, 2, 3, 4, 5 }; - var result = new RangeResult(range, new ReadOnlyMemory(innerArray)); + var result = new RangeResult(range, new ReadOnlyMemory(innerArray), CacheInteraction.FullHit); var adapter = CreateAdapter(mock.Object); mock.Setup(c => c.GetDataAsync(range, It.IsAny())) @@ -140,7 +140,7 @@ public async Task FetchAsync_DataEnumeratesFromMemory_ReflectsContentAtEnumerati var mock = CreateCacheMock(); var range = MakeRange(1, 5); var innerArray = new[] { 1, 2, 3, 4, 5 }; - var result = new RangeResult(range, new ReadOnlyMemory(innerArray)); + var result = new RangeResult(range, new ReadOnlyMemory(innerArray), CacheInteraction.FullHit); var adapter = CreateAdapter(mock.Object); mock.Setup(c => c.GetDataAsync(range, It.IsAny())) @@ -213,7 +213,7 @@ public async Task FetchAsync_WithNullRangeResult_ReturnsChunkWithNullRange() // ARRANGE — inner cache returns null range (out-of-bounds boundary miss) var mock = CreateCacheMock(); var range = MakeRange(9000, 9999); - var boundaryResult = new RangeResult(null, ReadOnlyMemory.Empty); + var boundaryResult = new RangeResult(null, ReadOnlyMemory.Empty, CacheInteraction.FullMiss); var adapter = CreateAdapter(mock.Object); mock.Setup(c => c.GetDataAsync(range, It.IsAny())) @@ -232,7 +232,7 @@ public async Task FetchAsync_WithNullRangeResult_ReturnsEmptyData() // ARRANGE var mock = CreateCacheMock(); var range = MakeRange(9000, 9999); - var boundaryResult = new RangeResult(null, ReadOnlyMemory.Empty); + var boundaryResult = new RangeResult(null, ReadOnlyMemory.Empty, CacheInteraction.FullMiss); var adapter = CreateAdapter(mock.Object); mock.Setup(c => c.GetDataAsync(range, It.IsAny())) @@ -253,7 +253,7 @@ public async Task FetchAsync_WithTruncatedRangeResult_ReturnsChunkWithTruncatedR var requestedRange = MakeRange(900, 1100); var truncatedRange = MakeRange(900, 999); // truncated at upper bound var truncatedData = new ReadOnlyMemory(Enumerable.Range(900, 100).ToArray()); - var truncatedResult = new RangeResult(truncatedRange, truncatedData); + var truncatedResult = new RangeResult(truncatedRange, truncatedData, CacheInteraction.PartialHit); var adapter = CreateAdapter(mock.Object); mock.Setup(c => c.GetDataAsync(requestedRange, It.IsAny())) @@ -279,7 +279,7 @@ public async Task FetchAsync_PropagatesCancellationTokenToGetDataAsync() var range = MakeRange(10, 20); var result = MakeResult(10, 20); var cts = new CancellationTokenSource(); - CancellationToken capturedToken = CancellationToken.None; + var capturedToken = CancellationToken.None; var adapter = CreateAdapter(mock.Object); mock.Setup(c => c.GetDataAsync(range, It.IsAny())) diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/Cache/WindowCacheDisposalTests.cs similarity index 99% rename from tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs rename to tests/SlidingWindowCache.Unit.Tests/Public/Cache/WindowCacheDisposalTests.cs index 276654c..f08dc8e 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Public/Cache/WindowCacheDisposalTests.cs @@ -1,9 +1,9 @@ using Intervals.NET.Domain.Default.Numeric; -using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Cache; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Tests.Infrastructure.DataSources; -namespace SlidingWindowCache.Unit.Tests.Public; +namespace SlidingWindowCache.Unit.Tests.Public.Cache; /// /// Unit tests for WindowCache disposal behavior. diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/RuntimeOptionsSnapshotTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/RuntimeOptionsSnapshotTests.cs new file mode 100644 index 0000000..659ad9c --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/RuntimeOptionsSnapshotTests.cs @@ -0,0 +1,126 @@ +using SlidingWindowCache.Public.Configuration; + +namespace SlidingWindowCache.Unit.Tests.Public.Configuration; + +/// +/// Unit tests for that verify property initialization +/// and snapshot semantics. +/// +public class RuntimeOptionsSnapshotTests +{ + #region Constructor - Property Initialization Tests + + [Fact] + public void Constructor_WithAllValues_InitializesAllProperties() + { + // ARRANGE & ACT + var snapshot = new RuntimeOptionsSnapshot( + leftCacheSize: 1.5, + rightCacheSize: 2.0, + leftThreshold: 0.3, + rightThreshold: 0.4, + debounceDelay: TimeSpan.FromMilliseconds(200) + ); + + // ASSERT + Assert.Equal(1.5, snapshot.LeftCacheSize); + Assert.Equal(2.0, snapshot.RightCacheSize); + Assert.Equal(0.3, snapshot.LeftThreshold); + Assert.Equal(0.4, snapshot.RightThreshold); + Assert.Equal(TimeSpan.FromMilliseconds(200), snapshot.DebounceDelay); + } + + [Fact] + public void Constructor_WithNullThresholds_ThresholdsAreNull() + { + // ARRANGE & ACT + var snapshot = new RuntimeOptionsSnapshot( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + leftThreshold: null, + rightThreshold: null, + debounceDelay: TimeSpan.Zero + ); + + // ASSERT + Assert.Null(snapshot.LeftThreshold); + Assert.Null(snapshot.RightThreshold); + } + + [Fact] + public void Constructor_WithZeroValues_InitializesZeroProperties() + { + // ARRANGE & ACT + var snapshot = new RuntimeOptionsSnapshot(0.0, 0.0, 0.0, 0.0, TimeSpan.Zero); + + // ASSERT + Assert.Equal(0.0, snapshot.LeftCacheSize); + Assert.Equal(0.0, snapshot.RightCacheSize); + Assert.Equal(0.0, snapshot.LeftThreshold); + Assert.Equal(0.0, snapshot.RightThreshold); + Assert.Equal(TimeSpan.Zero, snapshot.DebounceDelay); + } + + [Fact] + public void Constructor_WithOnlyLeftThreshold_RightThresholdIsNull() + { + // ARRANGE & ACT + var snapshot = new RuntimeOptionsSnapshot(1.0, 1.0, 0.25, null, TimeSpan.Zero); + + // ASSERT + Assert.Equal(0.25, snapshot.LeftThreshold); + Assert.Null(snapshot.RightThreshold); + } + + [Fact] + public void Constructor_WithOnlyRightThreshold_LeftThresholdIsNull() + { + // ARRANGE & ACT + var snapshot = new RuntimeOptionsSnapshot(1.0, 1.0, null, 0.25, TimeSpan.Zero); + + // ASSERT + Assert.Null(snapshot.LeftThreshold); + Assert.Equal(0.25, snapshot.RightThreshold); + } + + [Fact] + public void Constructor_WithLargeDebounceDelay_StoredCorrectly() + { + // ARRANGE & ACT + var delay = TimeSpan.FromSeconds(30); + var snapshot = new RuntimeOptionsSnapshot(1.0, 1.0, null, null, delay); + + // ASSERT + Assert.Equal(delay, snapshot.DebounceDelay); + } + + #endregion + + #region Property Immutability Tests + + [Fact] + public void Properties_AreReadOnly_NoSetterAvailable() + { + // ASSERT — verify all properties are get-only (compile-time guarantee via type system) + var type = typeof(RuntimeOptionsSnapshot); + + foreach (var prop in type.GetProperties()) + { + Assert.True(prop.CanRead, $"Property '{prop.Name}' should be readable."); + Assert.False(prop.CanWrite, $"Property '{prop.Name}' should NOT be writable."); + } + } + + #endregion + + #region Type Tests + + [Fact] + public void RuntimeOptionsSnapshot_IsSealed() + { + // ASSERT — ensure no subclassing + Assert.True(typeof(RuntimeOptionsSnapshot).IsSealed); + } + + #endregion +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/RuntimeOptionsUpdateBuilderTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/RuntimeOptionsUpdateBuilderTests.cs new file mode 100644 index 0000000..9bc1e19 --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/RuntimeOptionsUpdateBuilderTests.cs @@ -0,0 +1,231 @@ +using SlidingWindowCache.Core.State; +using SlidingWindowCache.Public.Configuration; + +namespace SlidingWindowCache.Unit.Tests.Public.Configuration; + +/// +/// Unit tests for verifying fluent API, +/// partial-update semantics, and threshold clear/set distinctions. +/// +public class RuntimeOptionsUpdateBuilderTests +{ + private static RuntimeCacheOptions BaseOptions() => new(1.0, 2.0, 0.1, 0.2, TimeSpan.FromMilliseconds(50)); + + #region Builder Method Tests — WithLeftCacheSize + + [Fact] + public void ApplyTo_WithLeftCacheSizeSet_ChangesOnlyLeftCacheSize() + { + var builder = new RuntimeOptionsUpdateBuilder(); + builder.WithLeftCacheSize(5.0); + + var result = builder.ApplyTo(BaseOptions()); + + Assert.Equal(5.0, result.LeftCacheSize); + Assert.Equal(2.0, result.RightCacheSize); // unchanged + Assert.Equal(0.1, result.LeftThreshold); // unchanged + Assert.Equal(0.2, result.RightThreshold); // unchanged + Assert.Equal(TimeSpan.FromMilliseconds(50), result.DebounceDelay); // unchanged + } + + #endregion + + #region Builder Method Tests — WithRightCacheSize + + [Fact] + public void ApplyTo_WithRightCacheSizeSet_ChangesOnlyRightCacheSize() + { + var builder = new RuntimeOptionsUpdateBuilder(); + builder.WithRightCacheSize(7.0); + + var result = builder.ApplyTo(BaseOptions()); + + Assert.Equal(1.0, result.LeftCacheSize); // unchanged + Assert.Equal(7.0, result.RightCacheSize); + Assert.Equal(0.1, result.LeftThreshold); // unchanged + Assert.Equal(0.2, result.RightThreshold); // unchanged + Assert.Equal(TimeSpan.FromMilliseconds(50), result.DebounceDelay); // unchanged + } + + #endregion + + #region Builder Method Tests — WithLeftThreshold / ClearLeftThreshold + + [Fact] + public void ApplyTo_WithLeftThresholdSet_UpdatesLeftThreshold() + { + var builder = new RuntimeOptionsUpdateBuilder(); + builder.WithLeftThreshold(0.3); + + var result = builder.ApplyTo(BaseOptions()); + + Assert.Equal(0.3, result.LeftThreshold); + Assert.Equal(0.2, result.RightThreshold); // unchanged + } + + [Fact] + public void ApplyTo_ClearLeftThreshold_SetsLeftThresholdToNull() + { + var builder = new RuntimeOptionsUpdateBuilder(); + builder.ClearLeftThreshold(); + + var result = builder.ApplyTo(BaseOptions()); + + Assert.Null(result.LeftThreshold); + Assert.Equal(0.2, result.RightThreshold); // unchanged + } + + [Fact] + public void ApplyTo_LeftThresholdNotSet_KeepsCurrentValue() + { + // No threshold method called on builder + var builder = new RuntimeOptionsUpdateBuilder(); + builder.WithLeftCacheSize(3.0); // only set left cache size + + var result = builder.ApplyTo(BaseOptions()); + + Assert.Equal(0.1, result.LeftThreshold); // unchanged from base + } + + #endregion + + #region Builder Method Tests — WithRightThreshold / ClearRightThreshold + + [Fact] + public void ApplyTo_WithRightThresholdSet_UpdatesRightThreshold() + { + var builder = new RuntimeOptionsUpdateBuilder(); + builder.WithRightThreshold(0.35); + + var result = builder.ApplyTo(BaseOptions()); + + Assert.Equal(0.35, result.RightThreshold); + Assert.Equal(0.1, result.LeftThreshold); // unchanged + } + + [Fact] + public void ApplyTo_ClearRightThreshold_SetsRightThresholdToNull() + { + var builder = new RuntimeOptionsUpdateBuilder(); + builder.ClearRightThreshold(); + + var result = builder.ApplyTo(BaseOptions()); + + Assert.Null(result.RightThreshold); + Assert.Equal(0.1, result.LeftThreshold); // unchanged + } + + [Fact] + public void ApplyTo_RightThresholdNotSet_KeepsCurrentValue() + { + var builder = new RuntimeOptionsUpdateBuilder(); + // Only change debounce + builder.WithDebounceDelay(TimeSpan.Zero); + + var result = builder.ApplyTo(BaseOptions()); + + Assert.Equal(0.2, result.RightThreshold); // unchanged from base + } + + #endregion + + #region Builder Method Tests — WithDebounceDelay + + [Fact] + public void ApplyTo_WithDebounceDelaySet_ChangesOnlyDebounceDelay() + { + var builder = new RuntimeOptionsUpdateBuilder(); + builder.WithDebounceDelay(TimeSpan.FromSeconds(1)); + + var result = builder.ApplyTo(BaseOptions()); + + Assert.Equal(TimeSpan.FromSeconds(1), result.DebounceDelay); + Assert.Equal(1.0, result.LeftCacheSize); // unchanged + Assert.Equal(2.0, result.RightCacheSize); // unchanged + } + + #endregion + + #region Builder Fluent Chaining Tests + + [Fact] + public void ApplyTo_FluentChain_AppliesAllChanges() + { + var base_ = new RuntimeCacheOptions(1.0, 1.0, null, null, TimeSpan.Zero); + var builder = new RuntimeOptionsUpdateBuilder(); + builder + .WithLeftCacheSize(2.0) + .WithRightCacheSize(3.0) + .WithLeftThreshold(0.1) + .WithRightThreshold(0.15) + .WithDebounceDelay(TimeSpan.FromMilliseconds(75)); + + var result = builder.ApplyTo(base_); + + Assert.Equal(2.0, result.LeftCacheSize); + Assert.Equal(3.0, result.RightCacheSize); + Assert.Equal(0.1, result.LeftThreshold); + Assert.Equal(0.15, result.RightThreshold); + Assert.Equal(TimeSpan.FromMilliseconds(75), result.DebounceDelay); + } + + [Fact] + public void ApplyTo_EmptyBuilder_ReturnsSnapshotWithAllCurrentValues() + { + var base_ = BaseOptions(); + var builder = new RuntimeOptionsUpdateBuilder(); + + var result = builder.ApplyTo(base_); + + Assert.Equal(base_.LeftCacheSize, result.LeftCacheSize); + Assert.Equal(base_.RightCacheSize, result.RightCacheSize); + Assert.Equal(base_.LeftThreshold, result.LeftThreshold); + Assert.Equal(base_.RightThreshold, result.RightThreshold); + Assert.Equal(base_.DebounceDelay, result.DebounceDelay); + } + + #endregion + + #region Builder Validation via ApplyTo Tests + + [Fact] + public void ApplyTo_WithInvalidMergedCacheSize_ThrowsArgumentOutOfRangeException() + { + var builder = new RuntimeOptionsUpdateBuilder(); + builder.WithLeftCacheSize(-1.0); + + var exception = Record.Exception(() => builder.ApplyTo(BaseOptions())); + + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void ApplyTo_WithThresholdSumExceedingOne_ThrowsArgumentException() + { + // Base has leftThreshold=0.1; set right=0.95 → sum=1.05 + var builder = new RuntimeOptionsUpdateBuilder(); + builder.WithRightThreshold(0.95); + + var exception = Record.Exception(() => builder.ApplyTo(BaseOptions())); + + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void WithDebounceDelay_WithNegativeValue_ThrowsArgumentOutOfRangeException() + { + // ARRANGE + var builder = new RuntimeOptionsUpdateBuilder(); + + // ACT + var exception = Record.Exception(() => builder.WithDebounceDelay(TimeSpan.FromMilliseconds(-1))); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + #endregion +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsBuilderTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsBuilderTests.cs new file mode 100644 index 0000000..bb1105f --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsBuilderTests.cs @@ -0,0 +1,495 @@ +using SlidingWindowCache.Public.Configuration; + +namespace SlidingWindowCache.Unit.Tests.Public.Configuration; + +/// +/// Unit tests for that verify fluent API, +/// default values, required-field enforcement, and output. +/// +public class WindowCacheOptionsBuilderTests +{ + #region Build() — Required Fields Tests + + [Fact] + public void Build_WithoutCacheSize_ThrowsInvalidOperationException() + { + // ARRANGE + var builder = new WindowCacheOptionsBuilder(); + + // ACT + var exception = Record.Exception(() => builder.Build()); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void Build_WithOnlyLeftCacheSize_ThrowsInvalidOperationException() + { + // ARRANGE + var builder = new WindowCacheOptionsBuilder().WithLeftCacheSize(1.0); + + // ACT + var exception = Record.Exception(() => builder.Build()); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void Build_WithOnlyRightCacheSize_ThrowsInvalidOperationException() + { + // ARRANGE + var builder = new WindowCacheOptionsBuilder().WithRightCacheSize(1.0); + + // ACT + var exception = Record.Exception(() => builder.Build()); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void Build_WithBothCacheSizesSet_DoesNotThrow() + { + // ARRANGE + var builder = new WindowCacheOptionsBuilder() + .WithLeftCacheSize(1.0) + .WithRightCacheSize(2.0); + + // ACT + var exception = Record.Exception(() => builder.Build()); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public void Build_WithSymmetricCacheSize_DoesNotThrow() + { + // ARRANGE + var builder = new WindowCacheOptionsBuilder().WithCacheSize(1.5); + + // ACT + var exception = Record.Exception(() => builder.Build()); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public void Build_WithAsymmetricCacheSize_DoesNotThrow() + { + // ARRANGE + var builder = new WindowCacheOptionsBuilder().WithCacheSize(1.0, 2.0); + + // ACT + var exception = Record.Exception(() => builder.Build()); + + // ASSERT + Assert.Null(exception); + } + + #endregion + + #region WithLeftCacheSize / WithRightCacheSize Tests + + [Fact] + public void Build_WithLeftAndRightCacheSize_SetsCorrectValues() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithLeftCacheSize(1.5) + .WithRightCacheSize(3.0) + .Build(); + + // ASSERT + Assert.Equal(1.5, options.LeftCacheSize); + Assert.Equal(3.0, options.RightCacheSize); + } + + [Fact] + public void Build_WithSymmetricCacheSize_SetsBothSides() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(2.0) + .Build(); + + // ASSERT + Assert.Equal(2.0, options.LeftCacheSize); + Assert.Equal(2.0, options.RightCacheSize); + } + + [Fact] + public void Build_WithAsymmetricCacheSize_SetsBothSidesIndependently() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(0.5, 4.0) + .Build(); + + // ASSERT + Assert.Equal(0.5, options.LeftCacheSize); + Assert.Equal(4.0, options.RightCacheSize); + } + + [Fact] + public void Build_WithZeroCacheSize_DoesNotThrow() + { + // ARRANGE & ACT + var exception = Record.Exception(() => + new WindowCacheOptionsBuilder().WithCacheSize(0.0).Build()); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public void Build_WithNegativeCacheSize_ThrowsArgumentOutOfRangeException() + { + // ARRANGE + var builder = new WindowCacheOptionsBuilder().WithCacheSize(-1.0); + + // ACT + var exception = Record.Exception(() => builder.Build()); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void Build_WithNegativeLeftCacheSize_ThrowsArgumentOutOfRangeException() + { + // ARRANGE + var builder = new WindowCacheOptionsBuilder() + .WithLeftCacheSize(-0.5) + .WithRightCacheSize(1.0); + + // ACT + var exception = Record.Exception(() => builder.Build()); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + #endregion + + #region WithReadMode Tests + + [Fact] + public void Build_DefaultReadMode_IsSnapshot() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .Build(); + + // ASSERT + Assert.Equal(UserCacheReadMode.Snapshot, options.ReadMode); + } + + [Fact] + public void Build_WithReadModeCopyOnRead_SetsCopyOnRead() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .WithReadMode(UserCacheReadMode.CopyOnRead) + .Build(); + + // ASSERT + Assert.Equal(UserCacheReadMode.CopyOnRead, options.ReadMode); + } + + [Fact] + public void Build_WithReadModeSnapshot_SetsSnapshot() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .WithReadMode(UserCacheReadMode.Snapshot) + .Build(); + + // ASSERT + Assert.Equal(UserCacheReadMode.Snapshot, options.ReadMode); + } + + #endregion + + #region WithThresholds Tests + + [Fact] + public void Build_WithoutThresholds_ThresholdsAreNull() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .Build(); + + // ASSERT + Assert.Null(options.LeftThreshold); + Assert.Null(options.RightThreshold); + } + + [Fact] + public void Build_WithSymmetricThresholds_SetsBothSides() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .WithThresholds(0.2) + .Build(); + + // ASSERT + Assert.Equal(0.2, options.LeftThreshold); + Assert.Equal(0.2, options.RightThreshold); + } + + [Fact] + public void Build_WithLeftThresholdOnly_SetsLeftAndRightIsNull() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .WithLeftThreshold(0.3) + .Build(); + + // ASSERT + Assert.Equal(0.3, options.LeftThreshold); + Assert.Null(options.RightThreshold); + } + + [Fact] + public void Build_WithRightThresholdOnly_SetsRightAndLeftIsNull() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .WithRightThreshold(0.25) + .Build(); + + // ASSERT + Assert.Null(options.LeftThreshold); + Assert.Equal(0.25, options.RightThreshold); + } + + [Fact] + public void Build_WithBothThresholdsIndependently_SetsBothCorrectly() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .WithLeftThreshold(0.1) + .WithRightThreshold(0.15) + .Build(); + + // ASSERT + Assert.Equal(0.1, options.LeftThreshold); + Assert.Equal(0.15, options.RightThreshold); + } + + [Fact] + public void Build_WithThresholdSumExceedingOne_ThrowsArgumentException() + { + // ARRANGE — 0.6 + 0.6 = 1.2 > 1.0 + var builder = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .WithThresholds(0.6); + + // ACT + var exception = Record.Exception(() => builder.Build()); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + [Fact] + public void Build_WithZeroThresholds_SetsZero() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .WithThresholds(0.0) + .Build(); + + // ASSERT + Assert.Equal(0.0, options.LeftThreshold); + Assert.Equal(0.0, options.RightThreshold); + } + + #endregion + + #region WithDebounceDelay Tests + + [Fact] + public void Build_WithDebounceDelay_SetsCorrectValue() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .WithDebounceDelay(TimeSpan.FromMilliseconds(250)) + .Build(); + + // ASSERT + Assert.Equal(TimeSpan.FromMilliseconds(250), options.DebounceDelay); + } + + [Fact] + public void Build_WithZeroDebounceDelay_SetsZero() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .WithDebounceDelay(TimeSpan.Zero) + .Build(); + + // ASSERT + Assert.Equal(TimeSpan.Zero, options.DebounceDelay); + } + + [Fact] + public void WithDebounceDelay_WithNegativeValue_ThrowsArgumentOutOfRangeException() + { + // ARRANGE + var builder = new WindowCacheOptionsBuilder().WithCacheSize(1.0); + + // ACT + var exception = Record.Exception(() => builder.WithDebounceDelay(TimeSpan.FromMilliseconds(-1))); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + #endregion + + #region WithRebalanceQueueCapacity Tests + + [Fact] + public void Build_WithRebalanceQueueCapacity_SetsCorrectValue() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .WithRebalanceQueueCapacity(10) + .Build(); + + // ASSERT + Assert.Equal(10, options.RebalanceQueueCapacity); + } + + [Fact] + public void Build_WithoutRebalanceQueueCapacity_CapacityIsNull() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .Build(); + + // ASSERT + Assert.Null(options.RebalanceQueueCapacity); + } + + #endregion + + #region Fluent Chaining Tests + + [Fact] + public void FluentMethods_ReturnSameBuilderInstance() + { + // ARRANGE + var builder = new WindowCacheOptionsBuilder(); + + // ACT & ASSERT — each method returns the same instance + Assert.Same(builder, builder.WithLeftCacheSize(1.0)); + Assert.Same(builder, builder.WithRightCacheSize(1.0)); + Assert.Same(builder, builder.WithReadMode(UserCacheReadMode.Snapshot)); + Assert.Same(builder, builder.WithLeftThreshold(0.1)); + Assert.Same(builder, builder.WithRightThreshold(0.1)); + Assert.Same(builder, builder.WithDebounceDelay(TimeSpan.Zero)); + Assert.Same(builder, builder.WithRebalanceQueueCapacity(5)); + } + + [Fact] + public void Build_FullFluentChain_ProducesCorrectOptions() + { + // ARRANGE & ACT + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.5, 3.0) + .WithReadMode(UserCacheReadMode.CopyOnRead) + .WithLeftThreshold(0.1) + .WithRightThreshold(0.15) + .WithDebounceDelay(TimeSpan.FromMilliseconds(200)) + .WithRebalanceQueueCapacity(8) + .Build(); + + // ASSERT + Assert.Equal(1.5, options.LeftCacheSize); + Assert.Equal(3.0, options.RightCacheSize); + Assert.Equal(UserCacheReadMode.CopyOnRead, options.ReadMode); + Assert.Equal(0.1, options.LeftThreshold); + Assert.Equal(0.15, options.RightThreshold); + Assert.Equal(TimeSpan.FromMilliseconds(200), options.DebounceDelay); + Assert.Equal(8, options.RebalanceQueueCapacity); + } + + [Fact] + public void Build_LatestCallWins_CacheSizeOverwrite() + { + // ARRANGE — set size twice; last call should win + var options = new WindowCacheOptionsBuilder() + .WithCacheSize(1.0) + .WithCacheSize(5.0) + .Build(); + + // ASSERT + Assert.Equal(5.0, options.LeftCacheSize); + Assert.Equal(5.0, options.RightCacheSize); + } + + [Fact] + public void Build_WithCacheSizeAfterLeftRight_OverwritesBothSides() + { + // ARRANGE — WithCacheSize(double) after WithLeftCacheSize/WithRightCacheSize overwrites both + var options = new WindowCacheOptionsBuilder() + .WithLeftCacheSize(1.0) + .WithRightCacheSize(2.0) + .WithCacheSize(3.0) + .Build(); + + // ASSERT + Assert.Equal(3.0, options.LeftCacheSize); + Assert.Equal(3.0, options.RightCacheSize); + } + + #endregion + + #region Type Tests + + [Fact] + public void WindowCacheOptionsBuilder_IsSealed() + { + // ASSERT + Assert.True(typeof(WindowCacheOptionsBuilder).IsSealed); + } + + [Fact] + public void WindowCacheOptionsBuilder_HasPublicParameterlessConstructor() + { + // ASSERT — verifies standalone usability + var ctor = typeof(WindowCacheOptionsBuilder) + .GetConstructor(Type.EmptyTypes); + + Assert.NotNull(ctor); + Assert.True(ctor!.IsPublic); + } + + #endregion +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsTests.cs index 50928c6..45ed15a 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsTests.cs @@ -324,6 +324,25 @@ public void Constructor_WithVerySmallNegativeRightCacheSize_ThrowsArgumentOutOfR Assert.Equal("rightCacheSize", exception.ParamName); } + [Fact] + public void Constructor_WithNegativeDebounceDelay_ThrowsArgumentOutOfRangeException() + { + // ARRANGE, ACT & ASSERT + var exception = Record.Exception(() => + new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot, + debounceDelay: TimeSpan.FromMilliseconds(-1) + ) + ); + + Assert.NotNull(exception); + Assert.IsType(exception); + var argException = (ArgumentOutOfRangeException)exception; + Assert.Equal("debounceDelay", argException.ParamName); + } + #endregion #region Constructor - Threshold Sum Validation Tests @@ -476,10 +495,10 @@ public void Constructor_WithSlightlyExceedingThresholdSum_ThrowsArgumentExceptio #endregion - #region Record Equality Tests + #region Value Equality Tests [Fact] - public void RecordEquality_WithSameValues_AreEqual() + public void Equality_WithSameValues_AreEqual() { // ARRANGE var options1 = new WindowCacheOptions( @@ -507,7 +526,37 @@ public void RecordEquality_WithSameValues_AreEqual() } [Fact] - public void RecordEquality_WithDifferentLeftCacheSize_AreNotEqual() + public void Equality_SameInstance_IsEqual() + { + // ARRANGE + var options = new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot + ); + + // ACT & ASSERT + Assert.Equal(options, options); + } + + [Fact] + public void Equality_WithNull_IsNotEqual() + { + // ARRANGE + var options = new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot + ); + + // ACT & ASSERT + Assert.False(options.Equals(null)); + Assert.False(options == null); + Assert.True(options != null); + } + + [Fact] + public void Equality_WithDifferentLeftCacheSize_AreNotEqual() { // ARRANGE var options1 = new WindowCacheOptions( @@ -529,7 +578,7 @@ public void RecordEquality_WithDifferentLeftCacheSize_AreNotEqual() } [Fact] - public void RecordEquality_WithDifferentRightCacheSize_AreNotEqual() + public void Equality_WithDifferentRightCacheSize_AreNotEqual() { // ARRANGE var options1 = new WindowCacheOptions( @@ -549,7 +598,7 @@ public void RecordEquality_WithDifferentRightCacheSize_AreNotEqual() } [Fact] - public void RecordEquality_WithDifferentReadMode_AreNotEqual() + public void Equality_WithDifferentReadMode_AreNotEqual() { // ARRANGE var options1 = new WindowCacheOptions( @@ -569,7 +618,7 @@ public void RecordEquality_WithDifferentReadMode_AreNotEqual() } [Fact] - public void RecordEquality_WithDifferentThresholds_AreNotEqual() + public void Equality_WithDifferentThresholds_AreNotEqual() { // ARRANGE var options1 = new WindowCacheOptions( @@ -591,7 +640,7 @@ public void RecordEquality_WithDifferentThresholds_AreNotEqual() } [Fact] - public void RecordEquality_WithDifferentRebalanceQueueCapacity_AreNotEqual() + public void Equality_WithDifferentRebalanceQueueCapacity_AreNotEqual() { // ARRANGE var options1 = new WindowCacheOptions( @@ -615,7 +664,7 @@ public void RecordEquality_WithDifferentRebalanceQueueCapacity_AreNotEqual() } [Fact] - public void RecordEquality_WithDifferentDebounceDelay_AreNotEqual() + public void Equality_WithDifferentDebounceDelay_AreNotEqual() { // ARRANGE var options1 = new WindowCacheOptions( diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/Extensions/WindowCacheConsistencyExtensionsTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/Extensions/WindowCacheConsistencyExtensionsTests.cs new file mode 100644 index 0000000..9b01fb5 --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Public/Extensions/WindowCacheConsistencyExtensionsTests.cs @@ -0,0 +1,945 @@ +using Intervals.NET.Domain.Default.Numeric; +using Moq; +using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Dto; +using SlidingWindowCache.Public.Extensions; + +namespace SlidingWindowCache.Unit.Tests.Public.Extensions; + +/// +/// Unit tests for +/// and . +/// Validates the composition contracts, conditional idle-wait behaviour, result passthrough, +/// cancellation propagation, and exception semantics. +/// Uses mocked to isolate the extension methods +/// from any real cache implementation. +/// +public sealed class WindowCacheConsistencyExtensionsTests +{ + #region Test Infrastructure + + private static Mock> CreateMock() => new(MockBehavior.Strict); + + private static Intervals.NET.Range CreateRange(int start, int end) + => Intervals.NET.Factories.Range.Closed(start, end); + + private static RangeResult CreateRangeResult(int start, int end, + CacheInteraction interaction = CacheInteraction.FullHit) + { + var range = CreateRange(start, end); + var data = new ReadOnlyMemory(Enumerable.Range(start, end - start + 1).ToArray()); + return new RangeResult(range, data, interaction); + } + + private static RangeResult CreateNullRangeResult(CacheInteraction interaction) => + new(null, ReadOnlyMemory.Empty, interaction); + + #endregion + + #region Composition Contract Tests + + [Fact] + public async Task GetDataAndWaitForIdleAsync_CallsGetDataAsyncFirst() + { + // ARRANGE + var mock = CreateMock(); + var callOrder = new List(); + var range = CreateRange(100, 110); + var expectedResult = CreateRangeResult(100, 110); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .Returns(() => + { + callOrder.Add("GetDataAsync"); + return ValueTask.FromResult(expectedResult); + }); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(() => + { + callOrder.Add("WaitForIdleAsync"); + return Task.CompletedTask; + }); + + // ACT + await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None); + + // ASSERT + Assert.Equal(2, callOrder.Count); + Assert.Equal("GetDataAsync", callOrder[0]); + Assert.Equal("WaitForIdleAsync", callOrder[1]); + } + + [Fact] + public async Task GetDataAndWaitForIdleAsync_ReturnsResultFromGetDataAsync() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var expectedResult = CreateRangeResult(100, 110); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(expectedResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // ACT + var result = await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None); + + // ASSERT + Assert.Equal(expectedResult.Range, result.Range); + Assert.Equal(expectedResult.Data.Length, result.Data.Length); + Assert.True(expectedResult.Data.Span.SequenceEqual(result.Data.Span)); + } + + [Fact] + public async Task GetDataAndWaitForIdleAsync_CallsBothMethodsExactlyOnce() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var expectedResult = CreateRangeResult(100, 110); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(expectedResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // ACT + await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None); + + // ASSERT + mock.Verify(c => c.GetDataAsync(range, It.IsAny()), Times.Once); + mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetDataAndWaitForIdleAsync_WithNullResultRange_ReturnsNullRange() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var nullRangeResult = new RangeResult(null, ReadOnlyMemory.Empty, CacheInteraction.FullMiss); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(nullRangeResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // ACT + var result = await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None); + + // ASSERT + Assert.Null(result.Range); + Assert.Equal(0, result.Data.Length); + } + + #endregion + + #region Cancellation Token Propagation Tests + + [Fact] + public async Task GetDataAndWaitForIdleAsync_PropagatesCancellationTokenToGetDataAsync() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var expectedResult = CreateRangeResult(100, 110); + var cts = new CancellationTokenSource(); + var capturedToken = CancellationToken.None; + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .Returns, CancellationToken>((_, ct) => + { + capturedToken = ct; + return ValueTask.FromResult(expectedResult); + }); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // ACT + await mock.Object.GetDataAndWaitForIdleAsync(range, cts.Token); + + // ASSERT + Assert.Equal(cts.Token, capturedToken); + } + + [Fact] + public async Task GetDataAndWaitForIdleAsync_PropagatesCancellationTokenToWaitForIdleAsync() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var expectedResult = CreateRangeResult(100, 110); + var cts = new CancellationTokenSource(); + var capturedToken = CancellationToken.None; + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(expectedResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(ct => + { + capturedToken = ct; + return Task.CompletedTask; + }); + + // ACT + await mock.Object.GetDataAndWaitForIdleAsync(range, cts.Token); + + // ASSERT + Assert.Equal(cts.Token, capturedToken); + } + + [Fact] + public async Task GetDataAndWaitForIdleAsync_UsesSameCancellationTokenForBothCalls() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var expectedResult = CreateRangeResult(100, 110); + var cts = new CancellationTokenSource(); + var capturedGetDataToken = CancellationToken.None; + var capturedWaitToken = CancellationToken.None; + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .Returns, CancellationToken>((_, ct) => + { + capturedGetDataToken = ct; + return ValueTask.FromResult(expectedResult); + }); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(ct => + { + capturedWaitToken = ct; + return Task.CompletedTask; + }); + + // ACT + await mock.Object.GetDataAndWaitForIdleAsync(range, cts.Token); + + // ASSERT - Same token passed to both + Assert.Equal(cts.Token, capturedGetDataToken); + Assert.Equal(cts.Token, capturedWaitToken); + Assert.Equal(capturedGetDataToken, capturedWaitToken); + } + + [Fact] + public async Task GetDataAndWaitForIdleAsync_DefaultCancellationToken_IsNone() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var expectedResult = CreateRangeResult(100, 110); + var capturedGetDataToken = new CancellationToken(true); // start with non-None value + var capturedWaitToken = new CancellationToken(true); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .Returns, CancellationToken>((_, ct) => + { + capturedGetDataToken = ct; + return ValueTask.FromResult(expectedResult); + }); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(ct => + { + capturedWaitToken = ct; + return Task.CompletedTask; + }); + + // ACT — no cancellationToken argument (uses default) + await mock.Object.GetDataAndWaitForIdleAsync(range); + + // ASSERT + Assert.Equal(CancellationToken.None, capturedGetDataToken); + Assert.Equal(CancellationToken.None, capturedWaitToken); + } + + #endregion + + #region Exception Propagation Tests + + [Fact] + public async Task GetDataAndWaitForIdleAsync_GetDataAsyncThrows_ExceptionPropagatesWithoutCallingWaitForIdleAsync() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var expectedException = new InvalidOperationException("GetDataAsync failed"); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ThrowsAsync(expectedException); + + // WaitForIdleAsync should NOT be set up — MockBehavior.Strict will throw if called + + // ACT + var exception = await Record.ExceptionAsync( + async () => await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None)); + + // ASSERT + Assert.NotNull(exception); + Assert.Same(expectedException, exception); + mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetDataAndWaitForIdleAsync_GetDataAsyncThrowsObjectDisposedException_Propagates() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ThrowsAsync(new ObjectDisposedException("cache")); + + // ACT + var exception = await Record.ExceptionAsync( + async () => await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetDataAndWaitForIdleAsync_GetDataAsyncCancelled_Propagates() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cts.Token)); + + // ACT + var exception = await Record.ExceptionAsync( + async () => await mock.Object.GetDataAndWaitForIdleAsync(range, cts.Token)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetDataAndWaitForIdleAsync_WaitForIdleAsyncThrows_ExceptionPropagates() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var expectedResult = CreateRangeResult(100, 110); + var expectedException = new InvalidOperationException("WaitForIdleAsync failed"); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(expectedResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .ThrowsAsync(expectedException); + + // ACT + var exception = await Record.ExceptionAsync( + async () => await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None)); + + // ASSERT + Assert.NotNull(exception); + Assert.Same(expectedException, exception); + } + + [Fact] + public async Task GetDataAndWaitForIdleAsync_WaitForIdleAsyncCancelled_ReturnsResultGracefully() + { + // ARRANGE — cancelling during the idle wait must NOT discard the obtained data; + // the method degrades gracefully to eventual consistency and returns the result. + var mock = CreateMock(); + var range = CreateRange(100, 110); + var expectedResult = CreateRangeResult(100, 110); + var cts = new CancellationTokenSource(); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(expectedResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cts.Token)); + + // ACT + var exception = await Record.ExceptionAsync( + async () => await mock.Object.GetDataAndWaitForIdleAsync(range, cts.Token)); + + // ASSERT — no exception; result returned gracefully + Assert.Null(exception); + } + + [Fact] + public async Task GetDataAndWaitForIdleAsync_WaitForIdleAsyncCancelled_ReturnsOriginalData() + { + // ARRANGE — the returned result must be identical to what GetDataAsync produced, + // preserving Range, Data, and CacheInteraction unchanged. + var mock = CreateMock(); + var range = CreateRange(100, 110); + var expectedResult = CreateRangeResult(100, 110, CacheInteraction.FullHit); + var cts = new CancellationTokenSource(); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(expectedResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cts.Token)); + + // ACT + var result = await mock.Object.GetDataAndWaitForIdleAsync(range, cts.Token); + + // ASSERT — same Range, Data, and CacheInteraction as GetDataAsync returned + Assert.Equal(expectedResult.Range, result.Range); + Assert.True(expectedResult.Data.Span.SequenceEqual(result.Data.Span)); + Assert.Equal(expectedResult.CacheInteraction, result.CacheInteraction); + } + + [Fact] + public async Task GetDataAndWaitForIdleAsync_WaitForIdleAsyncCancelled_DoesNotCallWaitForIdleTwice() + { + // ARRANGE — on cancellation, the method must not retry WaitForIdleAsync; + // it must be called exactly once and then give up gracefully. + var mock = CreateMock(); + var range = CreateRange(100, 110); + var expectedResult = CreateRangeResult(100, 110); + var cts = new CancellationTokenSource(); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(expectedResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cts.Token)); + + // ACT + await mock.Object.GetDataAndWaitForIdleAsync(range, cts.Token); + + // ASSERT — WaitForIdleAsync called exactly once, no retry + mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Once); + mock.Verify(c => c.GetDataAsync(range, It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetDataAndWaitForIdleAsync_WaitForIdleAsyncThrowsObjectDisposedException_Propagates() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var expectedResult = CreateRangeResult(100, 110); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(expectedResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .ThrowsAsync(new ObjectDisposedException("cache")); + + // ACT + var exception = await Record.ExceptionAsync( + async () => await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + } + + #endregion + + #region Extension Method Target Tests + + [Fact] + public async Task GetDataAndWaitForIdleAsync_WorksOnInterfaceReference() + { + // ARRANGE + var mock = CreateMock(); + var cacheInterface = mock.Object; + var range = CreateRange(100, 110); + var expectedResult = CreateRangeResult(100, 110); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(expectedResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // ACT — called via interface reference + var result = await cacheInterface.GetDataAndWaitForIdleAsync(range, CancellationToken.None); + + // ASSERT + Assert.Equal(expectedResult.Range, result.Range); + } + + #endregion + + // ========================================================================= + // GetDataAndWaitOnMissAsync — Hybrid Consistency Mode Tests + // ========================================================================= + + #region GetDataAndWaitOnMissAsync — Conditional Wait Behaviour Tests + + [Fact] + public async Task GetDataAndWaitOnMissAsync_FullHit_DoesNotWaitForIdle() + { + // ARRANGE — full hit: cache was already warm; no idle wait should occur + var mock = CreateMock(); + var range = CreateRange(100, 110); + var fullHitResult = CreateRangeResult(100, 110, CacheInteraction.FullHit); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(fullHitResult); + + // WaitForIdleAsync is NOT set up — MockBehavior.Strict will fail if called + + // ACT + var result = await mock.Object.GetDataAndWaitOnMissAsync(range, CancellationToken.None); + + // ASSERT + Assert.Equal(fullHitResult.Range, result.Range); + mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetDataAndWaitOnMissAsync_FullMiss_WaitsForIdle() + { + // ARRANGE — full miss: range had no overlap with cache; idle wait must occur + var mock = CreateMock(); + var range = CreateRange(5000, 5100); + var fullMissResult = CreateRangeResult(5000, 5100, CacheInteraction.FullMiss); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(fullMissResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // ACT + var result = await mock.Object.GetDataAndWaitOnMissAsync(range, CancellationToken.None); + + // ASSERT + Assert.Equal(fullMissResult.Range, result.Range); + mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetDataAndWaitOnMissAsync_PartialHit_WaitsForIdle() + { + // ARRANGE — partial hit: some segments were missing; idle wait must occur + var mock = CreateMock(); + var range = CreateRange(90, 120); + var partialHitResult = CreateRangeResult(90, 120, CacheInteraction.PartialHit); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(partialHitResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // ACT + var result = await mock.Object.GetDataAndWaitOnMissAsync(range, CancellationToken.None); + + // ASSERT + Assert.Equal(partialHitResult.Range, result.Range); + mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetDataAndWaitOnMissAsync_FullMiss_WaitsForIdleExactlyOnce() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(1, 10); + var fullMissResult = CreateRangeResult(1, 10, CacheInteraction.FullMiss); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(fullMissResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // ACT + await mock.Object.GetDataAndWaitOnMissAsync(range, CancellationToken.None); + + // ASSERT — WaitForIdleAsync called exactly once, not zero, not twice + mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Once); + mock.Verify(c => c.GetDataAsync(range, It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetDataAndWaitOnMissAsync_ReturnsResultFromGetDataAsync_OnFullMiss() + { + // ARRANGE — returned RangeResult must be the exact object from GetDataAsync + var mock = CreateMock(); + var range = CreateRange(200, 300); + var expectedResult = CreateRangeResult(200, 300, CacheInteraction.FullMiss); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(expectedResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // ACT + var result = await mock.Object.GetDataAndWaitOnMissAsync(range, CancellationToken.None); + + // ASSERT — same range, same data, same CacheInteraction + Assert.Equal(expectedResult.Range, result.Range); + Assert.Equal(expectedResult.Data.Length, result.Data.Length); + Assert.True(expectedResult.Data.Span.SequenceEqual(result.Data.Span)); + Assert.Equal(CacheInteraction.FullMiss, result.CacheInteraction); + } + + [Fact] + public async Task GetDataAndWaitOnMissAsync_ReturnsResultFromGetDataAsync_OnFullHit() + { + // ARRANGE — on full hit, result is passed through without WaitForIdleAsync + var mock = CreateMock(); + var range = CreateRange(50, 60); + var expectedResult = CreateRangeResult(50, 60, CacheInteraction.FullHit); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(expectedResult); + + // WaitForIdleAsync NOT set up — MockBehavior.Strict guards against any call + + // ACT + var result = await mock.Object.GetDataAndWaitOnMissAsync(range, CancellationToken.None); + + // ASSERT + Assert.Equal(expectedResult.Range, result.Range); + Assert.Equal(CacheInteraction.FullHit, result.CacheInteraction); + } + + [Fact] + public async Task GetDataAndWaitOnMissAsync_NullRange_FullMiss_WaitsForIdle() + { + // ARRANGE — physical boundary miss: DataSource returned null (out-of-bounds) + // Still a FullMiss interaction; idle wait must occur so the cache rebalances + var mock = CreateMock(); + var range = CreateRange(9000, 9999); + var boundaryMissResult = CreateNullRangeResult(CacheInteraction.FullMiss); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(boundaryMissResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // ACT + var result = await mock.Object.GetDataAndWaitOnMissAsync(range, CancellationToken.None); + + // ASSERT + Assert.Null(result.Range); + Assert.Equal(CacheInteraction.FullMiss, result.CacheInteraction); + mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Once); + } + + #endregion + + #region GetDataAndWaitOnMissAsync — Cancellation Token Propagation Tests + + [Fact] + public async Task GetDataAndWaitOnMissAsync_PropagatesCancellationTokenToGetDataAsync() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var fullMissResult = CreateRangeResult(100, 110, CacheInteraction.FullMiss); + var cts = new CancellationTokenSource(); + var capturedToken = CancellationToken.None; + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .Returns, CancellationToken>((_, ct) => + { + capturedToken = ct; + return ValueTask.FromResult(fullMissResult); + }); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // ACT + await mock.Object.GetDataAndWaitOnMissAsync(range, cts.Token); + + // ASSERT + Assert.Equal(cts.Token, capturedToken); + } + + [Fact] + public async Task GetDataAndWaitOnMissAsync_PropagatesCancellationTokenToWaitForIdleAsync_OnMiss() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var fullMissResult = CreateRangeResult(100, 110, CacheInteraction.FullMiss); + var cts = new CancellationTokenSource(); + var capturedToken = CancellationToken.None; + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(fullMissResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(ct => + { + capturedToken = ct; + return Task.CompletedTask; + }); + + // ACT + await mock.Object.GetDataAndWaitOnMissAsync(range, cts.Token); + + // ASSERT + Assert.Equal(cts.Token, capturedToken); + } + + #endregion + + #region GetDataAndWaitOnMissAsync — Exception Propagation Tests + + [Fact] + public async Task GetDataAndWaitOnMissAsync_GetDataAsyncThrows_ExceptionPropagatesWithoutCallingWaitForIdleAsync() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var expectedException = new InvalidOperationException("GetDataAsync failed"); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ThrowsAsync(expectedException); + + // WaitForIdleAsync NOT set up — MockBehavior.Strict guards against any call + + // ACT + var exception = await Record.ExceptionAsync( + async () => await mock.Object.GetDataAndWaitOnMissAsync(range, CancellationToken.None)); + + // ASSERT + Assert.NotNull(exception); + Assert.Same(expectedException, exception); + mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetDataAndWaitOnMissAsync_GetDataAsyncCancelled_Propagates() + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cts.Token)); + + // ACT + var exception = await Record.ExceptionAsync( + async () => await mock.Object.GetDataAndWaitOnMissAsync(range, cts.Token)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetDataAndWaitOnMissAsync_WaitForIdleAsyncCancelled_ReturnsResultGracefully() + { + // ARRANGE — cancelling the wait stops the wait, not the background rebalance; + // the already-obtained result must be returned gracefully. + var mock = CreateMock(); + var range = CreateRange(100, 110); + var fullMissResult = CreateRangeResult(100, 110, CacheInteraction.FullMiss); + var cts = new CancellationTokenSource(); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(fullMissResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cts.Token)); + + // ACT + var exception = await Record.ExceptionAsync( + async () => await mock.Object.GetDataAndWaitOnMissAsync(range, cts.Token)); + + // ASSERT — no exception; WaitForIdleAsync was still attempted (for the FullMiss) + Assert.Null(exception); + mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetDataAndWaitOnMissAsync_WaitForIdleAsyncCancelled_ReturnsOriginalData() + { + // ARRANGE — the returned result must be identical to what GetDataAsync produced, + // preserving Range, Data, and CacheInteraction unchanged. + var mock = CreateMock(); + var range = CreateRange(200, 300); + var expectedResult = CreateRangeResult(200, 300, CacheInteraction.FullMiss); + var cts = new CancellationTokenSource(); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(expectedResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cts.Token)); + + // ACT + var result = await mock.Object.GetDataAndWaitOnMissAsync(range, cts.Token); + + // ASSERT — same Range, Data, and CacheInteraction as GetDataAsync returned + Assert.Equal(expectedResult.Range, result.Range); + Assert.True(expectedResult.Data.Span.SequenceEqual(result.Data.Span)); + Assert.Equal(CacheInteraction.FullMiss, result.CacheInteraction); + } + + [Fact] + public async Task GetDataAndWaitOnMissAsync_WaitForIdleAsyncCancelled_OnPartialHit_ReturnsResultGracefully() + { + // ARRANGE — graceful degradation must also work for PartialHit, not just FullMiss + var mock = CreateMock(); + var range = CreateRange(90, 120); + var partialHitResult = CreateRangeResult(90, 120, CacheInteraction.PartialHit); + var cts = new CancellationTokenSource(); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(partialHitResult); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cts.Token)); + + // ACT + var exception = await Record.ExceptionAsync( + async () => await mock.Object.GetDataAndWaitOnMissAsync(range, cts.Token)); + + // ASSERT — no exception; WaitForIdleAsync was still attempted (for the PartialHit) + Assert.Null(exception); + mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Once); + } + + #endregion + + #region GetDataAndWaitOnMissAsync — Call Order Tests + + [Fact] + public async Task GetDataAndWaitOnMissAsync_OnFullMiss_CallsGetDataAsyncBeforeWaitForIdleAsync() + { + // ARRANGE + var mock = CreateMock(); + var callOrder = new List(); + var range = CreateRange(100, 110); + var fullMissResult = CreateRangeResult(100, 110, CacheInteraction.FullMiss); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .Returns(() => + { + callOrder.Add("GetDataAsync"); + return ValueTask.FromResult(fullMissResult); + }); + + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(() => + { + callOrder.Add("WaitForIdleAsync"); + return Task.CompletedTask; + }); + + // ACT + await mock.Object.GetDataAndWaitOnMissAsync(range, CancellationToken.None); + + // ASSERT + Assert.Equal(2, callOrder.Count); + Assert.Equal("GetDataAsync", callOrder[0]); + Assert.Equal("WaitForIdleAsync", callOrder[1]); + } + + [Fact] + public async Task GetDataAndWaitOnMissAsync_OnFullHit_CallsOnlyGetDataAsync() + { + // ARRANGE + var mock = CreateMock(); + var callOrder = new List(); + var range = CreateRange(100, 110); + var fullHitResult = CreateRangeResult(100, 110, CacheInteraction.FullHit); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .Returns(() => + { + callOrder.Add("GetDataAsync"); + return ValueTask.FromResult(fullHitResult); + }); + + // WaitForIdleAsync NOT set up — MockBehavior.Strict guards against any call + + // ACT + await mock.Object.GetDataAndWaitOnMissAsync(range, CancellationToken.None); + + // ASSERT + Assert.Single(callOrder); + Assert.Equal("GetDataAsync", callOrder[0]); + } + + #endregion + + #region GetDataAndWaitOnMissAsync — CacheInteraction Property Tests + + [Theory] + [InlineData(CacheInteraction.FullHit)] + [InlineData(CacheInteraction.PartialHit)] + [InlineData(CacheInteraction.FullMiss)] + public async Task GetDataAndWaitOnMissAsync_PreservesCacheInteractionOnResult(CacheInteraction interaction) + { + // ARRANGE + var mock = CreateMock(); + var range = CreateRange(100, 110); + var sourceResult = CreateRangeResult(100, 110, interaction); + + mock.Setup(c => c.GetDataAsync(range, It.IsAny())) + .ReturnsAsync(sourceResult); + + if (interaction != CacheInteraction.FullHit) + { + mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + } + + // ACT + var result = await mock.Object.GetDataAndWaitOnMissAsync(range, CancellationToken.None); + + // ASSERT — CacheInteraction is passed through unchanged + Assert.Equal(interaction, result.CacheInteraction); + } + + [Fact] + public void RangeResult_CacheInteraction_IsAccessibleOnPublicRecord() + { + // ARRANGE — verify the property is publicly readable + var range = CreateRange(1, 10); + var data = new ReadOnlyMemory(new[] { 1, 2, 3 }); + var result = new RangeResult(range, data, CacheInteraction.PartialHit); + + // ASSERT + Assert.Equal(CacheInteraction.PartialHit, result.CacheInteraction); + } + + [Theory] + [InlineData(CacheInteraction.FullHit)] + [InlineData(CacheInteraction.PartialHit)] + [InlineData(CacheInteraction.FullMiss)] + public void RangeResult_CacheInteraction_RoundtripsAllValues(CacheInteraction interaction) + { + // ARRANGE + var range = CreateRange(0, 1); + var data = new ReadOnlyMemory(new[] { 0, 1 }); + var result = new RangeResult(range, data, interaction); + + // ASSERT + Assert.Equal(interaction, result.CacheInteraction); + } + + #endregion +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/FuncDataSourceTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/FuncDataSourceTests.cs new file mode 100644 index 0000000..8f08f12 --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Public/FuncDataSourceTests.cs @@ -0,0 +1,235 @@ +using Intervals.NET; +using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Dto; + +namespace SlidingWindowCache.Unit.Tests.Public; + +/// +/// Unit tests for . +/// Validates constructor argument checking, delegation to the supplied func, +/// return-value forwarding, exception propagation, and the inherited batch overload. +/// +public sealed class FuncDataSourceTests +{ + #region Test Infrastructure + + private static Range MakeRange(int start, int end) + => Intervals.NET.Factories.Range.Closed(start, end); + + private static RangeChunk MakeChunk(Range range, IEnumerable data) + => new(range, data); + + #endregion + + #region Constructor Tests + + [Fact] + public void Constructor_WithNullFunc_ThrowsArgumentNullException() + { + // ARRANGE + Func, CancellationToken, Task>>? nullFunc = null; + + // ACT + var exception = Record.Exception( + () => new FuncDataSource(nullFunc!)); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Equal("fetchFunc", ((ArgumentNullException)exception).ParamName); + } + + [Fact] + public void Constructor_WithValidFunc_DoesNotThrow() + { + // ARRANGE + static Task> Func(Range r, CancellationToken ct) + => Task.FromResult(new RangeChunk(r, [])); + + // ACT + var exception = Record.Exception(() => new FuncDataSource(Func)); + + // ASSERT + Assert.Null(exception); + } + + #endregion + + #region FetchAsync Delegation Tests + + [Fact] + public async Task FetchAsync_PassesRangeToDelegate() + { + // ARRANGE + Range? capturedRange = null; + var expectedRange = MakeRange(10, 20); + + var source = new FuncDataSource( + (range, ct) => + { + capturedRange = range; + return Task.FromResult(MakeChunk(range, [])); + }); + + // ACT + await source.FetchAsync(expectedRange, CancellationToken.None); + + // ASSERT + Assert.Equal(expectedRange, capturedRange); + } + + [Fact] + public async Task FetchAsync_PassesCancellationTokenToDelegate() + { + // ARRANGE + CancellationToken capturedToken = default; + using var cts = new CancellationTokenSource(); + var range = MakeRange(0, 5); + + var source = new FuncDataSource( + (r, ct) => + { + capturedToken = ct; + return Task.FromResult(MakeChunk(r, [])); + }); + + // ACT + await source.FetchAsync(range, cts.Token); + + // ASSERT + Assert.Equal(cts.Token, capturedToken); + } + + [Fact] + public async Task FetchAsync_ReturnsDelegateResult() + { + // ARRANGE + var range = MakeRange(1, 3); + var expectedData = new[] { 10, 20, 30 }; + var expectedChunk = MakeChunk(range, expectedData); + + var source = new FuncDataSource( + (r, ct) => Task.FromResult(expectedChunk)); + + // ACT + var result = await source.FetchAsync(range, CancellationToken.None); + + // ASSERT + Assert.Equal(expectedChunk, result); + } + + [Fact] + public async Task FetchAsync_ReturnsDelegateResult_WithNullRange() + { + // ARRANGE — simulates a bounded source returning no data + var requestedRange = MakeRange(9000, 9999); + var emptyChunk = new RangeChunk(null, []); + + var source = new FuncDataSource( + (r, ct) => Task.FromResult(emptyChunk)); + + // ACT + var result = await source.FetchAsync(requestedRange, CancellationToken.None); + + // ASSERT + Assert.Null(result.Range); + Assert.Empty(result.Data); + } + + [Fact] + public async Task FetchAsync_PropagatesExceptionFromDelegate() + { + // ARRANGE + var range = MakeRange(0, 10); + var expected = new InvalidOperationException("source failure"); + + var source = new FuncDataSource( + (r, ct) => Task.FromException>(expected)); + + // ACT + var exception = await Record.ExceptionAsync( + () => source.FetchAsync(range, CancellationToken.None)); + + // ASSERT + Assert.NotNull(exception); + Assert.Same(expected, exception); + } + + [Fact] + public async Task FetchAsync_InvokesDelegateOnEachCall() + { + // ARRANGE + var callCount = 0; + var range = MakeRange(0, 1); + + var source = new FuncDataSource( + (r, ct) => + { + callCount++; + return Task.FromResult(MakeChunk(r, [])); + }); + + // ACT + await source.FetchAsync(range, CancellationToken.None); + await source.FetchAsync(range, CancellationToken.None); + + // ASSERT + Assert.Equal(2, callCount); + } + + #endregion + + #region Batch FetchAsync Tests (interface default) + + [Fact] + public async Task BatchFetchAsync_CallsDelegateForEachRange() + { + // ARRANGE + var invokedRanges = new List>(); + var ranges = new[] + { + MakeRange(0, 9), + MakeRange(10, 19), + MakeRange(20, 29), + }; + + var source = new FuncDataSource( + (r, ct) => + { + lock (invokedRanges) invokedRanges.Add(r); + return Task.FromResult(MakeChunk(r, [])); + }); + + // ACT + var results = await ((IDataSource)source) + .FetchAsync(ranges, CancellationToken.None); + + // ASSERT + Assert.Equal(ranges.Length, results.Count()); + Assert.Equal(ranges.Length, invokedRanges.Count); + Assert.All(ranges, r => Assert.Contains(r, invokedRanges)); + } + + [Fact] + public async Task BatchFetchAsync_ReturnsChunkForEachRange() + { + // ARRANGE + var ranges = new[] + { + MakeRange(0, 4), + MakeRange(5, 9), + }; + + var source = new FuncDataSource( + (r, ct) => Task.FromResult(MakeChunk(r, []))); + + // ACT + var results = (await ((IDataSource)source) + .FetchAsync(ranges, CancellationToken.None)).ToList(); + + // ASSERT + Assert.Equal(2, results.Count); + } + + #endregion +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheExtensionsTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheExtensionsTests.cs deleted file mode 100644 index 2131a2f..0000000 --- a/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheExtensionsTests.cs +++ /dev/null @@ -1,426 +0,0 @@ -using Intervals.NET.Domain.Default.Numeric; -using Moq; -using SlidingWindowCache.Public; -using SlidingWindowCache.Public.Dto; - -namespace SlidingWindowCache.Unit.Tests.Public; - -/// -/// Unit tests for . -/// Validates the composition contract: GetDataAsync followed by WaitForIdleAsync, -/// with correct result passthrough, cancellation propagation, and exception semantics. -/// Uses mocked to isolate the extension method -/// from any real cache implementation. -/// -public sealed class WindowCacheExtensionsTests -{ - #region Test Infrastructure - - private static Mock> CreateMock() - => new Mock>(MockBehavior.Strict); - - private static Intervals.NET.Range CreateRange(int start, int end) - => Intervals.NET.Factories.Range.Closed(start, end); - - private static RangeResult CreateRangeResult(int start, int end) - { - var range = CreateRange(start, end); - var data = new ReadOnlyMemory(Enumerable.Range(start, end - start + 1).ToArray()); - return new RangeResult(range, data); - } - - #endregion - - #region Composition Contract Tests - - [Fact] - public async Task GetDataAndWaitForIdleAsync_CallsGetDataAsyncFirst() - { - // ARRANGE - var mock = CreateMock(); - var callOrder = new List(); - var range = CreateRange(100, 110); - var expectedResult = CreateRangeResult(100, 110); - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .Returns(() => - { - callOrder.Add("GetDataAsync"); - return ValueTask.FromResult(expectedResult); - }); - - mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) - .Returns(() => - { - callOrder.Add("WaitForIdleAsync"); - return Task.CompletedTask; - }); - - // ACT - await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None); - - // ASSERT - Assert.Equal(2, callOrder.Count); - Assert.Equal("GetDataAsync", callOrder[0]); - Assert.Equal("WaitForIdleAsync", callOrder[1]); - } - - [Fact] - public async Task GetDataAndWaitForIdleAsync_ReturnsResultFromGetDataAsync() - { - // ARRANGE - var mock = CreateMock(); - var range = CreateRange(100, 110); - var expectedResult = CreateRangeResult(100, 110); - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .ReturnsAsync(expectedResult); - - mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) - .Returns(Task.CompletedTask); - - // ACT - var result = await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None); - - // ASSERT - Assert.Equal(expectedResult.Range, result.Range); - Assert.Equal(expectedResult.Data.Length, result.Data.Length); - Assert.True(expectedResult.Data.Span.SequenceEqual(result.Data.Span)); - } - - [Fact] - public async Task GetDataAndWaitForIdleAsync_CallsBothMethodsExactlyOnce() - { - // ARRANGE - var mock = CreateMock(); - var range = CreateRange(100, 110); - var expectedResult = CreateRangeResult(100, 110); - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .ReturnsAsync(expectedResult); - - mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) - .Returns(Task.CompletedTask); - - // ACT - await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None); - - // ASSERT - mock.Verify(c => c.GetDataAsync(range, It.IsAny()), Times.Once); - mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Once); - } - - [Fact] - public async Task GetDataAndWaitForIdleAsync_WithNullResultRange_ReturnsNullRange() - { - // ARRANGE - var mock = CreateMock(); - var range = CreateRange(100, 110); - var nullRangeResult = new RangeResult(null, ReadOnlyMemory.Empty); - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .ReturnsAsync(nullRangeResult); - - mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) - .Returns(Task.CompletedTask); - - // ACT - var result = await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None); - - // ASSERT - Assert.Null(result.Range); - Assert.Equal(0, result.Data.Length); - } - - #endregion - - #region Cancellation Token Propagation Tests - - [Fact] - public async Task GetDataAndWaitForIdleAsync_PropagatesCancellationTokenToGetDataAsync() - { - // ARRANGE - var mock = CreateMock(); - var range = CreateRange(100, 110); - var expectedResult = CreateRangeResult(100, 110); - var cts = new CancellationTokenSource(); - var capturedToken = CancellationToken.None; - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .Returns, CancellationToken>((_, ct) => - { - capturedToken = ct; - return ValueTask.FromResult(expectedResult); - }); - - mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) - .Returns(Task.CompletedTask); - - // ACT - await mock.Object.GetDataAndWaitForIdleAsync(range, cts.Token); - - // ASSERT - Assert.Equal(cts.Token, capturedToken); - } - - [Fact] - public async Task GetDataAndWaitForIdleAsync_PropagatesCancellationTokenToWaitForIdleAsync() - { - // ARRANGE - var mock = CreateMock(); - var range = CreateRange(100, 110); - var expectedResult = CreateRangeResult(100, 110); - var cts = new CancellationTokenSource(); - var capturedToken = CancellationToken.None; - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .ReturnsAsync(expectedResult); - - mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) - .Returns(ct => - { - capturedToken = ct; - return Task.CompletedTask; - }); - - // ACT - await mock.Object.GetDataAndWaitForIdleAsync(range, cts.Token); - - // ASSERT - Assert.Equal(cts.Token, capturedToken); - } - - [Fact] - public async Task GetDataAndWaitForIdleAsync_UsesSameCancellationTokenForBothCalls() - { - // ARRANGE - var mock = CreateMock(); - var range = CreateRange(100, 110); - var expectedResult = CreateRangeResult(100, 110); - var cts = new CancellationTokenSource(); - var capturedGetDataToken = CancellationToken.None; - var capturedWaitToken = CancellationToken.None; - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .Returns, CancellationToken>((_, ct) => - { - capturedGetDataToken = ct; - return ValueTask.FromResult(expectedResult); - }); - - mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) - .Returns(ct => - { - capturedWaitToken = ct; - return Task.CompletedTask; - }); - - // ACT - await mock.Object.GetDataAndWaitForIdleAsync(range, cts.Token); - - // ASSERT - Same token passed to both - Assert.Equal(cts.Token, capturedGetDataToken); - Assert.Equal(cts.Token, capturedWaitToken); - Assert.Equal(capturedGetDataToken, capturedWaitToken); - } - - [Fact] - public async Task GetDataAndWaitForIdleAsync_DefaultCancellationToken_IsNone() - { - // ARRANGE - var mock = CreateMock(); - var range = CreateRange(100, 110); - var expectedResult = CreateRangeResult(100, 110); - var capturedGetDataToken = new CancellationToken(true); // start with non-None value - var capturedWaitToken = new CancellationToken(true); - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .Returns, CancellationToken>((_, ct) => - { - capturedGetDataToken = ct; - return ValueTask.FromResult(expectedResult); - }); - - mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) - .Returns(ct => - { - capturedWaitToken = ct; - return Task.CompletedTask; - }); - - // ACT — no cancellationToken argument (uses default) - await mock.Object.GetDataAndWaitForIdleAsync(range); - - // ASSERT - Assert.Equal(CancellationToken.None, capturedGetDataToken); - Assert.Equal(CancellationToken.None, capturedWaitToken); - } - - #endregion - - #region Exception Propagation Tests - - [Fact] - public async Task GetDataAndWaitForIdleAsync_GetDataAsyncThrows_ExceptionPropagatesWithoutCallingWaitForIdleAsync() - { - // ARRANGE - var mock = CreateMock(); - var range = CreateRange(100, 110); - var expectedException = new InvalidOperationException("GetDataAsync failed"); - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .ThrowsAsync(expectedException); - - // WaitForIdleAsync should NOT be set up — MockBehavior.Strict will throw if called - - // ACT - var exception = await Record.ExceptionAsync( - async () => await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None)); - - // ASSERT - Assert.NotNull(exception); - Assert.Same(expectedException, exception); - mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Never); - } - - [Fact] - public async Task GetDataAndWaitForIdleAsync_GetDataAsyncThrowsObjectDisposedException_Propagates() - { - // ARRANGE - var mock = CreateMock(); - var range = CreateRange(100, 110); - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .ThrowsAsync(new ObjectDisposedException("cache")); - - // ACT - var exception = await Record.ExceptionAsync( - async () => await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None)); - - // ASSERT - Assert.NotNull(exception); - Assert.IsType(exception); - mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Never); - } - - [Fact] - public async Task GetDataAndWaitForIdleAsync_GetDataAsyncCancelled_Propagates() - { - // ARRANGE - var mock = CreateMock(); - var range = CreateRange(100, 110); - var cts = new CancellationTokenSource(); - cts.Cancel(); - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .ThrowsAsync(new OperationCanceledException(cts.Token)); - - // ACT - var exception = await Record.ExceptionAsync( - async () => await mock.Object.GetDataAndWaitForIdleAsync(range, cts.Token)); - - // ASSERT - Assert.NotNull(exception); - Assert.IsType(exception); - mock.Verify(c => c.WaitForIdleAsync(It.IsAny()), Times.Never); - } - - [Fact] - public async Task GetDataAndWaitForIdleAsync_WaitForIdleAsyncThrows_ExceptionPropagates() - { - // ARRANGE - var mock = CreateMock(); - var range = CreateRange(100, 110); - var expectedResult = CreateRangeResult(100, 110); - var expectedException = new InvalidOperationException("WaitForIdleAsync failed"); - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .ReturnsAsync(expectedResult); - - mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) - .ThrowsAsync(expectedException); - - // ACT - var exception = await Record.ExceptionAsync( - async () => await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None)); - - // ASSERT - Assert.NotNull(exception); - Assert.Same(expectedException, exception); - } - - [Fact] - public async Task GetDataAndWaitForIdleAsync_WaitForIdleAsyncCancelled_Propagates() - { - // ARRANGE - var mock = CreateMock(); - var range = CreateRange(100, 110); - var expectedResult = CreateRangeResult(100, 110); - var cts = new CancellationTokenSource(); - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .ReturnsAsync(expectedResult); - - mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) - .ThrowsAsync(new OperationCanceledException(cts.Token)); - - // ACT - var exception = await Record.ExceptionAsync( - async () => await mock.Object.GetDataAndWaitForIdleAsync(range, cts.Token)); - - // ASSERT - Assert.NotNull(exception); - Assert.IsType(exception); - } - - [Fact] - public async Task GetDataAndWaitForIdleAsync_WaitForIdleAsyncThrowsObjectDisposedException_Propagates() - { - // ARRANGE - var mock = CreateMock(); - var range = CreateRange(100, 110); - var expectedResult = CreateRangeResult(100, 110); - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .ReturnsAsync(expectedResult); - - mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) - .ThrowsAsync(new ObjectDisposedException("cache")); - - // ACT - var exception = await Record.ExceptionAsync( - async () => await mock.Object.GetDataAndWaitForIdleAsync(range, CancellationToken.None)); - - // ASSERT - Assert.NotNull(exception); - Assert.IsType(exception); - } - - #endregion - - #region Extension Method Target Tests - - [Fact] - public async Task GetDataAndWaitForIdleAsync_WorksOnInterfaceReference() - { - // ARRANGE - var mock = CreateMock(); - IWindowCache cacheInterface = mock.Object; - var range = CreateRange(100, 110); - var expectedResult = CreateRangeResult(100, 110); - - mock.Setup(c => c.GetDataAsync(range, It.IsAny())) - .ReturnsAsync(expectedResult); - - mock.Setup(c => c.WaitForIdleAsync(It.IsAny())) - .Returns(Task.CompletedTask); - - // ACT — called via interface reference - var result = await cacheInterface.GetDataAndWaitForIdleAsync(range, CancellationToken.None); - - // ASSERT - Assert.Equal(expectedResult.Range, result.Range); - } - - #endregion -}