diff --git a/README.md b/README.md index 3c518e6..bfd4239 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ consistency, and intelligent work avoidance.** - [Understanding the Sliding Window](#-understanding-the-sliding-window) - [Materialization for Fast Access](#-materialization-for-fast-access) - [Usage Example](#-usage-example) +- [Boundary Handling & Data Availability](#-boundary-handling--data-availability) - [Resource Management](#-resource-management) - [Configuration](#-configuration) - [Execution Strategy Selection](#-execution-strategy-selection) @@ -318,14 +319,14 @@ var cache = WindowCache.Create( readMode: UserCacheReadMode.Snapshot ); -// Request data - returns ReadOnlyMemory -var data = await cache.GetDataAsync( +// Request data - returns RangeResult +var result = await cache.GetDataAsync( Range.Closed(100, 200), cancellationToken ); // Access the data -foreach (var item in data.Span) +foreach (var item in result.Data.Span) { Console.WriteLine(item); } @@ -333,6 +334,117 @@ foreach (var item in data.Span) --- +## 🎯 Boundary Handling & Data Availability + +The cache provides explicit boundary handling through `RangeResult` returned by `GetDataAsync()`. This allows data sources to communicate data availability and partial fulfillment. + +### RangeResult Structure + +```csharp +public sealed record RangeResult( + Range? Range, // Actual range returned (nullable) + ReadOnlyMemory Data // The data for that range +); +``` + +### Basic Usage + +```csharp +var result = await cache.GetDataAsync( + Intervals.NET.Factories.Range.Closed(100, 200), + ct +); + +// Always check Range before using Data +if (result.Range != null) +{ + Console.WriteLine($"Received {result.Data.Length} elements for range {result.Range}"); + + foreach (var item in result.Data.Span) + { + ProcessItem(item); + } +} +else +{ + Console.WriteLine("No data available for requested range"); +} +``` + +### Why RangeResult? + +**Benefits:** +- βœ… **Explicit Contracts**: Know exactly what range was fulfilled +- βœ… **Boundary Awareness**: Data sources signal truncation at physical boundaries +- βœ… **No Exceptions for Normal Cases**: Out-of-bounds is expected, not exceptional +- βœ… **Partial Fulfillment**: Handle cases where only part of requested range is available + +### Bounded Data Sources Example + +For data sources with physical boundaries (databases with min/max IDs, APIs with limits): + +```csharp +public class BoundedDatabaseSource : IDataSource +{ + private const int MinId = 1000; + private const int MaxId = 9999; + + public async Task> FetchAsync( + Range requested, + CancellationToken ct) + { + var availableRange = Intervals.NET.Factories.Range.Closed(MinId, MaxId); + var fulfillable = requested.Intersect(availableRange); + + // No data available + if (fulfillable == null) + { + return new RangeChunk( + null, // Range must be null to signal no data available + Array.Empty() + ); + } + + // Fetch available portion + var data = await _db.FetchRecordsAsync( + fulfillable.LowerBound.Value, + fulfillable.UpperBound.Value, + ct + ); + + return new RangeChunk(fulfillable, data); + } +} + +// Example scenarios: +// Request [2000..3000] β†’ Range = [2000..3000], 1001 records βœ“ +// Request [500..1500] β†’ Range = [1000..1500], 501 records (truncated) βœ“ +// Request [0..999] β†’ Range = null, empty data βœ“ +``` + +### Handling Subset Requests + +When requesting a subset of cached data, `RangeResult` returns only the requested range: + +```csharp +// Prime cache with large range +await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(0, 1000), ct); + +// Request subset (served from cache) +var subset = await cache.GetDataAsync( + Intervals.NET.Factories.Range.Closed(100, 200), + ct +); + +// Result contains ONLY the requested subset +Assert.Equal(101, subset.Data.Length); // [100, 200] = 101 elements +Assert.Equal(subset.Range, Intervals.NET.Factories.Range.Closed(100, 200)); +``` + +**For complete boundary handling documentation, see:** [Boundary Handling Guide](docs/boundary-handling.md) + +--- + ## πŸ”„ Resource Management WindowCache manages background processing tasks and resources that require explicit disposal. **Always dispose the cache when done** to prevent resource leaks and ensure graceful shutdown of background operations. @@ -406,7 +518,7 @@ public class DataService : IAsyncDisposable ); } - public ValueTask> GetDataAsync(Range range, CancellationToken ct) + public ValueTask> GetDataAsync(Range range, CancellationToken ct) => _cache.GetDataAsync(range, ct); public async ValueTask DisposeAsync() @@ -757,9 +869,10 @@ see [Diagnostics Guide](docs/diagnostics.md).** 1. **[README - Quick Start](#-quick-start)** - Basic usage examples (you're already here!) 2. **[README - Configuration Guide](#configuration)** - Understand the 5 key parameters -3. **[Storage Strategies](docs/storage-strategies.md)** - Choose Snapshot vs CopyOnRead for your use case -4. **[Glossary - Common Misconceptions](docs/glossary.md#common-misconceptions)** - Avoid common pitfalls -5. **[Diagnostics](docs/diagnostics.md)** - Add optional instrumentation for visibility +3. **[Boundary Handling](docs/boundary-handling.md)** - RangeResult usage, bounded data sources, partial fulfillment +4. **[Storage Strategies](docs/storage-strategies.md)** - Choose Snapshot vs CopyOnRead for your use case +5. **[Glossary - Common Misconceptions](docs/glossary.md#common-misconceptions)** - Avoid common pitfalls +6. **[Diagnostics](docs/diagnostics.md)** - Add optional instrumentation for visibility **When to use this path**: Building features, integrating the cache, performance tuning. diff --git a/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/ExecutionStrategyBenchmarks.cs b/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/ExecutionStrategyBenchmarks.cs index 27ad041..82d2d6a 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/ExecutionStrategyBenchmarks.cs +++ b/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/ExecutionStrategyBenchmarks.cs @@ -245,7 +245,7 @@ private void SetupCache(int? rebalanceQueueCapacity) // Build initial range for first request var initialRange = Intervals.NET.Factories.Range.Closed( - InitialStart, + InitialStart, InitialStart + BaseSpanSize - 1 ); diff --git a/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/UserFlowBenchmarks.cs b/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/UserFlowBenchmarks.cs index 1250f76..473e999 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/UserFlowBenchmarks.cs +++ b/benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/UserFlowBenchmarks.cs @@ -73,8 +73,8 @@ public class UserFlowBenchmarks private Range _partialHitBackwardRange; private Range _fullMissRange; - private WindowCacheOptions _snapshotOptions; - private WindowCacheOptions _copyOnReadOptions; + private WindowCacheOptions? _snapshotOptions; + private WindowCacheOptions? _copyOnReadOptions; [GlobalSetup] public void GlobalSetup() @@ -120,13 +120,13 @@ public void IterationSetup() _snapshotCache = new WindowCache( _dataSource, _domain, - _snapshotOptions + _snapshotOptions! ); _copyOnReadCache = new WindowCache( _dataSource, _domain, - _copyOnReadOptions + _copyOnReadOptions! ); // Prime both caches with known initial window @@ -155,7 +155,7 @@ public void IterationCleanup() public async Task> User_FullHit_Snapshot() { // No rebalance triggered - return await _snapshotCache!.GetDataAsync(_fullHitRange, CancellationToken.None); + return (await _snapshotCache!.GetDataAsync(_fullHitRange, CancellationToken.None)).Data; } [Benchmark] @@ -163,7 +163,7 @@ public async Task> User_FullHit_Snapshot() public async Task> User_FullHit_CopyOnRead() { // No rebalance triggered - return await _copyOnReadCache!.GetDataAsync(_fullHitRange, CancellationToken.None); + return (await _copyOnReadCache!.GetDataAsync(_fullHitRange, CancellationToken.None)).Data; } #endregion @@ -175,7 +175,7 @@ public async Task> User_FullHit_CopyOnRead() public async Task> User_PartialHit_ForwardShift_Snapshot() { // Rebalance triggered, handled in cleanup - return await _snapshotCache!.GetDataAsync(_partialHitForwardRange, CancellationToken.None); + return (await _snapshotCache!.GetDataAsync(_partialHitForwardRange, CancellationToken.None)).Data; } [Benchmark] @@ -183,7 +183,7 @@ public async Task> User_PartialHit_ForwardShift_Snapshot() public async Task> User_PartialHit_ForwardShift_CopyOnRead() { // Rebalance triggered, handled in cleanup - return await _copyOnReadCache!.GetDataAsync(_partialHitForwardRange, CancellationToken.None); + return (await _copyOnReadCache!.GetDataAsync(_partialHitForwardRange, CancellationToken.None)).Data; } [Benchmark] @@ -191,7 +191,7 @@ public async Task> User_PartialHit_ForwardShift_CopyOnRead() public async Task> User_PartialHit_BackwardShift_Snapshot() { // Rebalance triggered, handled in cleanup - return await _snapshotCache!.GetDataAsync(_partialHitBackwardRange, CancellationToken.None); + return (await _snapshotCache!.GetDataAsync(_partialHitBackwardRange, CancellationToken.None)).Data; } [Benchmark] @@ -199,7 +199,7 @@ public async Task> User_PartialHit_BackwardShift_Snapshot() public async Task> User_PartialHit_BackwardShift_CopyOnRead() { // Rebalance triggered, handled in cleanup - return await _copyOnReadCache!.GetDataAsync(_partialHitBackwardRange, CancellationToken.None); + return (await _copyOnReadCache!.GetDataAsync(_partialHitBackwardRange, CancellationToken.None)).Data; } #endregion @@ -212,7 +212,7 @@ public async Task> User_FullMiss_Snapshot() { // No overlap - full cache replacement // Rebalance triggered, handled in cleanup - return await _snapshotCache!.GetDataAsync(_fullMissRange, CancellationToken.None); + return (await _snapshotCache!.GetDataAsync(_fullMissRange, CancellationToken.None)).Data; } [Benchmark] @@ -221,7 +221,7 @@ public async Task> User_FullMiss_CopyOnRead() { // No overlap - full cache replacement // Rebalance triggered, handled in cleanup - return await _copyOnReadCache!.GetDataAsync(_fullMissRange, CancellationToken.None); + return (await _copyOnReadCache!.GetDataAsync(_fullMissRange, CancellationToken.None)).Data; } #endregion diff --git a/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SlowDataSource.cs b/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SlowDataSource.cs index e3f5a06..d71f0b5 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SlowDataSource.cs +++ b/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SlowDataSource.cs @@ -1,6 +1,5 @@ using Intervals.NET; using Intervals.NET.Domain.Default.Numeric; -using Intervals.NET.Domain.Extensions.Fixed; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Dto; @@ -31,14 +30,14 @@ public SlowDataSource(IntegerFixedStepDomain domain, TimeSpan latency) /// Fetches data for a single range with simulated latency. /// Respects cancellation token to allow early exit during debounce or execution cancellation. /// - public async Task> FetchAsync(Range range, CancellationToken cancellationToken) + public async Task> FetchAsync(Range range, CancellationToken cancellationToken) { // Simulate I/O latency (network/database delay) // This delay is cancellable, allowing execution strategies to abort obsolete fetches await Task.Delay(_latency, cancellationToken).ConfigureAwait(false); // Generate data after delay completes - return GenerateDataForRange(range); + return new RangeChunk(range, GenerateDataForRange(range)); } /// diff --git a/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SynchronousDataSource.cs b/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SynchronousDataSource.cs index 39f8b5d..55caef1 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SynchronousDataSource.cs +++ b/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SynchronousDataSource.cs @@ -24,8 +24,8 @@ public SynchronousDataSource(IntegerFixedStepDomain domain) /// Fetches data for a single range with zero latency. /// Data generation: Returns the integer value at each position in the range. /// - public Task> FetchAsync(Range range, CancellationToken cancellationToken) => - Task.FromResult(GenerateDataForRange(range)); + public Task> FetchAsync(Range range, CancellationToken cancellationToken) => + Task.FromResult(new RangeChunk(range, GenerateDataForRange(range))); /// /// Fetches data for multiple ranges with zero latency. diff --git a/docs/actors-and-responsibilities.md b/docs/actors-and-responsibilities.md index 7456a46..96f3188 100644 --- a/docs/actors-and-responsibilities.md +++ b/docs/actors-and-responsibilities.md @@ -23,7 +23,9 @@ Handles user requests with minimal latency and maximal isolation from background **Critical Contract:** ``` -Every user access produces a rebalance intent containing delivered data. +Every user access that results in assembled data publishes a rebalance intent containing +that delivered data. Requests where IDataSource returns null (physical boundary misses) +do not publish an intent β€” there is no data to embed (Invariant C.24e). The UserRequestHandler is READ-ONLY with respect to cache state. The UserRequestHandler NEVER invokes directly decision logic - it just publishes an intent. ``` @@ -171,7 +173,7 @@ IntentController (User Thread for PublishIntent; Background Thread for ProcessIn **Enhanced Role (Decision-Driven Model):** Now responsible for: -- **Receiving intents** (on every user request) [IntentController.PublishIntent - User Thread] +- **Receiving intents** (when user request produces assembled data) [IntentController.PublishIntent - User Thread] - **Owning and invoking DecisionEngine** [IntentController - Background Thread (intent processing loop), synchronous] - **Intent identity and versioning** via ExecutionRequest snapshot [IntentController] - **Cancellation coordination** based on validation results from owned DecisionEngine [IntentController - Background Thread] diff --git a/docs/boundary-handling.md b/docs/boundary-handling.md new file mode 100644 index 0000000..6f341ea --- /dev/null +++ b/docs/boundary-handling.md @@ -0,0 +1,419 @@ +# Boundary Handling & Data Availability + +--- + +## Table of Contents + +- [Overview](#overview) +- [RangeResult Structure](#rangeresult-structure) +- [IDataSource Contract](#idatasource-contract) +- [Usage Patterns](#usage-patterns) +- [Bounded Data Sources](#bounded-data-sources) +- [Testing](#testing) +- [Architectural Considerations](#architectural-considerations) + +--- + +## Overview + +The Sliding Window Cache provides explicit boundary handling through the `RangeResult` type returned by `GetDataAsync()`. This design allows data sources to communicate data availability, partial fulfillment, and physical boundaries to consumers. + +### Why RangeResult? + +**Previous API (Implicit):** +```csharp +ReadOnlyMemory data = await cache.GetDataAsync(range, ct); +// Problem: No way to know if this is the full requested range or truncated +``` + +**Current API (Explicit):** +```csharp +RangeResult result = await cache.GetDataAsync(range, ct); +Range? actualRange = result.Range; // The ACTUAL range returned +ReadOnlyMemory data = result.Data; // The data for that range +``` + +**Benefits:** +- **Explicit Contracts**: Consumers know exactly what range was fulfilled +- **Boundary Awareness**: Data sources can signal truncation at physical boundaries +- **No Exceptions for Normal Cases**: Out-of-bounds is not exceptionalβ€”it's expected +- **Future Extensibility**: Foundation for features like sparse data, tombstones, metadata + +--- + +## RangeResult Structure + +```csharp +public sealed record RangeResult( + Range? Range, + ReadOnlyMemory Data +) where TRange : IComparable; +``` + +### 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`. | + +### 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` + +--- + +## IDataSource Contract + +Data sources implement `IDataSource` and return `RangeChunk` from `FetchAsync`: + +```csharp +public interface IDataSource + where TRangeType : IComparable +{ + Task> FetchAsync( + Range range, + CancellationToken cancellationToken + ); +} +``` + +### RangeChunk Structure + +```csharp +public record RangeChunk( + Range? Range, + IEnumerable Data +) where TRange : IComparable; +``` + +**Important:** `RangeChunk.Range` is **nullable**. IDataSource implementations MUST return `null` Range (not empty Range) to signal that no data is available for the requested range. The cache uses this to distinguish between "empty result" vs "unavailable data". + +--- + +## Usage Patterns + +### Pattern 1: Basic Access + +```csharp +var result = await cache.GetDataAsync( + Intervals.NET.Factories.Range.Closed(100, 200), + ct +); + +// Always check Range before using Data +if (result.Range != null) +{ + Console.WriteLine($"Received {result.Data.Length} elements"); + Console.WriteLine($"Range: {result.Range}"); + + foreach (var item in result.Data.Span) + { + ProcessItem(item); + } +} +else +{ + Console.WriteLine("No data available for requested range"); +} +``` + +### Pattern 2: Accessing Data Directly + +```csharp +// When you know data is available (e.g., infinite data source) +var result = await cache.GetDataAsync(range, ct); +var data = result.Data; // Access data directly + +// Process elements +foreach (var item in data.Span) +{ + ProcessItem(item); +} +``` + +### Pattern 3: Handling Partial Fulfillment + +```csharp +var requestedRange = Intervals.NET.Factories.Range.Closed(50, 150); +var result = await cache.GetDataAsync(requestedRange, ct); + +if (result.Range != null) +{ + // Check if we got the full requested range + if (result.Range.Equals(requestedRange)) + { + Console.WriteLine("Full range fulfilled"); + } + else + { + Console.WriteLine($"Requested: {requestedRange}"); + Console.WriteLine($"Received: {result.Range} (truncated)"); + + // Handle truncation + if (result.Range.Start > requestedRange.Start) + { + Console.WriteLine("Data truncated at start"); + } + if (result.Range.End < requestedRange.End) + { + Console.WriteLine("Data truncated at end"); + } + } +} +``` + +### Pattern 4: Subset Requests from Cache + +```csharp +// Prime cache with large range +await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(0, 1000), ct); + +// Request subset (served from cache) +var subsetResult = await cache.GetDataAsync( + Intervals.NET.Factories.Range.Closed(100, 200), + ct +); + +// Result contains ONLY the requested subset, not full cache +Assert.Equal(101, subsetResult.Data.Length); // [100, 200] = 101 elements +Assert.Equal(100, subsetResult.Data.Span[0]); +Assert.Equal(200, subsetResult.Data.Span[100]); +``` + +--- + +## Bounded Data Sources + +For data sources with physical boundaries (databases with min/max IDs, time-series with temporal limits, paginated APIs): + +### Implementation Guidelines + +1. **No Exceptions**: Never throw for out-of-bounds requests +2. **Truncate Gracefully**: Return intersection of requested and available +3. **Consistent Span**: Ensure `Data.Count()` matches `Range.Span(domain)` +4. **Empty Result**: Return empty enumerable when no data available + +### Example: Database with Bounded Records + +```csharp +public class BoundedDatabaseSource : IDataSource +{ + private const int MinId = 1000; + private const int MaxId = 9999; + private readonly IDatabase _db; + + public async Task> FetchAsync( + Range requested, + CancellationToken ct) + { + // Define available range + var availableRange = Intervals.NET.Factories.Range.Closed(MinId, MaxId); + + // Compute intersection with requested range + var fulfillable = requested.Intersect(availableRange); + + // No data available for this request + if (fulfillable == null) + { + return new RangeChunk( + null, // Range must be null (not requested) to signal no data available + Array.Empty() // Empty data + ); + } + + // Fetch available portion + var data = await _db.FetchRecordsAsync( + fulfillable.LowerBound.Value, + fulfillable.UpperBound.Value, + ct + ); + + return new RangeChunk(fulfillable, data); + } +} +``` + +### Example Scenarios + +```csharp +// Database has records with IDs [1000..9999] + +// Scenario 1: Request within bounds +Request: [2000..3000] +Response: Range = [2000..3000], Data = 1001 records βœ“ + +// Scenario 2: Request overlaps lower boundary +Request: [500..1500] +Response: Range = [1000..1500], Data = 501 records (truncated at lower) βœ“ + +// Scenario 3: Request overlaps upper boundary +Request: [9500..10500] +Response: Range = [9500..9999], Data = 500 records (truncated at upper) βœ“ + +// Scenario 4: Request completely out of bounds (too low) +Request: [0..999] +Response: Range = null, Data = empty βœ“ + +// Scenario 5: Request completely out of bounds (too high) +Request: [10000..11000] +Response: Range = null, Data = empty βœ“ +``` + +### Time-Series Example + +```csharp +public class TimeSeriesSource : IDataSource +{ + private readonly DateTime _dataStart = new DateTime(2020, 1, 1); + private readonly DateTime _dataEnd = new DateTime(2024, 12, 31); + private readonly ITimeSeriesDatabase _db; + + public async Task> FetchAsync( + Range requested, + CancellationToken ct) + { + var availableRange = Intervals.NET.Factories.Range.Closed(_dataStart, _dataEnd); + var fulfillable = requested.Intersect(availableRange); + + if (fulfillable == null) + { + return new RangeChunk( + null, // Range must be null (not requested) to signal no data available + Array.Empty() + ); + } + + var measurements = await _db.QueryAsync( + fulfillable.LowerBound.Value, + fulfillable.UpperBound.Value, + ct + ); + + return new RangeChunk(fulfillable, measurements); + } +} +``` + +--- + +## Testing + +The cache includes comprehensive boundary handling tests in `BoundaryHandlingTests.cs`: + +### Test Coverage (15 tests) + +**RangeResult Structure Tests:** +- βœ… Full data returns range and data +- βœ… Data property contains correct elements +- βœ… Multiple requests each return correct range + +**Cached Data Tests:** +- βœ… Cached data still returns correct range +- βœ… Subset of cache returns requested range (not full cache) +- βœ… Overlapping cache returns merged range + +**Range Property Validation:** +- βœ… Range matches data length +- βœ… Data boundaries match range boundaries + +**Edge Cases:** +- βœ… Single element range +- βœ… Large ranges (10,000+ elements) +- βœ… Disposed cache throws ObjectDisposedException + +**Sequential Access Patterns:** +- βœ… Forward scrolling pattern +- βœ… Backward scrolling pattern + +### Running Boundary Handling Tests + +```bash +# Run all boundary handling tests +dotnet test --filter "FullyQualifiedName~BoundaryHandlingTests" + +# Run specific test +dotnet test --filter "FullyQualifiedName~RangeResult_WithFullData_ReturnsRangeAndData" +``` + +--- + +## Architectural Considerations + +### Why Range is Nullable in RangeResult + +**Design Decision**: `RangeResult.Range` is nullable to signal data unavailability at the **user-facing API level**. + +**Alternatives Considered:** +1. ❌ **Exception-based**: Throw `DataUnavailableException` β†’ Makes unavailability exceptional (it's not) +2. ❌ **Sentinel ranges**: Use special range like `[int.MinValue, int.MinValue]` β†’ Ambiguous and error-prone +3. βœ… **Nullable Range**: Explicit unavailability signal, type-safe, idiomatic C# + +### Cache Behavior with Partial Data + +**Question**: What happens when data source returns truncated range? + +**Answer**: Cache stores and returns **exactly what the data source provides**. If data source returns `[1000..1500]` when requested `[500..1500]`, the cache: +1. Stores `[1000..1500]` internally +2. Returns `RangeResult` with `Range = [1000..1500]` +3. Future requests for `[500..1500]` will fetch `[500..999]` (gap filling) + +**Invariant Preservation**: Cache maintains **contiguity** invariantβ€”no gaps in cached ranges. Partial fulfillment is handled by: +- Storing only the fulfilled portion +- Fetching missing portions on subsequent requests +- Never creating gaps in the cache + +### User Path vs Background Path + +**Critical Distinction**: +- **User Path**: Returns data immediately (synchronous with respect to user request) + - User requests `[100..200]` + - Cache returns `RangeResult` with `Range = [100..200]` or truncated + - Intent published for background rebalancing + +- **Background Path**: Expands cache window asynchronously + - Decision engine evaluates intent + - Rebalance executor fetches expansion ranges + - User is NEVER blocked by rebalance operations + +**RangeResult at Both Paths**: +- User Path: `GetDataAsync()` returns `RangeResult` to user +- Background Path: Rebalance execution receives `RangeChunk` from data source +- Cache internally converts `RangeChunk` β†’ cached state β†’ `RangeResult` for users + +### Thread Safety + +**RangeResult is immutable** (`readonly record struct`), making it inherently thread-safe: +- No mutable state +- Value semantics (struct) +- `ReadOnlyMemory` is safe to share across threads +- Multiple threads can hold references to the same `RangeResult` safely + +**Cache Thread Safety**: +- Single logical consumer (one user, one viewport) +- Internal concurrency (User thread + Background threads) is fully thread-safe +- NOT designed for multiple independent consumers sharing one cache + +--- + +## Summary + +**Key Takeaways:** + +βœ… **RangeResult provides explicit boundary contracts** between cache and consumers +βœ… **Range property indicates actual data returned** (may differ from requested) +βœ… **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 + +--- + +**For More Information:** +- [Architecture Model](architecture-model.md) - System design and concurrency model +- [Invariants](invariants.md) - System constraints and guarantees +- [README.md](../README.md) - Usage examples and getting started +- [Component Map](component-map.md) - Detailed component catalog + diff --git a/docs/component-map.md b/docs/component-map.md index cb6c58e..bf833e1 100644 --- a/docs/component-map.md +++ b/docs/component-map.md @@ -807,7 +807,8 @@ public interface ICacheDiagnostics - `void RebalanceExecutionCancelled()` - Records execution cancellation mid-flight **Rebalance Skip Optimization Events:** -- `void RebalanceSkippedNoRebalanceRange()` - Records skip due to NoRebalanceRange policy +- `void RebalanceSkippedCurrentNoRebalanceRange()` - Records skip due to current cache NoRebalanceRange (Stage 1) +- `void RebalanceSkippedPendingNoRebalanceRange()` - Records skip due to pending rebalance NoRebalanceRange (Stage 2, anti-thrashing) - `void RebalanceSkippedSameRange()` - Records skip due to same-range optimization **Implementations**: diff --git a/docs/diagnostics.md b/docs/diagnostics.md index a313fb1..3865082 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -191,10 +191,12 @@ var cache = new WindowCache( ### User Path Events #### `UserRequestServed()` -**Tracks:** Completion of user request (data returned and intent published) -**Location:** `UserRequestHandler.HandleRequestAsync` (final step) -**Scenarios:** All user scenarios (U1-U5): cold start, full hit, partial hit, full miss/jump -**Interpretation:** Total number of user requests successfully served +**Tracks:** Completion of user request (data returned to caller) +**Location:** `UserRequestHandler.HandleRequestAsync` (final step, inside `!exceptionOccurred` block) +**Scenarios:** All user scenarios (U1-U5) and physical boundary miss (full vacuum) +**Fires when:** No exception occurred β€” regardless of whether a rebalance intent was published +**Does NOT fire when:** An exception propagated out of `HandleRequestAsync` +**Interpretation:** Total number of user requests that completed without exception (including boundary misses where `Range == null`) **Example Usage:** ```csharp @@ -337,6 +339,36 @@ Assert.Equal(1, diagnostics.DataSourceFetchMissingSegments); --- +#### `DataSegmentUnavailable()` +**Tracks:** A fetched chunk returned a `null` Range β€” the requested segment does not exist in the data source +**Location:** `CacheDataExtensionService.UnionAll` (when a `RangeChunk.Range` is null) +**Context:** User Thread (Partial Cache Hit β€” Scenario 3) **and** Background Thread (Rebalance Execution) +**Invariants:** G.48 (IDataSource Boundary Semantics), A.9a (Cache Contiguity) +**Interpretation:** Physical boundary encountered; the unavailable segment is silently skipped to preserve cache contiguity + +**Typical Scenarios:** +- Database with min/max ID bounds β€” extension tries to expand beyond available range +- Time-series data with temporal limits β€” requesting future/past data not yet/no longer available +- Paginated API with maximum pages β€” attempting to fetch beyond last page + +**Important:** This is purely informational. The system gracefully skips unavailable segments during `UnionAll`, and cache contiguity is preserved. No action is required by the caller. + +**Example Usage:** +```csharp +// BoundedDataSource has data in [1000, 9999] +// Request [500, 1500] overlaps lower boundary β€” partial cache hit fetches [500, 999] which returns null +var result = await cache.GetDataAsync(Range.Closed(500, 1500), ct); +await cache.WaitForIdleAsync(); + +// At least one unavailable segment was encountered during extension +Assert.True(diagnostics.DataSegmentUnavailable >= 1); + +// Cache contiguity preserved β€” result is the intersection of requested and available +Assert.Equal(Range.Closed(1000, 1500), result.Range); +``` + +--- + ### Rebalance Intent Lifecycle Events #### `RebalanceIntentPublished()` @@ -349,7 +381,7 @@ Assert.Equal(1, diagnostics.DataSourceFetchMissingSegments); ```csharp await cache.GetDataAsync(Range.Closed(100, 200), ct); -// Every user request publishes exactly one intent +// Intent is published when data was successfully assembled (not on physical boundary misses) Assert.Equal(1, diagnostics.RebalanceIntentPublished); ``` diff --git a/docs/invariants.md b/docs/invariants.md index fc36ff7..356fdce 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -234,6 +234,22 @@ 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. + +**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 +- `Range` is nullable to signal data unavailability without exceptions +- When `Range` is non-null, `Data.Length` MUST equal `Range.Span(domain)` + +**Rationale:** +- 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) + +**Related Documentation:** [Boundary Handling Guide](boundary-handling.md) β€” comprehensive coverage of RangeResult usage patterns, bounded data source implementation, partial fulfillment handling, and testing. + ### 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**. @@ -712,17 +728,20 @@ The Rebalance Decision Path and Rebalance Execution Path MUST execute asynchrono **Implementation:** See [component-map.md - Async Execution Model](#implementation) for enforcement mechanism details. - πŸ”΅ **[Architectural β€” Covered by same test as G.43]** -### G.45: Rebalance Execution Path performs I/O only in background execution context +### G.45: I/O responsibilities are separated between User Path and Rebalance Execution Path **Formal Specification:** -All I/O operations (data fetching via IDataSource) MUST occur exclusively in the background execution context. The User Path MUST complete and return to the caller before any background I/O operations begin. +I/O operations (data fetching via IDataSource) are divided by responsibility: +- **User Path** MAY call `IDataSource.FetchAsync` exclusively to serve the user's immediate requested range (Scenarios U1 Cold Start and U5 Full Cache Miss / Jump). This I/O is unavoidable because the user request cannot be served from cache. +- **Rebalance Execution Path** calls `IDataSource.FetchAsync` exclusively for background cache normalization (expanding or rebuilding the cache beyond the requested range). +- No component other than these two may call `IDataSource.FetchAsync`. **Architectural Properties:** -- User Path is I/O-free: Returns before IDataSource.FetchAsync called -- Background I/O isolation: Data fetching confined to Rebalance Execution Path -- No user-facing latency: I/O costs do not impact user request time +- User Path I/O is request-scoped: only fetches exactly the RequestedRange, never more +- Background I/O is normalization-scoped: fetches missing segments to reach DesiredCacheRange +- Responsibilities never overlap: User Path never fetches beyond RequestedRange; Rebalance Execution never serves user requests directly -**Rationale:** Isolates expensive I/O operations from user-facing request path to minimize latency. +**Rationale:** Separates the latency-critical user-serving fetch (minimal, unavoidable) from the background optimization fetch (potentially large, deferrable). User Path I/O is bounded by the requested range; background I/O is bounded by cache geometry policy. **Implementation:** See [component-map.md - I/O Isolation](#implementation) for enforcement mechanism details. - πŸ”΅ **[Architectural β€” Covered by same test as G.43]** diff --git a/src/SlidingWindowCache.WasmValidation/WasmCompilationValidator.cs b/src/SlidingWindowCache.WasmValidation/WasmCompilationValidator.cs index 9962449..32d6104 100644 --- a/src/SlidingWindowCache.WasmValidation/WasmCompilationValidator.cs +++ b/src/SlidingWindowCache.WasmValidation/WasmCompilationValidator.cs @@ -12,14 +12,14 @@ namespace SlidingWindowCache.WasmValidation; /// internal sealed class SimpleDataSource : IDataSource { - public Task> FetchAsync(Range range, CancellationToken cancellationToken) + public Task> FetchAsync(Range range, CancellationToken cancellationToken) { // Generate deterministic sequential data for the range // Range.Start and Range.End are RangeValue, use implicit conversion to int var start = range.Start.Value; var end = range.End.Value; var data = Enumerable.Range(start, end - start + 1); - return Task.FromResult(data); + return Task.FromResult(new RangeChunk(range, data)); } public Task>> FetchAsync( @@ -106,7 +106,7 @@ public static async Task ValidateConfiguration1_SnapshotMode_UnboundedQueue() await cache.WaitForIdleAsync(); // Use result to avoid unused variable warning - _ = result.Length; + _ = result.Data.Length; // Compilation successful if this code builds for net8.0-browser } @@ -145,7 +145,7 @@ public static async Task ValidateConfiguration2_CopyOnReadMode_UnboundedQueue() var range = Intervals.NET.Factories.Range.Closed(0, 10); var result = await cache.GetDataAsync(range, CancellationToken.None); await cache.WaitForIdleAsync(); - _ = result.Length; + _ = result.Data.Length; } /// @@ -182,7 +182,7 @@ public static async Task ValidateConfiguration3_SnapshotMode_BoundedQueue() var range = Intervals.NET.Factories.Range.Closed(0, 10); var result = await cache.GetDataAsync(range, CancellationToken.None); await cache.WaitForIdleAsync(); - _ = result.Length; + _ = result.Data.Length; } /// @@ -219,6 +219,6 @@ public static async Task ValidateConfiguration4_CopyOnReadMode_BoundedQueue() var range = Intervals.NET.Factories.Range.Closed(0, 10); var result = await cache.GetDataAsync(range, CancellationToken.None); await cache.WaitForIdleAsync(); - _ = result.Length; + _ = result.Data.Length; } } \ No newline at end of file diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs index 83e0dca..4d33b8d 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs @@ -94,7 +94,7 @@ CancellationToken ct var fetchedResults = await _dataSource.FetchAsync(missingRanges, ct) .ConfigureAwait(false); - // Step 3: Union fetched data with current cache + // Step 3: Union fetched data with current cache (UnionAll will filter null ranges) return UnionAll(currentCache, fetchedResults, _domain); } @@ -132,19 +132,40 @@ Range requestedRange /// Combines the existing cached data with the newly fetched data, /// ensuring that the resulting range data is correctly merged and consistent with the domain. /// - private static RangeData UnionAll( + /// + /// Boundary Handling: + /// + /// Segments with null Range (unavailable data from DataSource) are filtered out + /// before union. This ensures cache only contains contiguous available data, + /// preserving Invariant A.9a (Cache Contiguity). + /// + /// + /// When DataSource returns RangeChunk with Range = null (e.g., request beyond database boundaries), + /// those segments are skipped and do not affect the cache. The cache converges to maximum + /// available data without gaps. + /// + /// + private RangeData UnionAll( RangeData current, IEnumerable> rangeChunks, TDomain domain ) { // Combine existing data with fetched data - foreach (var (range, data) in rangeChunks) + foreach (var chunk in rangeChunks) { + // Filter out segments with null ranges (unavailable data) + // This preserves cache contiguity - only available data is stored + if (!chunk.Range.HasValue) + { + _cacheDiagnostics.DataSegmentUnavailable(); + continue; + } + // It is important to call Union on the current range data to overwrite outdated // intersected segments with the newly fetched data, ensuring that the most up-to-date // information is retained in the cache. - current = current.Union(data.ToRangeData(range, domain))!; + current = current.Union(chunk.Data.ToRangeData(chunk.Range!.Value, domain))!; } return current; diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs index 4a41588..6106da7 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs @@ -91,7 +91,7 @@ namespace SlidingWindowCache.Core.Rebalance.Execution; /// /// See also: for unbounded alternative /// -internal sealed class ChannelBasedRebalanceExecutionController +internal sealed class ChannelBasedRebalanceExecutionController : IRebalanceExecutionController where TRange : IComparable where TDomain : IRangeDomain @@ -155,7 +155,7 @@ int capacity _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 @@ -167,7 +167,7 @@ int capacity AllowSynchronousContinuations = false, FullMode = BoundedChannelFullMode.Wait // Block on WriteAsync when full (backpressure) }); - + // Start execution loop immediately - runs for cache lifetime _executionLoopTask = ProcessExecutionRequestsAsync(); } @@ -183,7 +183,7 @@ int capacity /// This property can be safely accessed from multiple threads (intent loop, decision engine). /// /// - public ExecutionRequest? LastExecutionRequest + public ExecutionRequest? LastExecutionRequest => Volatile.Read(ref _lastExecutionRequest); /// @@ -219,7 +219,7 @@ public ExecutionRequest? LastExecutionRequest /// /// public async ValueTask PublishExecutionRequest( - Intent intent, + Intent intent, Range desiredRange, Range? desiredNoRebalanceRange, CancellationToken loopCancellationToken) diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs index f2c2685..4dd392f 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs @@ -70,10 +70,10 @@ ICacheDiagnostics cacheDiagnostics /// This executor is intentionally simple - no analytical decisions, no necessity checks. /// Decision logic has been validated by DecisionEngine before invocation. /// -/// Serialization: The active IRebalanceExecutionController actor guarantees single-threaded -/// execution (via task chaining or channel-based sequential processing depending on configuration). -/// No semaphore needed β€” the actor ensures only one execution runs at a time. -/// Cancellation allows fast exit from superseded operations. + /// Serialization: The active IRebalanceExecutionController actor guarantees single-threaded + /// execution (via task chaining or channel-based sequential processing depending on configuration). + /// No semaphore needed β€” the actor ensures only one execution runs at a time. + /// Cancellation allows fast exit from superseded operations. /// public async Task ExecuteAsync( Intent intent, diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs index e9c1c56..166b4d8 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs @@ -85,7 +85,7 @@ namespace SlidingWindowCache.Core.Rebalance.Execution; /// /// See also: for bounded alternative with backpressure /// -internal sealed class TaskBasedRebalanceExecutionController +internal sealed class TaskBasedRebalanceExecutionController : IRebalanceExecutionController where TRange : IComparable where TDomain : IRangeDomain @@ -146,7 +146,7 @@ AsyncActivityCounter 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 => + public ExecutionRequest? LastExecutionRequest => Volatile.Read(ref _lastExecutionRequest); /// @@ -188,7 +188,7 @@ AsyncActivityCounter activityCounter /// /// public ValueTask PublishExecutionRequest( - Intent intent, + Intent intent, Range desiredRange, Range? desiredNoRebalanceRange, CancellationToken loopCancellationToken) @@ -218,7 +218,7 @@ public ValueTask PublishExecutionRequest( desiredNoRebalanceRange, cancellationTokenSource ); - + // Store as last request (for cancellation coordination and diagnostics) Volatile.Write(ref _lastExecutionRequest, request); diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index e57b90d..dbf2ffc 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -218,7 +218,7 @@ private async Task ProcessIntentsAsync() { // Wait for signal from user thread await _intentSignal.WaitAsync(_loopCancellation.Token).ConfigureAwait(false); - + // Signal successfully consumed - we must decrement in finally consumedSignal = true; diff --git a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs index 94b66f9..68503f1 100644 --- a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs +++ b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs @@ -1,4 +1,4 @@ -ο»Ώusing Intervals.NET; +using Intervals.NET; using Intervals.NET.Data; using Intervals.NET.Data.Extensions; using Intervals.NET.Domain.Abstractions; @@ -8,6 +8,7 @@ using SlidingWindowCache.Core.State; using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Dto; namespace SlidingWindowCache.Core.UserPath; @@ -22,7 +23,9 @@ namespace SlidingWindowCache.Core.UserPath; /// Execution Context: User Thread /// Critical Contract: /// -/// Every user access produces a rebalance intent. +/// Every user access that results in assembled data publishes a rebalance intent. +/// Requests where IDataSource returns null for the requested range (physical boundary misses) +/// do not publish an intent, as there is no delivered data to embed (see Invariant C.24e). /// The UserRequestHandler NEVER invokes decision logic. /// /// Responsibilities: @@ -84,17 +87,19 @@ ICacheDiagnostics cacheDiagnostics /// The range requested by the user. /// A cancellation token to cancel the operation. /// - /// A task that represents the asynchronous operation. The task result contains the data - /// for the specified range as a . + /// A task that represents the asynchronous operation. The task result contains a + /// with the actual available range and data. + /// The Range may be null if no data is available, or a subset of requestedRange if truncated at boundaries. /// /// /// This method implements the User Path logic (READ-ONLY with respect to cache state): /// /// Check if requested range is fully or partially covered by cache /// Fetch missing data from IDataSource as needed + /// Compute actual available range (intersection of requested and available) /// Materialize assembled data to array /// Publish rebalance intent with delivered data (fire-and-forget) - /// Return data immediately + /// Return RangeResult immediately /// /// CRITICAL: User Path is READ-ONLY /// @@ -108,8 +113,14 @@ ICacheDiagnostics cacheDiagnostics /// ❌ NEVER writes to NoRebalanceRange /// /// + /// Boundary Handling: + /// + /// When DataSource has physical boundaries (e.g., database min/max IDs), the returned + /// RangeResult.Range indicates what portion of the request was actually available. + /// This allows graceful handling of out-of-bounds requests without exceptions. + /// /// - public async ValueTask> HandleRequestAsync( + public async ValueTask> HandleRequestAsync( Range requestedRange, CancellationToken cancellationToken) { @@ -126,21 +137,36 @@ public async ValueTask> HandleRequestAsync( var isColdStart = !_state.LastRequested.HasValue; RangeData? assembledData = null; - ReadOnlyMemory resultData; + var exceptionOccurred = false; try { + Range? actualRange; + ReadOnlyMemory resultData; + if (isColdStart) { // Scenario 1: Cold Start // Cache has never been populated - fetch data ONLY for requested range _cacheDiagnostics.DataSourceFetchSingleRange(); - assembledData = (await _dataSource.FetchAsync(requestedRange, cancellationToken)) - .ToRangeData(requestedRange, _state.Domain); + var fetchedChunk = await _dataSource.FetchAsync(requestedRange, cancellationToken); - _cacheDiagnostics.UserRequestFullCacheMiss(); + // Handle boundary: chunk.Range may be null or truncated + if (fetchedChunk.Range.HasValue) + { + assembledData = fetchedChunk.Data.ToRangeData(fetchedChunk.Range.Value, _state.Domain); + actualRange = fetchedChunk.Range.Value; + resultData = new ReadOnlyMemory(assembledData.Data.ToArray()); + } + else + { + // No data available for requested range + assembledData = null; + actualRange = null; + resultData = ReadOnlyMemory.Empty; + } - resultData = new ReadOnlyMemory(assembledData.Data.ToArray()); + _cacheDiagnostics.UserRequestFullCacheMiss(); } else { @@ -154,6 +180,8 @@ public async ValueTask> HandleRequestAsync( _cacheDiagnostics.UserRequestFullCacheHit(); + actualRange = requestedRange; // Fully in cache, so actual = requested + // Return a requested range data using the cache storage's Read method, which may return a view or a copy depending on the strategy resultData = cacheStorage.Read(requestedRange); } @@ -175,7 +203,23 @@ public async ValueTask> HandleRequestAsync( _cacheDiagnostics.UserRequestPartialCacheHit(); - resultData = new ReadOnlyMemory(assembledData[requestedRange].Data.ToArray()); + // Compute actual available range (intersection of requested and assembled) + // assembledData.Range may not fully cover requestedRange if DataSource returned truncated/null chunks + // (e.g., bounded source where some segments are unavailable) + actualRange = assembledData.Range.Intersect(requestedRange); + + // Slice to the actual available range (may be smaller than requestedRange) + if (actualRange.HasValue) + { + var slicedData = assembledData[actualRange.Value]; + resultData = new ReadOnlyMemory(slicedData.Data.ToArray()); + } + else + { + // No actual intersection after extension (defensive fallback) + assembledData = null; + resultData = ReadOnlyMemory.Empty; + } } else { @@ -184,36 +228,63 @@ public async ValueTask> HandleRequestAsync( // Fetch ONLY the requested range from IDataSource // NOTE: The logic is similar to cold start _cacheDiagnostics.DataSourceFetchSingleRange(); - assembledData = (await _dataSource.FetchAsync(requestedRange, cancellationToken).ConfigureAwait(false)) - .ToRangeData(requestedRange, _state.Domain); + var fetchedChunk = await _dataSource.FetchAsync(requestedRange, cancellationToken) + .ConfigureAwait(false); - _cacheDiagnostics.UserRequestFullCacheMiss(); + // Handle boundary: chunk.Range may be null or truncated + if (fetchedChunk.Range.HasValue) + { + assembledData = fetchedChunk.Data.ToRangeData(fetchedChunk.Range.Value, _state.Domain); + actualRange = fetchedChunk.Range.Value; + resultData = new ReadOnlyMemory(assembledData.Data.ToArray()); + } + else + { + // No data available for requested range + assembledData = null; + actualRange = null; + resultData = ReadOnlyMemory.Empty; + } - resultData = new ReadOnlyMemory(assembledData.Data.ToArray()); + _cacheDiagnostics.UserRequestFullCacheMiss(); } } } + + // Return RangeResult with actual available range and data + return new RangeResult(actualRange, resultData); + } + catch + { + // In case of any exception during request handling, we want to ensure that we do not publish an intent with potentially inconsistent data. The exception will propagate to the caller, but we set a flag to prevent intent publication in the finally block. + exceptionOccurred = true; + throw; } finally { - // If assembledData is NULL, it means an exception was thrown during data retrieval (either from cache or data source). - // Publishing intent doesn't make sense, the possibly redundant rebalance triggered by this failure will simply fail again during execution or next user request. - // So, exception should be caught and handled before proceeding to publish intent. - if (assembledData is not null) + var shouldPublishIntent = assembledData is not null; + + if (!exceptionOccurred) { - // Create new Intent - var intent = new Intent(requestedRange, assembledData); + // Publish intent only when there was a physical data hit (assembledData is not null). + // Full vacuum (out-of-physical-bounds) requests produce no intent β€” there is no + // meaningful cache shift to signal to the rebalance pipeline. + // If an exception occurred, we skip both intent and served-counter to avoid recording + // incomplete or inconsistent state. + if (shouldPublishIntent) + { + var intent = new Intent(requestedRange, assembledData!); - // Publish rebalance intent with assembled data range (fire-and-forget) - // Rebalance Execution will use this as the authoritative source - _intentController.PublishIntent(intent); + // Publish rebalance intent with assembled data range (fire-and-forget) + // Rebalance Execution will use this as the authoritative source + _intentController.PublishIntent(intent); + } + // UserRequestServed fires for ALL non-exception completions, including boundary misses + // where assembledData == null (full vacuum / out-of-physical-bounds). _cacheDiagnostics.UserRequestServed(); } } - - // Return data directly - return resultData; } /// diff --git a/src/SlidingWindowCache/Infrastructure/Concurrency/AsyncActivityCounter.cs b/src/SlidingWindowCache/Infrastructure/Concurrency/AsyncActivityCounter.cs index 6fcb7bc..07efc5c 100644 --- a/src/SlidingWindowCache/Infrastructure/Concurrency/AsyncActivityCounter.cs +++ b/src/SlidingWindowCache/Infrastructure/Concurrency/AsyncActivityCounter.cs @@ -131,7 +131,7 @@ public void IncrementActivity() { // Create new TCS for this busy period var newTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - + // Publish new TCS with release fence (Volatile.Write) // Ensures TCS construction completes before reference becomes visible Volatile.Write(ref _idleTcs, newTcs); @@ -203,7 +203,7 @@ public void DecrementActivity() // Read current TCS with acquire fence (Volatile.Read) // Ensures we observe TCS published by Volatile.Write in IncrementActivity var tcs = Volatile.Read(ref _idleTcs); - + // Signal idle state - TrySetResult is thread-safe and idempotent // Multiple threads might see count=0 simultaneously, but only first TrySetResult succeeds tcs.TrySetResult(true); @@ -259,7 +259,7 @@ public Task WaitForIdleAsync(CancellationToken cancellationToken = default) // Snapshot current TCS with acquire fence (Volatile.Read) // Ensures we observe TCS published by Volatile.Write in IncrementActivity var tcs = Volatile.Read(ref _idleTcs); - + // Use Task.WaitAsync for simplified cancellation (available in .NET 6+) // If already completed, returns immediately // If pending, waits until signaled or cancellation token fires diff --git a/src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs b/src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs index 7aa6fff..d2769ae 100644 --- a/src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs +++ b/src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs @@ -24,6 +24,7 @@ public class EventCounterCacheDiagnostics : ICacheDiagnostics private int _userRequestFullCacheMiss; private int _dataSourceFetchSingleRange; private int _dataSourceFetchMissingSegments; + private int _dataSegmentUnavailable; private int _rebalanceExecutionFailed; public int UserRequestServed => _userRequestServed; @@ -34,6 +35,7 @@ public class EventCounterCacheDiagnostics : ICacheDiagnostics public int UserRequestFullCacheMiss => _userRequestFullCacheMiss; public int DataSourceFetchSingleRange => _dataSourceFetchSingleRange; public int DataSourceFetchMissingSegments => _dataSourceFetchMissingSegments; + public int DataSegmentUnavailable => _dataSegmentUnavailable; public int RebalanceIntentPublished => _rebalanceIntentPublished; public int RebalanceIntentCancelled => _rebalanceIntentCancelled; public int RebalanceExecutionStarted => _rebalanceExecutionStarted; @@ -55,6 +57,10 @@ public class EventCounterCacheDiagnostics : ICacheDiagnostics void ICacheDiagnostics.DataSourceFetchMissingSegments() => Interlocked.Increment(ref _dataSourceFetchMissingSegments); + /// + void ICacheDiagnostics.DataSegmentUnavailable() => + Interlocked.Increment(ref _dataSegmentUnavailable); + /// void ICacheDiagnostics.DataSourceFetchSingleRange() => Interlocked.Increment(ref _dataSourceFetchSingleRange); @@ -137,6 +143,7 @@ public void Reset() _userRequestFullCacheMiss = 0; _dataSourceFetchSingleRange = 0; _dataSourceFetchMissingSegments = 0; + _dataSegmentUnavailable = 0; _rebalanceExecutionFailed = 0; } } \ No newline at end of file diff --git a/src/SlidingWindowCache/Infrastructure/Instrumentation/ICacheDiagnostics.cs b/src/SlidingWindowCache/Infrastructure/Instrumentation/ICacheDiagnostics.cs index 59eeec9..8d86e57 100644 --- a/src/SlidingWindowCache/Infrastructure/Instrumentation/ICacheDiagnostics.cs +++ b/src/SlidingWindowCache/Infrastructure/Instrumentation/ICacheDiagnostics.cs @@ -14,9 +14,11 @@ public interface ICacheDiagnostics /// /// Records a completed user request served by the User Path. - /// Called at the end of UserRequestHandler.HandleRequestAsync after data is returned to the user and intent is published. - /// Tracks completion of all user scenarios: cold start (U1), full cache hit (U2, U3), partial cache hit (U4), and full cache miss/jump (U5). - /// Location: UserRequestHandler.HandleRequestAsync (final step) + /// Called at the end of UserRequestHandler.HandleRequestAsync after data is returned to the user. + /// Fires for ALL successfully completed requests (no exception), regardless of whether a rebalance intent was published. + /// This includes boundary misses (full vacuum / out-of-physical-bounds requests) where assembledData is null and no intent is published. + /// Tracks completion of all user scenarios: cold start (U1), full cache hit (U2, U3), partial cache hit (U4), full cache miss/jump (U5), and physical boundary miss. + /// Location: UserRequestHandler.HandleRequestAsync (final step, inside !exceptionOccurred block) /// void UserRequestServed(); @@ -88,6 +90,32 @@ public interface ICacheDiagnostics /// void DataSourceFetchMissingSegments(); + /// + /// Called when a data segment is unavailable because the DataSource returned a null Range. + /// This typically occurs when prefetching or extending the cache hits physical boundaries + /// (e.g., database min/max IDs, time-series with temporal limits, paginated APIs with max pages). + /// + /// + /// Context: User Thread (Partial Cache Hit β€” Scenario 3) and Background Thread (Rebalance Execution) + /// + /// This is informational only - the system handles boundaries gracefully by skipping + /// unavailable segments during cache union (UnionAll), preserving cache contiguity (Invariant A.9a). + /// + /// Typical Scenarios: + /// + /// Database with min/max ID bounds - extension tries to expand beyond available range + /// Time-series data with temporal limits - requesting future/past data not yet/no longer available + /// Paginated API with maximum pages - attempting to fetch beyond last page + /// + /// + /// Location: CacheDataExtensionService.UnionAll (when a fetched chunk has a null Range) + /// + /// + /// Related: Invariant G.48 (IDataSource Boundary Semantics), Invariant A.9a (Cache Contiguity) + /// + /// + void DataSegmentUnavailable(); + // ============================================================================ // REBALANCE INTENT LIFECYCLE COUNTERS // ============================================================================ @@ -95,7 +123,9 @@ public interface ICacheDiagnostics /// /// Records publication of a rebalance intent by the User Path. /// Called after UserRequestHandler publishes an intent containing delivered data to IntentController. - /// Every user request produces exactly one intent publication (fire-and-forget). + /// Intent is published only when the user request results in assembled data (assembledData != null). + /// Physical boundary misses β€” where IDataSource returns null for the requested range β€” do not produce an intent + /// because there is no delivered data to embed in the intent (see Invariant C.24e). /// Location: IntentController.PublishIntent (after scheduler receives intent) /// Related: Invariant A.3 (User Path is sole source of rebalance intent), Invariant 24e (Intent must contain delivered data) /// Note: Intent publication does NOT guarantee execution (opportunistic behavior) diff --git a/src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs b/src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs index ac3cad9..227c761 100644 --- a/src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs +++ b/src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs @@ -20,6 +20,11 @@ public void DataSourceFetchMissingSegments() { } + /// + public void DataSegmentUnavailable() + { + } + /// public void DataSourceFetchSingleRange() { diff --git a/src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs b/src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs index d535cc6..8c630bc 100644 --- a/src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs +++ b/src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs @@ -65,7 +65,7 @@ public WindowCacheOptions( } // Validate that thresholds don't overlap (sum must not exceed 1.0) - if (leftThreshold.HasValue && rightThreshold.HasValue && + if (leftThreshold.HasValue && rightThreshold.HasValue && (leftThreshold.Value + rightThreshold.Value) > 1.0) { throw new ArgumentException( diff --git a/src/SlidingWindowCache/Public/Dto/RangeChunk.cs b/src/SlidingWindowCache/Public/Dto/RangeChunk.cs index 2aee087..c239cee 100644 --- a/src/SlidingWindowCache/Public/Dto/RangeChunk.cs +++ b/src/SlidingWindowCache/Public/Dto/RangeChunk.cs @@ -5,5 +5,28 @@ namespace SlidingWindowCache.Public.Dto; /// /// Represents a chunk of data associated with a specific range. This is used to encapsulate the data fetched for a particular range in the sliding window cache. /// -public record RangeChunk(Range Range, IEnumerable Data) +/// The type representing range boundaries. +/// The type of data elements. +/// +/// The range of data in this chunk. +/// Null if no data is available for the requested range (e.g., out of physical bounds). +/// When non-null, the Data enumerable MUST contain exactly Range.Span elements. +/// +/// +/// The data elements for the range. +/// Empty enumerable when Range is null. +/// +/// +/// IDataSource Contract: +/// Implementations MUST return null Range when no data is available +/// (e.g., requested range beyond physical database boundaries, time-series temporal limits). +/// Implementations MUST NOT throw exceptions for out-of-bounds requests. +/// Example - Bounded Database: +/// +/// // Database with records ID 100-500 +/// // Request [50..150] β†’ Return RangeChunk([100..150], 51 records) +/// // Request [600..700] β†’ Return RangeChunk(null, empty enumerable) +/// +/// +public record RangeChunk(Range? Range, IEnumerable Data) where TRangeType : IComparable; \ No newline at end of file diff --git a/src/SlidingWindowCache/Public/Dto/RangeResult.cs b/src/SlidingWindowCache/Public/Dto/RangeResult.cs new file mode 100644 index 0000000..395909b --- /dev/null +++ b/src/SlidingWindowCache/Public/Dto/RangeResult.cs @@ -0,0 +1,41 @@ +using Intervals.NET; + +namespace SlidingWindowCache.Public.Dto; + +/// +/// Represents the result of a cache data request, containing the actual available range and data. +/// +/// 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. +/// 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. +/// +/// +/// ActualRange 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. +/// Example Usage: +/// +/// var result = await cache.GetDataAsync(Range.Closed(50, 600), ct); +/// if (result.Range.HasValue) +/// { +/// Console.WriteLine($"Available: {result.Range.Value}"); +/// ProcessData(result.Data); +/// } +/// else +/// { +/// Console.WriteLine("No data available"); +/// } +/// +/// +public sealed record RangeResult( + Range? Range, + ReadOnlyMemory Data +) where TRange : IComparable; diff --git a/src/SlidingWindowCache/Public/IDataSource.cs b/src/SlidingWindowCache/Public/IDataSource.cs index 2d8aef7..0d545f4 100644 --- a/src/SlidingWindowCache/Public/IDataSource.cs +++ b/src/SlidingWindowCache/Public/IDataSource.cs @@ -68,7 +68,43 @@ public interface IDataSource where TRangeType : IComparab /// The task result contains an enumerable of data of type /// for the specified range. /// - Task> FetchAsync( + /// + /// Bounded Data Sources: + /// + /// For data sources with physical boundaries (e.g., databases with min/max IDs, + /// time-series with temporal limits, paginated APIs with maximum pages), implementations MUST: + /// + /// + /// Return RangeChunk with Range = null when no data is available for the requested range + /// Return truncated range when partial data is available (intersection of requested and available) + /// NEVER throw exceptions for out-of-bounds requests - use null Range instead + /// Ensure Data.Count() equals Range.Span when Range is non-null + /// + /// Boundary Handling Examples: + /// + /// // Database with records ID 100-500 + /// public async Task<RangeChunk<int, MyData>> FetchAsync(Range<int> requested, CancellationToken ct) + /// { + /// // Compute intersection with available range + /// var available = requested.Intersect(Range.Closed(MinId, MaxId)); + /// + /// // No data available - return RangeChunk with null Range + /// if (available == null) + /// return new RangeChunk<int, MyData>(null, Array.Empty<MyData>()); + /// + /// // Fetch available portion + /// var data = await Database.FetchRecordsAsync(available.LeftEndpoint, available.RightEndpoint, ct); + /// return new RangeChunk<int, MyData>(available, data); + /// } + /// + /// // Examples: + /// // Request [50..150] β†’ RangeChunk([100..150], 51 records) - truncated at lower bound + /// // Request [400..600] β†’ RangeChunk([400..500], 101 records) - truncated at upper bound + /// // Request [600..700] β†’ RangeChunk(null, empty) - completely out of bounds + /// + /// See documentation on boundary handling for detailed guidance. + /// + Task> FetchAsync( Range range, CancellationToken cancellationToken ); @@ -86,7 +122,7 @@ CancellationToken cancellationToken /// /// A task that represents the asynchronous fetch operation. /// The task result contains an enumerable of - /// for the specified ranges. + /// for the specified ranges. Each RangeChunk may have a null Range if no data is available. /// /// /// Default Behavior: @@ -104,19 +140,19 @@ CancellationToken cancellationToken /// Batch API endpoints that accept multiple range parameters /// Custom batching logic with size limits or throttling /// + /// Boundary Handling: + /// + /// When implementing for bounded data sources, ensure each RangeChunk follows the same + /// boundary contract as the single-range FetchAsync method (null Range for unavailable data, + /// truncated ranges for partial availability). + /// /// async Task>> FetchAsync( IEnumerable> ranges, CancellationToken cancellationToken ) { - var tasks = ranges.Select(async range => - new RangeChunk( - range, - await FetchAsync(range, cancellationToken) - ) - ); - + var tasks = ranges.Select(async range => await FetchAsync(range, cancellationToken)); return await Task.WhenAll(tasks); } } \ No newline at end of file diff --git a/src/SlidingWindowCache/Public/WindowCache.cs b/src/SlidingWindowCache/Public/WindowCache.cs index 91283fb..7f3445e 100644 --- a/src/SlidingWindowCache/Public/WindowCache.cs +++ b/src/SlidingWindowCache/Public/WindowCache.cs @@ -1,4 +1,4 @@ -ο»Ώusing Intervals.NET; +using Intervals.NET; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Planning; using SlidingWindowCache.Core.Rebalance.Decision; @@ -10,6 +10,7 @@ using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Infrastructure.Storage; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Dto; namespace SlidingWindowCache.Public; @@ -74,10 +75,37 @@ public interface IWindowCache : IAsyncDisposable /// A cancellation token to cancel the operation. /// /// - /// A task that represents the asynchronous operation. The task result contains the data - /// for the specified range as a . + /// A task that represents the asynchronous operation. The task result contains a + /// with the actual available range and data. /// - ValueTask> GetDataAsync( + /// + /// Bounded Data Sources: + /// + /// When working with bounded data sources (e.g., databases with min/max IDs, time-series with + /// temporal limits), the returned RangeResult.Range indicates what portion of the request was + /// actually available. The Range may be: + /// + /// + /// Equal to requestedRange - all data available (typical case) + /// Subset of requestedRange - partial data available (truncated at boundaries) + /// Null - no data available for the requested range + /// + /// Example: + /// + /// var result = await cache.GetDataAsync(Range.Closed(50, 600), ct); + /// if (result.Range.HasValue) + /// { + /// Console.WriteLine($"Got data for range: {result.Range.Value}"); + /// ProcessData(result.Data); + /// } + /// else + /// { + /// Console.WriteLine("No data available for requested range"); + /// } + /// + /// See boundary handling documentation for details. + /// + ValueTask> GetDataAsync( Range requestedRange, CancellationToken cancellationToken); @@ -264,7 +292,7 @@ WindowCacheOptions windowCacheOptions /// This method acts as a thin delegation layer to the internal actor. /// WindowCache itself implements no business logic - it is a pure facade. /// - public ValueTask> GetDataAsync( + public ValueTask> GetDataAsync( Range requestedRange, CancellationToken cancellationToken) { @@ -273,10 +301,10 @@ public ValueTask> GetDataAsync( { throw new ObjectDisposedException( nameof(WindowCache), - "Cannot access a disposed WindowCache instance."); + "Cannot retrieve data from a disposed cache."); } - // Pure facade: delegate to UserRequestHandler actor + // Delegate to UserRequestHandler (Fast Path Actor) return _userRequestHandler.HandleRequestAsync(requestedRange, cancellationToken); } @@ -401,7 +429,7 @@ public async ValueTask DisposeAsync() // Dispose the UserRequestHandler which cascades to all internal actors // Disposal order: UserRequestHandler -> IntentController -> RebalanceExecutionController await _userRequestHandler.DisposeAsync().ConfigureAwait(false); - + // Signal successful completion tcs.TrySetResult(true); } @@ -423,12 +451,12 @@ public async ValueTask DisposeAsync() // Brief spin-wait for TCS publication (should be very fast - CPU-only operation) TaskCompletionSource? tcs; var spinWait = new SpinWait(); - + while ((tcs = Volatile.Read(ref _disposalCompletionSource)) == null) { spinWait.SpinOnce(); } - + // Await disposal completion without CPU burn // If winner threw exception, this will re-throw the same exception await tcs.Task.ConfigureAwait(false); diff --git a/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs new file mode 100644 index 0000000..91b39cc --- /dev/null +++ b/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs @@ -0,0 +1,393 @@ +using Intervals.NET.Domain.Default.Numeric; +using SlidingWindowCache.Integration.Tests.TestInfrastructure; +using SlidingWindowCache.Infrastructure.Instrumentation; +using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Configuration; + +namespace SlidingWindowCache.Integration.Tests; + +/// +/// Tests that validate boundary handling when the data source has physical limits. +/// Uses BoundedDataSource (MinId=1000, MaxId=9999) to simulate a database with bounded records. +/// +/// Scenarios covered: +/// - User Path: Physical data miss, partial hit, full hit +/// - Rebalance Path: Physical data miss, partial miss, full hit +/// +public sealed class BoundaryHandlingTests : IAsyncDisposable +{ + private readonly IntegerFixedStepDomain _domain; + private readonly BoundedDataSource _dataSource; + private WindowCache? _cache; + private readonly EventCounterCacheDiagnostics _cacheDiagnostics; + + public BoundaryHandlingTests() + { + _domain = new IntegerFixedStepDomain(); + _dataSource = new BoundedDataSource(); + _cacheDiagnostics = new EventCounterCacheDiagnostics(); + } + + public async ValueTask DisposeAsync() + { + if (_cache != null) + { + await _cache.WaitForIdleAsync(); + await _cache.DisposeAsync(); + } + } + + #region User Path - Boundary Handling + + [Fact] + public async Task UserPath_PhysicalDataMiss_ReturnsNullRange() + { + // ARRANGE - Bounded data source with data in [1000, 9999] + var cache = CreateCache(); + + // Request completely below physical bounds + var requestBelowBounds = Intervals.NET.Factories.Range.Closed(0, 999); + + // ACT + var result = await cache.GetDataAsync(requestBelowBounds, CancellationToken.None); + + // ASSERT - Range is null, data is empty + Assert.Null(result.Range); + Assert.True(result.Data.IsEmpty); + Assert.Equal(0, result.Data.Length); + } + + [Fact] + public async Task UserPath_PhysicalDataMiss_AboveBounds_ReturnsNullRange() + { + // ARRANGE + var cache = CreateCache(); + + // Request completely above physical bounds + var requestAboveBounds = Intervals.NET.Factories.Range.Closed(10000, 11000); + + // ACT + var result = await cache.GetDataAsync(requestAboveBounds, CancellationToken.None); + + // ASSERT - Range is null, data is empty + Assert.Null(result.Range); + Assert.True(result.Data.IsEmpty); + Assert.Equal(0, result.Data.Length); + } + + [Fact] + public async Task UserPath_PartialHit_LowerBoundaryTruncation_ReturnsTruncatedRange() + { + // ARRANGE - Data available in [1000, 9999] + var cache = CreateCache(); + + // Request [500, 1500] - overlaps lower boundary + // Expected: [1000, 1500] (truncated at lower boundary) + var requestedRange = Intervals.NET.Factories.Range.Closed(500, 1500); + + // ACT + var result = await cache.GetDataAsync(requestedRange, CancellationToken.None); + + // ASSERT - Range is truncated to [1000, 1500] + Assert.NotNull(result.Range); + var expectedRange = Intervals.NET.Factories.Range.Closed(1000, 1500); + Assert.Equal(expectedRange, result.Range); + + // Data should contain 501 elements [1000..1500] + Assert.Equal(501, result.Data.Length); + Assert.Equal(1000, result.Data.Span[0]); + Assert.Equal(1500, result.Data.Span[500]); + } + + [Fact] + public async Task UserPath_PartialHit_UpperBoundaryTruncation_ReturnsTruncatedRange() + { + // ARRANGE - Data available in [1000, 9999] + var cache = CreateCache(); + + // Request [9500, 10500] - overlaps upper boundary + // Expected: [9500, 9999] (truncated at upper boundary) + var requestedRange = Intervals.NET.Factories.Range.Closed(9500, 10500); + + // ACT + var result = await cache.GetDataAsync(requestedRange, CancellationToken.None); + + // ASSERT - Range is truncated to [9500, 9999] + Assert.NotNull(result.Range); + var expectedRange = Intervals.NET.Factories.Range.Closed(9500, 9999); + Assert.Equal(expectedRange, result.Range); + + // Data should contain 500 elements [9500..9999] + Assert.Equal(500, result.Data.Length); + Assert.Equal(9500, result.Data.Span[0]); + Assert.Equal(9999, result.Data.Span[499]); + } + + [Fact] + public async Task UserPath_FullHit_WithinBounds_ReturnsFullRange() + { + // ARRANGE - Data available in [1000, 9999] + var cache = CreateCache(); + + // Request [2000, 3000] - completely within bounds + var requestedRange = Intervals.NET.Factories.Range.Closed(2000, 3000); + + // ACT + var result = await cache.GetDataAsync(requestedRange, CancellationToken.None); + + // ASSERT - Full requested range returned + Assert.NotNull(result.Range); + Assert.Equal(requestedRange, result.Range); + + // Data should contain 1001 elements [2000..3000] + Assert.Equal(1001, result.Data.Length); + Assert.Equal(2000, result.Data.Span[0]); + Assert.Equal(3000, result.Data.Span[1000]); + } + + [Fact] + public async Task UserPath_FullHit_AtExactBoundaries_ReturnsFullRange() + { + // ARRANGE + var cache = CreateCache(); + + // Request exactly at physical boundaries [1000, 9999] + var requestedRange = Intervals.NET.Factories.Range.Closed(1000, 9999); + + // ACT + var result = await cache.GetDataAsync(requestedRange, CancellationToken.None); + + // ASSERT - Full range at exact boundaries + Assert.NotNull(result.Range); + Assert.Equal(requestedRange, result.Range); + + // Data should contain 9000 elements [1000..9999] + Assert.Equal(9000, result.Data.Length); + Assert.Equal(1000, result.Data.Span[0]); + Assert.Equal(9999, result.Data.Span[8999]); + } + + /// + /// When a request is completely outside the physical bounds of the data source, + /// the user path must: + /// - Return RangeResult with null Range and empty Data (full vacuum) + /// - Count the request as served (UserRequestServed == 1) β€” the request completed without exception + /// - NOT publish a rebalance intent (RebalanceIntentPublished == 0) β€” no meaningful data hit to signal + /// + /// This validates the boundary between "request completed" and "intent published": + /// UserRequestServed fires whenever !exceptionOccurred, even on full vacuum. + /// Intent is only published when assembledData is not null (physical data hit occurred). + /// + [Fact] + public async Task UserPath_PhysicalDataMiss_CountsAsServed_ButDoesNotPublishIntent() + { + // ARRANGE - Bounded data source has data only in [1000, 9999] + var cache = CreateCache(); + + // Request completely below physical bounds (full vacuum β€” no data whatsoever) + var requestBelowBounds = Intervals.NET.Factories.Range.Closed(0, 999); + + // ACT + var result = await cache.GetDataAsync(requestBelowBounds, CancellationToken.None); + + // ASSERT - No data returned (full vacuum) + Assert.Null(result.Range); + Assert.True(result.Data.IsEmpty); + + // ASSERT - Request was completed without exception β†’ counts as served + Assert.Equal(1, _cacheDiagnostics.UserRequestServed); + + // ASSERT - No physical data hit β†’ no rebalance intent published + Assert.Equal(0, _cacheDiagnostics.RebalanceIntentPublished); + } + + #endregion + + #region Rebalance Path - Boundary Handling + + [Fact] + public async Task RebalancePath_PhysicalDataMiss_CacheContainsOnlyAvailableData() + { + // ARRANGE - Data available in [1000, 9999] + // Configure cache with large left coefficient to trigger rebalance below bounds + var cache = CreateCacheWithLeftExpansion(); + + // Initial request at [1100, 1200] - rebalance will try to expand left beyond bounds + var initialRequest = Intervals.NET.Factories.Range.Closed(1100, 1200); + + // ACT + var result = await cache.GetDataAsync(initialRequest, CancellationToken.None); + await cache.WaitForIdleAsync(); // Wait for rebalance to complete + + // ASSERT - User got requested data + Assert.NotNull(result.Range); + Assert.Equal(initialRequest, result.Range); + Assert.Equal(101, result.Data.Length); + + // After rebalance, cache should only contain data from [1000, ...] (not below) + // Subsequent request below 1000 should still return null + var belowBoundsRequest = Intervals.NET.Factories.Range.Closed(900, 950); + var belowResult = await cache.GetDataAsync(belowBoundsRequest, CancellationToken.None); + + Assert.Null(belowResult.Range); + Assert.True(belowResult.Data.IsEmpty); + } + + [Fact] + public async Task RebalancePath_PartialMiss_LowerBoundary_CacheExpandsToLimit() + { + // ARRANGE - Configure cache to expand left significantly + var cache = CreateCacheWithLeftExpansion(); + + // Request near lower boundary - rebalance will hit physical limit + var requestNearBoundary = Intervals.NET.Factories.Range.Closed(1050, 1150); + + // ACT + var result = await cache.GetDataAsync(requestNearBoundary, CancellationToken.None); + await cache.WaitForIdleAsync(); + + // ASSERT - User got requested data + Assert.NotNull(result.Range); + Assert.Equal(requestNearBoundary, result.Range); + + // Cache should have expanded left to physical boundary (1000) + // Verify by requesting data at the boundary + var boundaryRequest = Intervals.NET.Factories.Range.Closed(1000, 1010); + var boundaryResult = await cache.GetDataAsync(boundaryRequest, CancellationToken.None); + + Assert.NotNull(boundaryResult.Range); + Assert.Equal(boundaryRequest, boundaryResult.Range); + Assert.Equal(11, boundaryResult.Data.Length); + Assert.Equal(1000, boundaryResult.Data.Span[0]); + } + + [Fact] + public async Task RebalancePath_PartialMiss_UpperBoundary_CacheExpandsToLimit() + { + // ARRANGE - Configure cache to expand right significantly + var cache = CreateCacheWithRightExpansion(); + + // Request near upper boundary - rebalance will hit physical limit + var requestNearBoundary = Intervals.NET.Factories.Range.Closed(9850, 9950); + + // ACT + var result = await cache.GetDataAsync(requestNearBoundary, CancellationToken.None); + await cache.WaitForIdleAsync(); + + // ASSERT - User got requested data + Assert.NotNull(result.Range); + Assert.Equal(requestNearBoundary, result.Range); + + // Cache should have expanded right to physical boundary (9999) + // Verify by requesting data at the boundary + var boundaryRequest = Intervals.NET.Factories.Range.Closed(9990, 9999); + var boundaryResult = await cache.GetDataAsync(boundaryRequest, CancellationToken.None); + + Assert.NotNull(boundaryResult.Range); + Assert.Equal(boundaryRequest, boundaryResult.Range); + Assert.Equal(10, boundaryResult.Data.Length); + Assert.Equal(9990, boundaryResult.Data.Span[0]); + Assert.Equal(9999, boundaryResult.Data.Span[9]); + } + + [Fact] + public async Task RebalancePath_FullHit_WithinBounds_CacheExpandsNormally() + { + // ARRANGE - Data source has data in [1000, 9999] + var cache = CreateCache(); + + // Request well within bounds - rebalance should succeed fully + var requestInMiddle = Intervals.NET.Factories.Range.Closed(5000, 5100); + + // ACT + var result = await cache.GetDataAsync(requestInMiddle, CancellationToken.None); + await cache.WaitForIdleAsync(); + + // ASSERT - User got requested data + Assert.NotNull(result.Range); + Assert.Equal(requestInMiddle, result.Range); + + // Rebalance expanded cache in both directions (no physical limits hit) + // Verify cache contains expanded data on both sides + var leftExpanded = Intervals.NET.Factories.Range.Closed(4900, 4950); + var leftResult = await cache.GetDataAsync(leftExpanded, CancellationToken.None); + + Assert.NotNull(leftResult.Range); + Assert.Equal(leftExpanded, leftResult.Range); + + var rightExpanded = Intervals.NET.Factories.Range.Closed(5150, 5200); + var rightResult = await cache.GetDataAsync(rightExpanded, CancellationToken.None); + + Assert.NotNull(rightResult.Range); + Assert.Equal(rightExpanded, rightResult.Range); + } + + #endregion + + #region Helper Methods + + private WindowCache CreateCache() + { + var options = new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot, + leftThreshold: 0.2, + rightThreshold: 0.2, + debounceDelay: TimeSpan.FromMilliseconds(10) + ); + + _cache = new WindowCache( + _dataSource, + _domain, + options, + _cacheDiagnostics + ); + + return _cache; + } + + private WindowCache CreateCacheWithLeftExpansion() + { + var options = new WindowCacheOptions( + leftCacheSize: 3.0, // Large left expansion + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot, + leftThreshold: 0.2, + rightThreshold: 0.2, + debounceDelay: TimeSpan.FromMilliseconds(10) + ); + + _cache = new WindowCache( + _dataSource, + _domain, + options, + _cacheDiagnostics + ); + + return _cache; + } + + private WindowCache CreateCacheWithRightExpansion() + { + var options = new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 3.0, // Large right expansion + readMode: UserCacheReadMode.Snapshot, + leftThreshold: 0.2, + rightThreshold: 0.2, + debounceDelay: TimeSpan.FromMilliseconds(10) + ); + + _cache = new WindowCache( + _dataSource, + _domain, + options, + _cacheDiagnostics + ); + + return _cache; + } + + #endregion +} diff --git a/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs b/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs index 7ddcebb..bff7fee 100644 --- a/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs @@ -85,7 +85,7 @@ public async Task CacheMiss_ColdStart_DataSourceReceivesExactRequestedRange() "DataSource should be asked to fetch at least the requested range [100, 110]"); // Verify data is correct - var array = result.ToArray(); + var array = result.Data.ToArray(); Assert.Equal((int)requestedRange.Span(_domain), array.Length); Assert.Equal(100, array[0]); Assert.Equal(110, array[^1]); @@ -111,7 +111,7 @@ public async Task CacheMiss_NonOverlappingJump_DataSourceReceivesNewRange() Assert.True(_dataSource.TotalFetchCount > 0, "DataSource should be called for non-overlapping range"); // Verify correct data - var array = result.ToArray(); + var array = result.Data.ToArray(); Assert.Equal(11, array.Length); Assert.Equal(500, array[0]); Assert.Equal(510, array[^1]); @@ -137,7 +137,7 @@ public async Task PartialCacheHit_OverlappingRange_FetchesOnlyMissingSegments() var result = await cache.GetDataAsync(overlappingRange, CancellationToken.None); // ASSERT - Verify returned data is correct - var array = result.ToArray(); + var array = result.Data.ToArray(); Assert.Equal(16, array.Length); // [105, 120] = 16 elements Assert.Equal(105, array[0]); Assert.Equal(120, array[^1]); @@ -165,7 +165,7 @@ public async Task PartialCacheHit_LeftExtension_DataCorrect() var result = await cache.GetDataAsync(leftExtendRange, CancellationToken.None); // ASSERT - Verify data correctness - var array = result.ToArray(); + var array = result.Data.ToArray(); Assert.Equal(16, array.Length); Assert.Equal(190, array[0]); Assert.Equal(205, array[^1]); @@ -186,7 +186,7 @@ public async Task PartialCacheHit_RightExtension_DataCorrect() var result = await cache.GetDataAsync(rightExtendRange, CancellationToken.None); // ASSERT - Verify data correctness - var array2 = result.ToArray(); + var array2 = result.Data.ToArray(); Assert.Equal(16, array2.Length); Assert.Equal(305, array2[0]); Assert.Equal(320, array2[^1]); @@ -223,8 +223,8 @@ public async Task Rebalance_WithExpansionCoefficients_ExpandsCacheCorrectly() var data2 = await cache.GetDataAsync(withinExpanded, CancellationToken.None); // ASSERT - Verify data correctness - var array1 = result.ToArray(); - var array2 = data2.ToArray(); + var array1 = result.Data.ToArray(); + var array2 = data2.Data.ToArray(); Assert.Equal(11, array1.Length); Assert.Equal(100, array1[0]); Assert.Equal(11, array2.Length); @@ -255,7 +255,7 @@ public async Task Rebalance_SequentialRequests_CacheAdaptsToPattern() foreach (var range in ranges) { var loopResult = await cache.GetDataAsync(range, CancellationToken.None); - Assert.Equal((int)range.Span(_domain), loopResult.Length); + Assert.Equal((int)range.Span(_domain), loopResult.Data.Length); await cache.WaitForIdleAsync(); } @@ -284,7 +284,7 @@ public async Task NoRedundantFetches_RepeatedSameRange_UsesCache() // ASSERT - Second request should not trigger additional fetch (served from cache) // Note: May trigger rebalance fetch in background, but user data served from cache - var array = data2.ToArray(); + var array = data2.Data.ToArray(); Assert.Equal(11, array.Length); Assert.Equal(100, array[0]); } @@ -316,7 +316,7 @@ public async Task NoRedundantFetches_SubsetOfCache_NoAdditionalFetch() var result = await cache.GetDataAsync(subset, CancellationToken.None); // ASSERT - Data is correct - var array = result.ToArray(); + var array = result.Data.ToArray(); Assert.Equal(11, array.Length); Assert.Equal(150, array[0]); Assert.Equal(160, array[^1]); @@ -387,7 +387,7 @@ public async Task EdgeCase_VerySmallRange_SingleElement_HandlesCorrectly() var result = await cache.GetDataAsync(singleElementRange, CancellationToken.None); // ASSERT - var array1 = result.ToArray(); + var array1 = result.Data.ToArray(); Assert.Single(array1); Assert.Equal(42, array1[0]); Assert.True(_dataSource.TotalFetchCount >= 1); @@ -404,7 +404,7 @@ public async Task EdgeCase_VeryLargeRange_HandlesWithoutError() var result = await cache.GetDataAsync(largeRange, CancellationToken.None); // ASSERT - var array2 = result.ToArray(); + var array2 = result.Data.ToArray(); Assert.Equal(1000, array2.Length); Assert.Equal(0, array2[0]); Assert.Equal(999, array2[^1]); diff --git a/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs b/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs index 019e007..0bc5e60 100644 --- a/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs @@ -80,7 +80,7 @@ public async Task Concurrent_10SimultaneousRequests_AllSucceed() { var start = i * 100; var range = Intervals.NET.Factories.Range.Closed(start, start + 20); - tasks.Add(cache.GetDataAsync(range, CancellationToken.None).AsTask()); + tasks.Add(cache.GetDataAsync(range, CancellationToken.None).AsTask().ContinueWith(t => t.Result.Data)); } var results = await Task.WhenAll(tasks); @@ -122,7 +122,7 @@ public async Task Concurrent_SameRangeMultipleTimes_NoDeadlock() foreach (var result in results) { - var array = result.ToArray(); + var array = result.Data.ToArray(); Assert.Equal(21, array.Length); Assert.Equal(100, array[0]); Assert.Equal(120, array[^1]); @@ -146,7 +146,7 @@ public async Task Concurrent_OverlappingRanges_AllDataValid() { var offset = i * 5; var range = Intervals.NET.Factories.Range.Closed(100 + offset, 150 + offset); - tasks.Add(cache.GetDataAsync(range, CancellationToken.None).AsTask()); + tasks.Add(cache.GetDataAsync(range, CancellationToken.None).AsTask().ContinueWith(t => t.Result.Data)); } var results = await Task.WhenAll(tasks); @@ -183,7 +183,7 @@ public async Task HighVolume_100SequentialRequests_NoErrors() var range = Intervals.NET.Factories.Range.Closed(start, start + 15); var result = await cache.GetDataAsync(range, CancellationToken.None); - Assert.Equal(16, result.Length); + Assert.Equal(16, result.Data.Length); } catch (Exception ex) { @@ -216,7 +216,7 @@ public async Task HighVolume_50ConcurrentBursts_SystemStable() { var start = (i % 10) * 50; // Create some overlap var range = Intervals.NET.Factories.Range.Closed(start, start + 25); - tasks.Add(cache.GetDataAsync(range, CancellationToken.None).AsTask()); + tasks.Add(cache.GetDataAsync(range, CancellationToken.None).AsTask().ContinueWith(t => t.Result.Data)); } var results = await Task.WhenAll(tasks); @@ -258,7 +258,7 @@ public async Task MixedConcurrent_RandomAndSequential_NoConflicts() range = Intervals.NET.Factories.Range.Closed(start, start + 20); } - tasks.Add(cache.GetDataAsync(range, CancellationToken.None).AsTask()); + tasks.Add(cache.GetDataAsync(range, CancellationToken.None).AsTask().ContinueWith(t => t.Result.Data)); } var results = await Task.WhenAll(tasks); @@ -354,7 +354,7 @@ public async Task RapidFire_100RequestsMinimalDelay_NoDeadlock() var range = Intervals.NET.Factories.Range.Closed(start, start + 20); var result = await cache.GetDataAsync(range, CancellationToken.None); - Assert.Equal(21, result.Length); + Assert.Equal(21, result.Data.Length); } // ASSERT - Completed without deadlock @@ -390,7 +390,7 @@ public async Task DataIntegrity_ConcurrentReads_AllDataCorrect() { var range = Intervals.NET.Factories.Range.Closed(500 + offset, 550 + offset); var data = await cache.GetDataAsync(range, CancellationToken.None); - return (data.Length, data.Span[0], expectedFirst); + return (data.Data.Length, data.Data.Span[0], expectedFirst); })); } @@ -436,7 +436,7 @@ public async Task TimeoutProtection_LongRunningTest_CompletesWithinReasonableTim { var start = i * 15; var range = Intervals.NET.Factories.Range.Closed(start, start + 25); - tasks.Add(cache.GetDataAsync(range, cts.Token).AsTask()); + tasks.Add(cache.GetDataAsync(range, cts.Token).AsTask().ContinueWith(t => t.Result.Data)); } // ASSERT - Completes within timeout diff --git a/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs b/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs index 12f568c..6991277 100644 --- a/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs @@ -81,9 +81,9 @@ public async Task CacheMiss_ColdStart_PropagatesExactUserRange() var result = await cache.GetDataAsync(userRange, CancellationToken.None); // ASSERT - Data is correct - Assert.Equal(11, result.Length); - Assert.Equal(100, result.Span[0]); - Assert.Equal(110, result.Span[^1]); + Assert.Equal(11, result.Data.Length); + Assert.Equal(100, result.Data.Span[0]); + Assert.Equal(110, result.Data.Span[^1]); // ASSERT - IDataSource received exact user range on cold start var requestedRanges = _dataSource.GetAllRequestedRanges(); @@ -104,7 +104,7 @@ public async Task CacheMiss_ColdStart_LargeRange_PropagatesExactly() var result = await cache.GetDataAsync(userRange, CancellationToken.None); // ASSERT - Assert.Equal(1000, result.Length); + Assert.Equal(1000, result.Data.Length); // ASSERT - IDataSource received exact large range var requestedRanges = _dataSource.GetAllRequestedRanges(); @@ -141,8 +141,8 @@ public async Task CacheHit_FullCoverage_NoAdditionalFetch() var result = await cache.GetDataAsync(subsetRange, CancellationToken.None); // ASSERT - Data is correct - Assert.Equal(6, result.Length); - Assert.Equal(110, result.Span[0]); + Assert.Equal(6, result.Data.Length); + Assert.Equal(110, result.Data.Span[0]); // ASSERT - No additional fetch should occur (cache hit) var newFetches = _dataSource.GetAllRequestedRanges(); @@ -176,9 +176,9 @@ public async Task PartialCacheHit_RightExtension_FetchesOnlyMissingSegment() var result = await cache.GetDataAsync(rightExtension, CancellationToken.None); // ASSERT - Data is correct - Assert.Equal(11, result.Length); - Assert.Equal(220, result.Span[0]); - Assert.Equal(230, result.Span[^1]); + Assert.Equal(11, result.Data.Length); + Assert.Equal(220, result.Data.Span[0]); + Assert.Equal(230, result.Data.Span[^1]); // ASSERT - IDataSource should fetch only missing right segment (221, 230] _dataSource.AssertRangeRequested(Intervals.NET.Factories.Range.OpenClosed(221, 230)); @@ -211,9 +211,9 @@ public async Task PartialCacheHit_LeftExtension_FetchesOnlyMissingSegment() var result = await cache.GetDataAsync(leftExtension, CancellationToken.None); // ASSERT - Data is correct - Assert.Equal(11, result.Length); - Assert.Equal(280, result.Span[0]); - Assert.Equal(290, result.Span[^1]); + Assert.Equal(11, result.Data.Length); + Assert.Equal(280, result.Data.Span[0]); + Assert.Equal(290, result.Data.Span[^1]); // ASSERT - IDataSource should fetch only missing left segment [280, 289) _dataSource.AssertRangeRequested(Intervals.NET.Factories.Range.ClosedOpen(280, 289)); @@ -356,9 +356,9 @@ public async Task PartialOverlap_BothSides_FetchesBothMissingSegments() var result = await cache.GetDataAsync(extendedRange, CancellationToken.None); // ASSERT - Data is correct - Assert.Equal(51, result.Length); - Assert.Equal(80, result.Span[0]); - Assert.Equal(130, result.Span[^1]); + Assert.Equal(51, result.Data.Length); + Assert.Equal(80, result.Data.Span[0]); + Assert.Equal(130, result.Data.Span[^1]); // ASSERT - Should fetch both missing segments // Left segment [80, 89) and right segment (121, 130] @@ -390,9 +390,9 @@ public async Task NonOverlappingJump_FetchesEntireNewRange() var result = await cache.GetDataAsync(jumpRange, CancellationToken.None); // ASSERT - Data is correct - Assert.Equal(11, result.Length); - Assert.Equal(500, result.Span[0]); - Assert.Equal(510, result.Span[^1]); + Assert.Equal(11, result.Data.Length); + Assert.Equal(500, result.Data.Span[0]); + Assert.Equal(510, result.Data.Span[^1]); // ASSERT - Should fetch entire new range _dataSource.AssertRangeRequested(Intervals.NET.Factories.Range.Closed(500, 510)); @@ -426,9 +426,9 @@ public async Task AdjacentRanges_RightAdjacent_FetchesExactNewSegment() var result = await cache.GetDataAsync(adjacentRange, CancellationToken.None); // ASSERT - Data is correct - Assert.Equal(10, result.Length); - Assert.Equal(111, result.Span[0]); - Assert.Equal(120, result.Span[^1]); + Assert.Equal(10, result.Data.Length); + Assert.Equal(111, result.Data.Span[0]); + Assert.Equal(120, result.Data.Span[^1]); // ASSERT - Should fetch only the new adjacent segment var requestedRanges = _dataSource.GetAllRequestedRanges(); @@ -463,9 +463,9 @@ public async Task AdjacentRanges_LeftAdjacent_FetchesExactNewSegment() var result = await cache.GetDataAsync(adjacentRange, CancellationToken.None); // ASSERT - Data is correct - Assert.Equal(10, result.Length); - Assert.Equal(90, result.Span[0]); - Assert.Equal(99, result.Span[^1]); + Assert.Equal(10, result.Data.Length); + Assert.Equal(90, result.Data.Span[0]); + Assert.Equal(99, result.Data.Span[^1]); // ASSERT - Should fetch only the new adjacent segment var requestedRanges = _dataSource.GetAllRequestedRanges(); diff --git a/tests/SlidingWindowCache.Integration.Tests/ExecutionStrategySelectionTests.cs b/tests/SlidingWindowCache.Integration.Tests/ExecutionStrategySelectionTests.cs index fae6316..692398a 100644 --- a/tests/SlidingWindowCache.Integration.Tests/ExecutionStrategySelectionTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/ExecutionStrategySelectionTests.cs @@ -1,8 +1,8 @@ using Intervals.NET; using Intervals.NET.Domain.Default.Numeric; -using Intervals.NET.Domain.Extensions.Fixed; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Dto; namespace SlidingWindowCache.Integration.Tests; @@ -16,11 +16,11 @@ public class ExecutionStrategySelectionTests private class TestDataSource : IDataSource { - public Task> FetchAsync( + public Task> FetchAsync( Range range, CancellationToken cancellationToken) { - return Task.FromResult(GenerateDataForRange(range)); + return Task.FromResult(new RangeChunk(range, GenerateDataForRange(range))); } /// @@ -91,9 +91,9 @@ public async Task WindowCache_WithNullCapacity_UsesTaskBasedStrategy() var result = await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(10, 20), CancellationToken.None); // ASSERT - Assert.Equal(11, result.Length); - Assert.Equal("Item_10", result.Span[0]); - Assert.Equal("Item_20", result.Span[10]); + Assert.Equal(11, result.Data.Length); + Assert.Equal("Item_10", result.Data.Span[0]); + Assert.Equal("Item_20", result.Data.Span[10]); } [Fact] @@ -106,7 +106,7 @@ public async Task WindowCache_WithDefaultParameters_UsesTaskBasedStrategy() leftCacheSize: 1.0, rightCacheSize: 2.0, readMode: UserCacheReadMode.Snapshot - // rebalanceQueueCapacity not specified - defaults to null + // rebalanceQueueCapacity not specified - defaults to null ); await using var cache = new WindowCache( @@ -119,9 +119,9 @@ public async Task WindowCache_WithDefaultParameters_UsesTaskBasedStrategy() var result = await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(0, 10), CancellationToken.None); // ASSERT - Assert.Equal(11, result.Length); - Assert.Equal("Item_0", result.Span[0]); - Assert.Equal("Item_10", result.Span[10]); + Assert.Equal(11, result.Data.Length); + Assert.Equal("Item_0", result.Data.Span[0]); + Assert.Equal("Item_10", result.Data.Span[10]); } [Fact] @@ -147,7 +147,7 @@ public async Task TaskBasedStrategy_UnderLoad_MaintainsSerialExecution() ); // ACT - Rapid sequential requests (should trigger multiple rebalances) - var tasks = new List>>(); + var tasks = new List>>(); for (int i = 0; i < 10; i++) { int start = i * 10; @@ -161,7 +161,7 @@ public async Task TaskBasedStrategy_UnderLoad_MaintainsSerialExecution() Assert.Equal(10, results.Length); foreach (var result in results) { - Assert.Equal(11, result.Length); + Assert.Equal(11, result.Data.Length); } // Wait for idle to ensure all background work completes @@ -195,9 +195,9 @@ public async Task WindowCache_WithBoundedCapacity_UsesChannelBasedStrategy() var result = await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(100, 110), CancellationToken.None); // ASSERT - Assert.Equal(11, result.Length); - Assert.Equal("Item_100", result.Span[0]); - Assert.Equal("Item_110", result.Span[10]); + Assert.Equal(11, result.Data.Length); + Assert.Equal("Item_100", result.Data.Span[0]); + Assert.Equal("Item_110", result.Data.Span[10]); } [Fact] @@ -223,7 +223,7 @@ public async Task ChannelBasedStrategy_UnderLoad_MaintainsSerialExecution() ); // ACT - Rapid sequential requests (may experience backpressure) - var tasks = new List>>(); + var tasks = new List>>(); for (int i = 0; i < 10; i++) { int start = i * 10; @@ -237,7 +237,7 @@ public async Task ChannelBasedStrategy_UnderLoad_MaintainsSerialExecution() Assert.Equal(10, results.Length); foreach (var result in results) { - Assert.Equal(11, result.Length); + Assert.Equal(11, result.Data.Length); } // Wait for idle to ensure all background work completes @@ -272,9 +272,9 @@ public async Task ChannelBasedStrategy_WithCapacityOne_WorksCorrectly() var result3 = await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(40, 50), CancellationToken.None); // ASSERT - Assert.Equal(11, result1.Length); - Assert.Equal(11, result2.Length); - Assert.Equal(11, result3.Length); + Assert.Equal(11, result1.Data.Length); + Assert.Equal(11, result2.Data.Length); + Assert.Equal(11, result3.Data.Length); // Wait for idle await cache.WaitForIdleAsync(CancellationToken.None); diff --git a/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs b/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs index 1decbb9..82b473b 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs @@ -85,7 +85,7 @@ public async Task RandomRanges_200Iterations_NoExceptions() { var range = GenerateRandomRange(); var result = await cache.GetDataAsync(range, CancellationToken.None); - Assert.Equal((int)range.Span(_domain), result.Length); + Assert.Equal((int)range.Span(_domain), result.Data.Length); } // ASSERT - Verify IDataSource was called and no malformed ranges requested @@ -113,7 +113,7 @@ public async Task RandomRanges_DataContentAlwaysValid() var result = await cache.GetDataAsync(range, CancellationToken.None); var start = (int)range.Start; - var array = result.ToArray(); // Convert to array to avoid ref struct in async + var array = result.Data.ToArray(); // Convert to array to avoid ref struct in async for (var j = 0; j < array.Length; j++) { @@ -139,7 +139,7 @@ public async Task RandomOverlappingRanges_NoExceptions() var range = Intervals.NET.Factories.Range.Closed(overlapStart, overlapEnd); var result = await cache.GetDataAsync(range, CancellationToken.None); - Assert.Equal((int)range.Span(_domain), result.Length); + Assert.Equal((int)range.Span(_domain), result.Data.Length); } } @@ -163,7 +163,7 @@ public async Task RandomAccessSequence_ForwardBackward_StableOperation() ); var result = await cache.GetDataAsync(range, CancellationToken.None); - var array = result.ToArray(); + var array = result.Data.ToArray(); Assert.Equal(rangeLength, array.Length); Assert.Equal(currentPosition, array[0]); } @@ -204,7 +204,7 @@ public async Task StressCombination_MixedPatterns_500Iterations() } var result = await cache.GetDataAsync(range, CancellationToken.None); - Assert.Equal((int)range.Span(_domain), result.Length); + Assert.Equal((int)range.Span(_domain), result.Data.Length); } // ASSERT - Comprehensive validation of IDataSource interactions diff --git a/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs b/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs index 527d5c8..c6b9dc5 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs @@ -80,8 +80,8 @@ public async Task FiniteRange_ClosedBoundaries_ReturnsCorrectLength() // ASSERT - Validate memory length matches range span var expectedLength = (int)range.Span(_domain); - Assert.Equal(expectedLength, result.Length); - Assert.Equal(11, result.Length); // [100, 110] inclusive = 11 elements + Assert.Equal(expectedLength, result.Data.Length); + Assert.Equal(11, result.Data.Length); // [100, 110] inclusive = 11 elements // ASSERT - Validate IDataSource was called with correct range Assert.True(_dataSource.TotalFetchCount > 0, "DataSource should be called for cold start"); @@ -99,7 +99,7 @@ public async Task FiniteRange_BoundaryAlignment_ReturnsCorrectValues() var result = await cache.GetDataAsync(range, CancellationToken.None); // ASSERT - Validate boundary values are correct - var array = result.ToArray(); + var array = result.Data.ToArray(); Assert.Equal(50, array[0]); // First element matches start Assert.Equal(55, array[^1]); // Last element matches end Assert.True(array.SequenceEqual([50, 51, 52, 53, 54, 55])); @@ -122,7 +122,7 @@ public async Task FiniteRange_MultipleRequests_ConsistentLengths() { var loopResult = await cache.GetDataAsync(range, CancellationToken.None); var expectedLength = (int)range.Span(_domain); - Assert.Equal(expectedLength, loopResult.Length); + Assert.Equal(expectedLength, loopResult.Data.Length); } } @@ -137,7 +137,7 @@ public async Task FiniteRange_SingleElementRange_ReturnsOneElement() var result = await cache.GetDataAsync(range, CancellationToken.None); // ASSERT - var array = result.ToArray(); + var array = result.Data.ToArray(); Assert.Single(array); Assert.Equal(42, array[0]); } @@ -153,7 +153,7 @@ public async Task FiniteRange_DataContentMatchesRange_SequentialValues() var result = await cache.GetDataAsync(range, CancellationToken.None); // ASSERT - Verify sequential data from start to end - var array = result.ToArray(); + var array = result.Data.ToArray(); for (var i = 0; i < array.Length; i++) { Assert.Equal(1000 + i, array[i]); @@ -179,7 +179,7 @@ public async Task InfiniteBoundary_LeftInfinite_CacheHandlesGracefully() // ASSERT - No exceptions, correct length var expectedLength = (int)range.Span(_domain); - Assert.Equal(expectedLength, result.Length); + Assert.Equal(expectedLength, result.Data.Length); } [Fact] @@ -196,7 +196,7 @@ public async Task InfiniteBoundary_RightInfinite_CacheHandlesGracefully() // ASSERT - No exceptions, correct length var expectedLength = (int)range.Span(_domain); - Assert.Equal(expectedLength, result.Length); + Assert.Equal(expectedLength, result.Data.Length); } #endregion @@ -228,8 +228,8 @@ public async Task SpanConsistency_AfterCacheExpansion_LengthStillCorrect() var data2 = await cache.GetDataAsync(range2, CancellationToken.None); // ASSERT - Both requests return correct lengths despite cache expansion - Assert.Equal((int)range1.Span(_domain), data1.Length); - Assert.Equal((int)range2.Span(_domain), data2.Length); + Assert.Equal((int)range1.Span(_domain), data1.Data.Length); + Assert.Equal((int)range2.Span(_domain), data2.Data.Length); } [Fact] @@ -248,7 +248,7 @@ public async Task SpanConsistency_OverlappingRanges_EachReturnsCorrectLength() foreach (var range in ranges) { var loopResult = await cache.GetDataAsync(range, CancellationToken.None); - Assert.Equal((int)range.Span(_domain), loopResult.Length); + Assert.Equal((int)range.Span(_domain), loopResult.Data.Length); } } @@ -293,7 +293,7 @@ public async Task BoundaryEdgeCase_ZeroCrossingRange_HandlesCorrectly() var result = await cache.GetDataAsync(range, CancellationToken.None); // ASSERT - var array = result.ToArray(); + var array = result.Data.ToArray(); Assert.Equal(21, array.Length); // -10 to 10 inclusive Assert.Equal(-10, array[0]); Assert.Equal(0, array[10]); @@ -311,7 +311,7 @@ public async Task BoundaryEdgeCase_NegativeRange_ReturnsCorrectData() var result = await cache.GetDataAsync(range, CancellationToken.None); // ASSERT - var array = result.ToArray(); + var array = result.Data.ToArray(); Assert.Equal(11, array.Length); Assert.Equal(-100, array[0]); Assert.Equal(-90, array[^1]); diff --git a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs index ae8df8f..69b8991 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs @@ -1,6 +1,7 @@ using Intervals.NET; using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Infrastructure.Instrumentation; +using SlidingWindowCache.Integration.Tests.TestInfrastructure; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; @@ -40,7 +41,7 @@ public async Task RebalanceExecutionFailed_IsRecorded_WhenDataSourceThrowsDuring if (callCount == 1) { // First call (user request) succeeds - return GenerateTestData(range); + return FaultyDataSource.GenerateStringData(range); } // Second call (rebalance) fails @@ -99,7 +100,7 @@ public async Task UserRequests_ContinueToWork_AfterRebalanceFailure() } // Other calls succeed - return GenerateTestData(range); + return FaultyDataSource.GenerateStringData(range); } ); @@ -131,8 +132,8 @@ public async Task UserRequests_ContinueToWork_AfterRebalanceFailure() // Assert: Both requests succeeded despite rebalance failure Assert.Equal(2, _diagnostics.UserRequestServed); - Assert.Equal(11, data1.Length); - Assert.Equal(11, data2.Length); + Assert.Equal(11, data1.Data.Length); + Assert.Equal(11, data2.Data.Length); // Verify at least one rebalance failed Assert.True(_diagnostics.RebalanceExecutionFailed >= 1, @@ -159,7 +160,7 @@ public async Task ProductionDiagnostics_ProperlyLogsRebalanceFailures() if (callCount == 1) { // First call (user request) succeeds - return GenerateTestData(range); + return FaultyDataSource.GenerateStringData(range); } // Second call (rebalance) fails @@ -199,39 +200,6 @@ public async Task ProductionDiagnostics_ProperlyLogsRebalanceFailures() #region Helper Classes - /// - /// Faulty data source for testing exception handling. - /// - private class FaultyDataSource : IDataSource - where TRange : IComparable - { - private readonly Func, IEnumerable> _fetchSingleRange; - - public FaultyDataSource(Func, IEnumerable> fetchSingleRange) - { - _fetchSingleRange = fetchSingleRange; - } - - public Task> FetchAsync(Range range, CancellationToken cancellationToken) - { - var data = _fetchSingleRange(range); - return Task.FromResult(data); - } - - public Task> FetchAsync(IEnumerable> ranges, - CancellationToken cancellationToken) - { - var allData = new List(); - foreach (var range in ranges) - { - var data = _fetchSingleRange(range); - allData.AddRange(data); - } - - return Task.FromResult>(allData); - } - } - /// /// Production-ready diagnostics implementation that logs failures. /// This demonstrates the minimum requirement for production use. @@ -319,17 +287,10 @@ public void RebalanceSkippedPendingNoRebalanceRange() public void RebalanceSkippedSameRange() { } - } - private static IEnumerable GenerateTestData(Intervals.NET.Range range) - { - var data = new List(); - for (var i = range.Start.Value; i <= range.End.Value; i++) + public void DataSegmentUnavailable() { - data.Add($"Item-{i}"); } - - return data; } #endregion diff --git a/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/BoundedDataSource.cs b/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/BoundedDataSource.cs new file mode 100644 index 0000000..b3940b1 --- /dev/null +++ b/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/BoundedDataSource.cs @@ -0,0 +1,119 @@ +using Intervals.NET; +using Intervals.NET.Extensions; +using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Dto; + +namespace SlidingWindowCache.Integration.Tests.TestInfrastructure; + +/// +/// A test IDataSource implementation that simulates a bounded data source with physical limits. +/// Only returns data for ranges within [MinId, MaxId] boundaries. +/// Used for testing boundary handling, partial fulfillment, and out-of-bounds scenarios. +/// +public sealed class BoundedDataSource : IDataSource +{ + private const int MinId = 1000; + private const int MaxId = 9999; + + /// + /// Gets the minimum available ID (inclusive). + /// + public int MinimumId => MinId; + + /// + /// Gets the maximum available ID (inclusive). + /// + public int MaximumId => MaxId; + + /// + /// Fetches data for a single range, respecting physical boundaries. + /// Returns only data within [MinId, MaxId]. + /// + public Task> FetchAsync(Range requested, CancellationToken cancellationToken) + { + // Define the physical boundary + var availableRange = Intervals.NET.Factories.Range.Closed(MinId, MaxId); + + // Compute intersection with requested range + var fulfillable = requested.Intersect(availableRange); + + // No data available - completely out of bounds + if (fulfillable == null) + { + return Task.FromResult(new RangeChunk( + null, // Range must be null when no data is available (per IDataSource contract) + Array.Empty() + )); + } + + // Fetch available portion (non-null fulfillable) + var data = GenerateDataForRange(fulfillable.Value); + return Task.FromResult(new RangeChunk(fulfillable.Value, data)); + } + + /// + /// Fetches data for multiple ranges in batch. + /// Each range respects physical boundaries independently. + /// + public async Task>> FetchAsync( + IEnumerable> ranges, + CancellationToken cancellationToken) + { + var chunks = new List>(); + + foreach (var range in ranges) + { + var chunk = await FetchAsync(range, cancellationToken); + chunks.Add(chunk); + } + + return chunks; + } + + /// + /// Generates sequential integer data for a range, respecting boundary inclusivity. + /// + private static List GenerateDataForRange(Range range) + { + var data = new List(); + var start = (int)range.Start; + var end = (int)range.End; + + switch (range) + { + case { IsStartInclusive: true, IsEndInclusive: true }: + // [start, end] + for (var i = start; i <= end; i++) + { + data.Add(i); + } + break; + + case { IsStartInclusive: true, IsEndInclusive: false }: + // [start, end) + for (var i = start; i < end; i++) + { + data.Add(i); + } + break; + + case { IsStartInclusive: false, IsEndInclusive: true }: + // (start, end] + for (var i = start + 1; i <= end; i++) + { + data.Add(i); + } + break; + + default: + // (start, end) + for (var i = start + 1; i < end; i++) + { + data.Add(i); + } + break; + } + + return data; + } +} diff --git a/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/FaultyDataSource.cs b/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/FaultyDataSource.cs new file mode 100644 index 0000000..4107aa5 --- /dev/null +++ b/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/FaultyDataSource.cs @@ -0,0 +1,69 @@ +using Intervals.NET; +using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Dto; + +namespace SlidingWindowCache.Integration.Tests.TestInfrastructure; + +/// +/// A configurable IDataSource that delegates fetch calls through a user-supplied callback, +/// allowing individual tests to inject faults (throw), boundary misses (return null Range), +/// or normal data on a per-call basis. +/// +/// The range boundary type. +/// The data type. +public sealed class FaultyDataSource : IDataSource + where TRange : IComparable +{ + private readonly Func, IEnumerable> _fetchSingleRange; + + /// + /// Initializes a new instance. + /// + /// + /// Callback invoked for every single-range fetch. May throw to simulate failures, + /// or return any to control the returned data. + /// The in the result is always set to + /// the requested range; return an empty enumerable when the range is out of bounds. + /// + public FaultyDataSource(Func, IEnumerable> fetchSingleRange) + { + _fetchSingleRange = fetchSingleRange; + } + + /// + public Task> FetchAsync(Range range, CancellationToken cancellationToken) + { + var data = _fetchSingleRange(range); + return Task.FromResult(new RangeChunk(range, data)); + } + + /// + public Task>> FetchAsync( + IEnumerable> ranges, + CancellationToken cancellationToken) + { + var chunks = new List>(); + foreach (var range in ranges) + { + var data = _fetchSingleRange(range); + chunks.Add(new RangeChunk(range, data)); + } + + return Task.FromResult>>(chunks); + } + + /// + /// Generates sequential string items ("Item-N") for a closed integer range. + /// Convenience helper for tests using IDataSource<int, string>. + /// + public static IEnumerable GenerateStringData(Range range) + { + var data = new List(); + for (var i = range.Start.Value; i <= range.End.Value; i++) + { + data.Add($"Item-{i}"); + } + + return data; + } +} diff --git a/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/SpyDataSource.cs b/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/SpyDataSource.cs index 7334e06..bbb5787 100644 --- a/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/SpyDataSource.cs +++ b/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/SpyDataSource.cs @@ -86,13 +86,13 @@ public void AssertRangeRequested(Range range) /// /// Fetches data for a single range and records the call. /// - public Task> FetchAsync(Range range, CancellationToken cancellationToken) + public Task> FetchAsync(Range range, CancellationToken cancellationToken) { _singleFetchCalls.Add(range); Interlocked.Increment(ref _totalFetchCount); var data = GenerateDataForRange(range); - return Task.FromResult>(data); + return Task.FromResult(new RangeChunk(range, data)); } /// diff --git a/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs new file mode 100644 index 0000000..f5fa10c --- /dev/null +++ b/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs @@ -0,0 +1,147 @@ +using Intervals.NET.Domain.Default.Numeric; +using SlidingWindowCache.Infrastructure.Instrumentation; +using SlidingWindowCache.Integration.Tests.TestInfrastructure; +using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Configuration; + +namespace SlidingWindowCache.Integration.Tests; + +/// +/// Tests for validating proper exception handling in User Path operations. +/// Verifies that exceptions from IDataSource during user requests: +/// - Propagate to the caller +/// - Do NOT increment UserRequestServed +/// - Do NOT publish a rebalance intent +/// - Leave the cache operational for subsequent requests +/// +public sealed class UserPathExceptionHandlingTests : IAsyncDisposable +{ + private readonly IntegerFixedStepDomain _domain; + private readonly EventCounterCacheDiagnostics _diagnostics; + private WindowCache? _cache; + + public UserPathExceptionHandlingTests() + { + _domain = new IntegerFixedStepDomain(); + _diagnostics = new EventCounterCacheDiagnostics(); + } + + public async ValueTask DisposeAsync() + { + if (_cache != null) + { + await _cache.WaitForIdleAsync(); + await _cache.DisposeAsync(); + } + } + + /// + /// When IDataSource.FetchAsync throws on the first (user-path) call: + /// - The exception propagates to the caller of GetDataAsync + /// - UserRequestServed is NOT incremented (incomplete request is not "served") + /// - RebalanceIntentPublished is NOT incremented (no intent from a failed request) + /// + [Fact] + public async Task UserFetchException_PropagatesException_AndDoesNotCountAsServed_AndDoesNotPublishIntent() + { + // ARRANGE + var dataSource = new FaultyDataSource( + fetchSingleRange: _ => throw new InvalidOperationException("Simulated user-path fetch failure") + ); + + var options = new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot, + leftThreshold: 0.0, + rightThreshold: 0.0, + debounceDelay: TimeSpan.FromMilliseconds(10) + ); + + _cache = new WindowCache( + dataSource, + _domain, + options, + _diagnostics + ); + + // ACT + var exception = await Record.ExceptionAsync(async () => + await _cache.GetDataAsync( + Intervals.NET.Factories.Range.Closed(100, 110), + CancellationToken.None)); + + // ASSERT - exception propagated + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("Simulated user-path fetch failure", exception.Message); + + // ASSERT - not counted as served + Assert.Equal(0, _diagnostics.UserRequestServed); + + // ASSERT - no intent published + Assert.Equal(0, _diagnostics.RebalanceIntentPublished); + } + + /// + /// After a user-path exception, the cache remains fully operational. + /// A subsequent successful request is served normally and counts as served. + /// + [Fact] + public async Task UserFetchException_CacheRemainsOperational_SubsequentRequestSucceeds() + { + // ARRANGE - first call throws, second call succeeds + var callCount = 0; + var dataSource = new FaultyDataSource( + fetchSingleRange: range => + { + callCount++; + if (callCount == 1) + { + throw new InvalidOperationException("Simulated user-path fetch failure on first call"); + } + + // Second and subsequent calls succeed + return FaultyDataSource.GenerateStringData(range); + } + ); + + var options = new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot, + leftThreshold: 0.0, + rightThreshold: 0.0, + debounceDelay: TimeSpan.FromMilliseconds(10) + ); + + _cache = new WindowCache( + dataSource, + _domain, + options, + _diagnostics + ); + + // ACT - first call: expect exception + var firstException = await Record.ExceptionAsync(async () => + await _cache.GetDataAsync( + Intervals.NET.Factories.Range.Closed(100, 110), + CancellationToken.None)); + + // ACT - second call: expect success + var secondResult = await _cache.GetDataAsync( + Intervals.NET.Factories.Range.Closed(100, 110), + CancellationToken.None); + + // ASSERT - first call threw + Assert.NotNull(firstException); + Assert.IsType(firstException); + + // ASSERT - second call succeeded + Assert.NotNull(secondResult.Range); + Assert.Equal(11, secondResult.Data.Length); + + // ASSERT - only the successful second request was counted as served + Assert.Equal(1, _diagnostics.UserRequestServed); + } +} diff --git a/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs b/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs index a4c5fa0..6870135 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs @@ -190,7 +190,7 @@ public static Mock> CreateMockDataSource(IntegerFixedStepD break; } - return data; + return new RangeChunk(range, data); }); mock.Setup(ds => ds.FetchAsync(It.IsAny>>(), It.IsAny())) @@ -200,8 +200,8 @@ public static Mock> CreateMockDataSource(IntegerFixedStepD foreach (var range in ranges) { - var data = await mock.Object.FetchAsync(range, ct); - chunks.Add(new RangeChunk(range, data)); + var chunk = await mock.Object.FetchAsync(range, ct); + chunks.Add(chunk); } return chunks; @@ -267,7 +267,7 @@ public static (Mock> mock, List> fetchedRanges) break; } - return data; + return new RangeChunk(range, data); }); mock.Setup(ds => ds.FetchAsync(It.IsAny>>(), It.IsAny())) @@ -277,8 +277,8 @@ public static (Mock> mock, List> fetchedRanges) foreach (var range in ranges) { - var data = await mock.Object.FetchAsync(range, ct); - chunks.Add(new RangeChunk(range, data)); + var chunk = await mock.Object.FetchAsync(range, ct); + chunks.Add(chunk); } return chunks; @@ -322,7 +322,7 @@ public static async Task> ExecuteRequestAndWaitForRebalance( { var result = await cache.GetDataAsync(range, CancellationToken.None); await cache.WaitForIdleAsync(); - return result; + return result.Data; } /// diff --git a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs index 3e396de..91746cc 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs @@ -102,8 +102,8 @@ public static IEnumerable A3_8_TestData scenario[3], // priorStart scenario[4], // priorEnd scenario[5], // hasPriorRequest - storage[0], // storageName - storage[1] // readMode + storage[0], // storageName + storage[1] // readMode ]; } } @@ -189,7 +189,7 @@ public async Task Invariant_A_Minus1_ConcurrentWriteSafety() var rangeSize = random.Next(10, 30); var range = TestHelpers.CreateRange(baseStart, baseStart + rangeSize); - tasks.Add(Task.Run(async () => await cache.GetDataAsync(range, CancellationToken.None))); + tasks.Add(Task.Run(async () => (await cache.GetDataAsync(range, CancellationToken.None)).Data)); } // Wait for all requests to complete @@ -236,9 +236,9 @@ public async Task Invariant_A2_1_UserPathAlwaysServesRequests() var result3 = await cache.GetDataAsync(TestHelpers.CreateRange(105, 115), CancellationToken.None); // ASSERT: All requests completed with correct data - TestHelpers.AssertUserDataCorrect(result1, TestHelpers.CreateRange(100, 110)); - TestHelpers.AssertUserDataCorrect(result2, TestHelpers.CreateRange(200, 210)); - TestHelpers.AssertUserDataCorrect(result3, TestHelpers.CreateRange(105, 115)); + TestHelpers.AssertUserDataCorrect(result1.Data, TestHelpers.CreateRange(100, 110)); + TestHelpers.AssertUserDataCorrect(result2.Data, TestHelpers.CreateRange(200, 210)); + TestHelpers.AssertUserDataCorrect(result3.Data, TestHelpers.CreateRange(105, 115)); Assert.Equal(3, _cacheDiagnostics.UserRequestServed); } @@ -262,7 +262,7 @@ public async Task Invariant_A2_2_UserPathNeverWaitsForRebalance() Assert.Equal(1, _cacheDiagnostics.UserRequestServed); Assert.Equal(1, _cacheDiagnostics.RebalanceIntentPublished); Assert.Equal(0, _cacheDiagnostics.RebalanceExecutionCompleted); - TestHelpers.AssertUserDataCorrect(result, TestHelpers.CreateRange(100, 110)); + TestHelpers.AssertUserDataCorrect(result.Data, TestHelpers.CreateRange(100, 110)); await cache.WaitForIdleAsync(); Assert.Equal(1, _cacheDiagnostics.RebalanceExecutionCompleted); } @@ -290,7 +290,7 @@ public async Task Invariant_A2_10_UserAlwaysReceivesExactRequestedRange() foreach (var range in testRanges) { var loopResult = await cache.GetDataAsync(range, CancellationToken.None); - TestHelpers.AssertUserDataCorrect(loopResult, range); + TestHelpers.AssertUserDataCorrect(loopResult.Data, range); } } @@ -334,7 +334,7 @@ public async Task Invariant_A3_8_UserPathNeverMutatesCache( var result = await cache.GetDataAsync(TestHelpers.CreateRange(reqStart, reqEnd), CancellationToken.None); // ASSERT: User receives correct data immediately - TestHelpers.AssertUserDataCorrect(result, TestHelpers.CreateRange(reqStart, reqEnd)); + TestHelpers.AssertUserDataCorrect(result.Data, TestHelpers.CreateRange(reqStart, reqEnd)); // User Path MUST NOT mutate cache (single-writer architecture) TestHelpers.AssertNoUserPathMutations(_cacheDiagnostics); @@ -364,9 +364,9 @@ public async Task Invariant_A3_9a_CacheContiguityMaintained() var result3 = await cache.GetDataAsync(TestHelpers.CreateRange(95, 120), CancellationToken.None); // ASSERT: All data is contiguous (no gaps) - TestHelpers.AssertUserDataCorrect(result1, TestHelpers.CreateRange(100, 110)); - TestHelpers.AssertUserDataCorrect(result2, TestHelpers.CreateRange(105, 115)); - TestHelpers.AssertUserDataCorrect(result3, TestHelpers.CreateRange(95, 120)); + TestHelpers.AssertUserDataCorrect(result1.Data, TestHelpers.CreateRange(100, 110)); + TestHelpers.AssertUserDataCorrect(result2.Data, TestHelpers.CreateRange(105, 115)); + TestHelpers.AssertUserDataCorrect(result3.Data, TestHelpers.CreateRange(95, 120)); } #endregion @@ -397,8 +397,8 @@ public async Task Invariant_B11_CacheDataAndRangeAlwaysConsistent() { var result = await cache.GetDataAsync(range, CancellationToken.None); var expectedLength = (int)range.End - (int)range.Start + 1; - Assert.Equal(expectedLength, result.Length); - TestHelpers.AssertUserDataCorrect(result, range); + Assert.Equal(expectedLength, result.Data.Length); + TestHelpers.AssertUserDataCorrect(result.Data, range); } } @@ -419,11 +419,11 @@ public async Task Invariant_B15_CancelledRebalanceDoesNotViolateConsistency() var result = await cache.GetDataAsync(TestHelpers.CreateRange(200, 210), CancellationToken.None); // ASSERT: Cache still returns correct data - TestHelpers.AssertUserDataCorrect(result, TestHelpers.CreateRange(200, 210)); + TestHelpers.AssertUserDataCorrect(result.Data, TestHelpers.CreateRange(200, 210)); // Verify cache is not corrupted by making another request var result2 = await cache.GetDataAsync(TestHelpers.CreateRange(205, 215), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(result2, TestHelpers.CreateRange(205, 215)); + TestHelpers.AssertUserDataCorrect(result2.Data, TestHelpers.CreateRange(205, 215)); } /// @@ -465,7 +465,7 @@ public async Task Invariant_B15_Enhanced_CancellationDuringIO() // ASSERT: Cache remains consistent despite cancellation during I/O var result3 = await cache.GetDataAsync(TestHelpers.CreateRange(205, 215), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(result3, TestHelpers.CreateRange(205, 215)); + TestHelpers.AssertUserDataCorrect(result3.Data, TestHelpers.CreateRange(205, 215)); // Verify lifecycle integrity - system remained stable TestHelpers.AssertRebalanceLifecycleIntegrity(_cacheDiagnostics); @@ -506,12 +506,12 @@ public async Task Invariant_B16_OnlyLatestResultsApplied() // ASSERT: Cache should reflect the latest intent (around 200-210 range with extensions) // Make a request in the second range area to verify cache is centered there var result = await cache.GetDataAsync(TestHelpers.CreateRange(205, 215), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(result, TestHelpers.CreateRange(205, 215)); + TestHelpers.AssertUserDataCorrect(result.Data, TestHelpers.CreateRange(205, 215)); // Should be full hit (cache was rebalanced to this region) _cacheDiagnostics.Reset(); var verifyResult = await cache.GetDataAsync(TestHelpers.CreateRange(208, 212), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(verifyResult, TestHelpers.CreateRange(208, 212)); + TestHelpers.AssertUserDataCorrect(verifyResult.Data, TestHelpers.CreateRange(208, 212)); TestHelpers.AssertFullCacheHit(_cacheDiagnostics, 1); // Verify system stability @@ -645,7 +645,7 @@ public async Task Invariant_C20_DecisionEngineExitsEarlyForObsoleteIntent() // Verify final cache state is correct (centered around last request) var result = await cache.GetDataAsync(TestHelpers.CreateRange(405, 415), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(result, TestHelpers.CreateRange(405, 415)); + TestHelpers.AssertUserDataCorrect(result.Data, TestHelpers.CreateRange(405, 415)); } /// @@ -698,7 +698,8 @@ public async Task Invariant_C23_SystemStabilizesUnderLoad() for (var i = 0; i < 10; i++) { var start = 100 + i * 2; - tasks.Add(cache.GetDataAsync(TestHelpers.CreateRange(start, start + 10), CancellationToken.None).AsTask()); + tasks.Add(cache.GetDataAsync(TestHelpers.CreateRange(start, start + 10), CancellationToken.None).AsTask() + .ContinueWith(t => t.Result.Data)); } await Task.WhenAll(tasks); @@ -706,7 +707,7 @@ public async Task Invariant_C23_SystemStabilizesUnderLoad() // ASSERT: System is stable and serves new requests correctly var finalResult = await cache.GetDataAsync(TestHelpers.CreateRange(105, 115), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(finalResult, TestHelpers.CreateRange(105, 115)); + TestHelpers.AssertUserDataCorrect(finalResult.Data, TestHelpers.CreateRange(105, 115)); } #endregion @@ -859,7 +860,7 @@ public async Task Invariant_D28_SkipWhenDesiredEqualsCurrentRange() await cache.WaitForIdleAsync(); // ASSERT: Verify same-range skip occurred (Stage 3 validation) - TestHelpers.AssertUserDataCorrect(result, initialRange); + TestHelpers.AssertUserDataCorrect(result.Data, initialRange); TestHelpers.AssertIntentPublished(_cacheDiagnostics, 1); TestHelpers.AssertRebalanceSkippedSameRange(_cacheDiagnostics, 1); @@ -902,7 +903,7 @@ public async Task Invariant_E30_DesiredRangeComputedFromConfigAndRequest() var withinDesired = await cache.GetDataAsync(TestHelpers.CreateRange(95, 115), CancellationToken.None); // ASSERT: Data is correct, demonstrating cache expanded based on configuration - TestHelpers.AssertUserDataCorrect(withinDesired, TestHelpers.CreateRange(95, 115)); + TestHelpers.AssertUserDataCorrect(withinDesired.Data, TestHelpers.CreateRange(95, 115)); // Verify this was a full cache hit, proving the desired range was calculated correctly TestHelpers.AssertFullCacheHit(_cacheDiagnostics); @@ -945,8 +946,8 @@ public async Task Invariant_E31_DesiredRangeIndependentOfCacheState() await cache2.WaitForIdleAsync(); // ASSERT: Both caches should have same behavior for [200, 210] despite different histories - TestHelpers.AssertUserDataCorrect(result1, TestHelpers.CreateRange(200, 210)); - TestHelpers.AssertUserDataCorrect(result2, TestHelpers.CreateRange(200, 210)); + TestHelpers.AssertUserDataCorrect(result1.Data, TestHelpers.CreateRange(200, 210)); + TestHelpers.AssertUserDataCorrect(result2.Data, TestHelpers.CreateRange(200, 210)); // Both should have scheduled rebalance for the same desired range (deterministic computation) // Verify both caches converged to serving the same expanded range @@ -956,8 +957,8 @@ public async Task Invariant_E31_DesiredRangeIndependentOfCacheState() var verify1 = await cache1.GetDataAsync(TestHelpers.CreateRange(195, 215), CancellationToken.None); var verify2 = await cache2.GetDataAsync(TestHelpers.CreateRange(195, 215), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(verify1, TestHelpers.CreateRange(195, 215)); - TestHelpers.AssertUserDataCorrect(verify2, TestHelpers.CreateRange(195, 215)); + TestHelpers.AssertUserDataCorrect(verify1.Data, TestHelpers.CreateRange(195, 215)); + TestHelpers.AssertUserDataCorrect(verify2.Data, TestHelpers.CreateRange(195, 215)); // Both should be full cache hits (both caches expanded to same desired range) TestHelpers.AssertFullCacheHit(diagnostics1, 1); @@ -1121,7 +1122,7 @@ public async Task Invariant_F36a_RebalanceNormalizesCache(string storageName, Us // Cache should be normalized - verify by requesting from expected expanded range var extendedData = await cache.GetDataAsync(TestHelpers.CreateRange(95, 115), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(extendedData, TestHelpers.CreateRange(95, 115)); + TestHelpers.AssertUserDataCorrect(extendedData.Data, TestHelpers.CreateRange(95, 115)); } /// @@ -1155,7 +1156,7 @@ public async Task Invariant_F40_F41_F42_PostExecutionGuarantees(string storageNa // After rebalance, cache should serve data from normalized range [100-11, 110+11] = [89, 121] var normalizedData = await cache.GetDataAsync(TestHelpers.CreateRange(90, 120), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(normalizedData, TestHelpers.CreateRange(90, 120)); + TestHelpers.AssertUserDataCorrect(normalizedData.Data, TestHelpers.CreateRange(90, 120)); } } @@ -1220,7 +1221,7 @@ public async Task Invariant_F38_IncrementalFetchOptimization() // Verify final state is correct var result = await cache.GetDataAsync(TestHelpers.CreateRange(105, 120), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(result, TestHelpers.CreateRange(105, 120)); + TestHelpers.AssertUserDataCorrect(result.Data, TestHelpers.CreateRange(105, 120)); } /// @@ -1274,11 +1275,11 @@ public async Task Invariant_F39_DataPreservationDuringExpansion() // Verify cache serves correct data after expansion var result = await cache.GetDataAsync(TestHelpers.CreateRange(90, 105), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(result, TestHelpers.CreateRange(90, 105)); + TestHelpers.AssertUserDataCorrect(result.Data, TestHelpers.CreateRange(90, 105)); // Verify original range is still correct (data preserved) var originalResult = await cache.GetDataAsync(TestHelpers.CreateRange(100, 110), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(originalResult, TestHelpers.CreateRange(100, 110)); + TestHelpers.AssertUserDataCorrect(originalResult.Data, TestHelpers.CreateRange(100, 110)); } #endregion @@ -1309,7 +1310,7 @@ public async Task Invariant_G43_G44_G45_ExecutionContextSeparation() Assert.Equal(1, _cacheDiagnostics.UserRequestServed); Assert.Equal(1, _cacheDiagnostics.RebalanceIntentPublished); Assert.Equal(0, _cacheDiagnostics.RebalanceExecutionCompleted); - TestHelpers.AssertUserDataCorrect(result, TestHelpers.CreateRange(100, 110)); + TestHelpers.AssertUserDataCorrect(result.Data, TestHelpers.CreateRange(100, 110)); await cache.WaitForIdleAsync(); Assert.Equal(1, _cacheDiagnostics.RebalanceExecutionCompleted); } @@ -1365,25 +1366,25 @@ public async Task CompleteScenario_MultipleRequestsWithRebalancing() // Act & Assert: Sequential user requests // Request 1: Cold start var result1 = await cache.GetDataAsync(TestHelpers.CreateRange(100, 110), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(result1, TestHelpers.CreateRange(100, 110)); + TestHelpers.AssertUserDataCorrect(result1.Data, TestHelpers.CreateRange(100, 110)); // Request 2: Overlapping expansion var result2 = await cache.GetDataAsync(TestHelpers.CreateRange(105, 120), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(result2, TestHelpers.CreateRange(105, 120)); + TestHelpers.AssertUserDataCorrect(result2.Data, TestHelpers.CreateRange(105, 120)); await cache.WaitForIdleAsync(); // Request 3: Within cached/rebalanced range var result3 = await cache.GetDataAsync(TestHelpers.CreateRange(110, 115), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(result3, TestHelpers.CreateRange(110, 115)); + TestHelpers.AssertUserDataCorrect(result3.Data, TestHelpers.CreateRange(110, 115)); // Request 4: Non-intersecting jump var data4 = await cache.GetDataAsync(TestHelpers.CreateRange(200, 210), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(data4, TestHelpers.CreateRange(200, 210)); + TestHelpers.AssertUserDataCorrect(data4.Data, TestHelpers.CreateRange(200, 210)); await cache.WaitForIdleAsync(); // Request 5: Verify cache stability var data5 = await cache.GetDataAsync(TestHelpers.CreateRange(205, 215), CancellationToken.None); - TestHelpers.AssertUserDataCorrect(data5, TestHelpers.CreateRange(205, 215)); + TestHelpers.AssertUserDataCorrect(data5.Data, TestHelpers.CreateRange(205, 215)); // Wait for background rebalance to settle before checking counters await cache.WaitForIdleAsync(); @@ -1408,7 +1409,8 @@ public async Task CompleteScenario_MultipleRequestsWithRebalancing() /// Queue capacity: null = task-based (unbounded), >= 1 = channel-based (bounded) [Theory] [MemberData(nameof(ExecutionStrategyTestData))] - public async Task ConcurrencyScenario_RapidRequestsBurstWithCancellation(string executionStrategy, int? queueCapacity) + public async Task ConcurrencyScenario_RapidRequestsBurstWithCancellation(string executionStrategy, + int? queueCapacity) { // ARRANGE var options = TestHelpers.CreateDefaultOptions( @@ -1421,7 +1423,8 @@ public async Task ConcurrencyScenario_RapidRequestsBurstWithCancellation(string for (var i = 0; i < 20; i++) { var start = 100 + i * 5; - tasks.Add(cache.GetDataAsync(TestHelpers.CreateRange(start, start + 10), CancellationToken.None).AsTask()); + tasks.Add(cache.GetDataAsync(TestHelpers.CreateRange(start, start + 10), CancellationToken.None).AsTask() + .ContinueWith(t => t.Result.Data)); } var results = await Task.WhenAll(tasks); @@ -1468,8 +1471,8 @@ public async Task ReadMode_VerifyBehavior(UserCacheReadMode readMode) var result2 = await cache.GetDataAsync(TestHelpers.CreateRange(105, 115), CancellationToken.None); // Assert - TestHelpers.VerifyDataMatchesRange(result1, TestHelpers.CreateRange(100, 110)); - TestHelpers.VerifyDataMatchesRange(result2, TestHelpers.CreateRange(105, 115)); + TestHelpers.VerifyDataMatchesRange(result1.Data, TestHelpers.CreateRange(100, 110)); + TestHelpers.VerifyDataMatchesRange(result2.Data, TestHelpers.CreateRange(105, 115)); } #endregion diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs index 83b563f..89a6839 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs @@ -2,6 +2,7 @@ using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Dto; namespace SlidingWindowCache.Unit.Tests.Public; @@ -19,14 +20,14 @@ public class WindowCacheDisposalTests /// private sealed class TestDataSource : IDataSource { - public async Task> FetchAsync( + public async Task> FetchAsync( Range requestedRange, CancellationToken cancellationToken) { // Simulate async I/O await Task.Delay(1, cancellationToken); - return GenerateDataForRange(requestedRange); + return new RangeChunk(requestedRange, GenerateDataForRange(requestedRange)); } /// @@ -112,7 +113,7 @@ public async Task DisposeAsync_AfterNormalUsage_DisposesSuccessfully() // ACT - Use the cache var data = await cache.GetDataAsync(range, CancellationToken.None); - Assert.Equal(11, data.Length); // Verify usage worked + Assert.Equal(11, data.Data.Length); // Verify usage worked // Wait for background processing to stabilize await cache.WaitForIdleAsync(); @@ -320,7 +321,7 @@ public async Task DisposeAsync_StopsBackgroundLoops_SubsequentOperationsThrow() // ARRANGE var cache = CreateCache(); var range = Intervals.NET.Factories.Range.Closed(0, 10); - + // Trigger some background activity await cache.GetDataAsync(range, CancellationToken.None); await cache.WaitForIdleAsync(); // Wait for background work to complete @@ -379,7 +380,7 @@ public async Task UsingStatement_DisposesAutomatically() { var range = Intervals.NET.Factories.Range.Closed(0, 10); var data = await cache.GetDataAsync(range, CancellationToken.None); - Assert.Equal(11, data.Length); + Assert.Equal(11, data.Data.Length); } // DisposeAsync called automatically here // ASSERT - Implicit: No exceptions thrown during disposal @@ -394,7 +395,7 @@ public async Task UsingDeclaration_DisposesAutomatically() var data = await cache.GetDataAsync(range, CancellationToken.None); // ASSERT - Assert.Equal(11, data.Length); + Assert.Equal(11, data.Data.Length); // DisposeAsync will be called automatically at end of scope }