Skip to content

Commit 72ac47c

Browse files
blaze6950Mykyta Zotov
andauthored
[PUBLIC API BREAKING CHANGE] Fix/properly handle requests that are out of physical data bounds (#4)
* docs: add boundary handling and data availability documentation; docs: update invariants to reflect RangeResult structure and behavior * feat: handle out-of-bounds requests gracefully by returning actual available range and data; refactor: improve boundary handling logic in data fetching and caching mechanisms; docs: update comments and documentation for clarity on range handling * refactor: FetchAsync method updated to return RangeChunk instead of IEnumerable; test assertions modified to access Data property * docs: README has been updated to include boundary handling and data availability information * refactor: update data fetching methods to return RangeChunk instead of IEnumerable * test: boundary handling tests have been added to validate physical data source limits; feat: BoundedDataSource test infrastructure has been implemented for bounded data scenarios * style: formatting inconsistencies have been corrected across multiple files * refactor: update WindowCacheOptions to be nullable for improved flexibility * fix: test method signatures updated to match test data parameters; test: parameter count mismatches have been resolved for Invariant tests * test: update user path cache invariant test method signature for clarity * fix: handle requests that are out of physical data bounds by allowing nullable ranges in response structures * fix: UserRequestServed now fires on all non-exception completions including full vacuum boundary misses; UserRequestHandler finally block has been restructured to decouple served-counter from intent publication; test: UserPathExceptionHandlingTests added covering exception propagation and post-exception cache operability; test: BoundaryHandlingTests extended with physical data miss diagnostics assertion; refactor: FaultyDataSource and GenerateStringData extracted to shared TestInfrastructure; docs: ICacheDiagnostics UserRequestServed and DataSegmentUnavailable docs corrected; docs: diagnostics.md UserRequestServed description updated and DataSegmentUnavailable section added; docs: boundary-handling.md out-of-bounds RangeChunk examples corrected to use null Range; docs: component-map.md stale skip-event method names replaced with current method names * docs: invariant misalignments between code behavior and documentation have been corrected; fix: G.45 has been rewritten to describe actual I/O separation between User Path and Rebalance Execution; fix: stale 'every user request produces exactly one intent' claims have been removed from ICacheDiagnostics, UserRequestHandler XML doc, diagnostics.md, and actors-and-responsibilities.md --------- Co-authored-by: Mykyta Zotov <mykyta.zotov@ihsmarkit.com>
1 parent 0457a75 commit 72ac47c

41 files changed

Lines changed: 1846 additions & 306 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ consistency, and intelligent work avoidance.**
2323
- [Understanding the Sliding Window](#-understanding-the-sliding-window)
2424
- [Materialization for Fast Access](#-materialization-for-fast-access)
2525
- [Usage Example](#-usage-example)
26+
- [Boundary Handling & Data Availability](#-boundary-handling--data-availability)
2627
- [Resource Management](#-resource-management)
2728
- [Configuration](#-configuration)
2829
- [Execution Strategy Selection](#-execution-strategy-selection)
@@ -318,21 +319,132 @@ var cache = WindowCache<int, string, IntegerFixedStepDomain>.Create(
318319
readMode: UserCacheReadMode.Snapshot
319320
);
320321

321-
// Request data - returns ReadOnlyMemory<string>
322-
var data = await cache.GetDataAsync(
322+
// Request data - returns RangeResult<int, string>
323+
var result = await cache.GetDataAsync(
323324
Range.Closed(100, 200),
324325
cancellationToken
325326
);
326327

327328
// Access the data
328-
foreach (var item in data.Span)
329+
foreach (var item in result.Data.Span)
329330
{
330331
Console.WriteLine(item);
331332
}
332333
```
333334

334335
---
335336

337+
## 🎯 Boundary Handling & Data Availability
338+
339+
The cache provides explicit boundary handling through `RangeResult<TRange, TData>` returned by `GetDataAsync()`. This allows data sources to communicate data availability and partial fulfillment.
340+
341+
### RangeResult Structure
342+
343+
```csharp
344+
public sealed record RangeResult<TRange, TData>(
345+
Range<TRange>? Range, // Actual range returned (nullable)
346+
ReadOnlyMemory<TData> Data // The data for that range
347+
);
348+
```
349+
350+
### Basic Usage
351+
352+
```csharp
353+
var result = await cache.GetDataAsync(
354+
Intervals.NET.Factories.Range.Closed(100, 200),
355+
ct
356+
);
357+
358+
// Always check Range before using Data
359+
if (result.Range != null)
360+
{
361+
Console.WriteLine($"Received {result.Data.Length} elements for range {result.Range}");
362+
363+
foreach (var item in result.Data.Span)
364+
{
365+
ProcessItem(item);
366+
}
367+
}
368+
else
369+
{
370+
Console.WriteLine("No data available for requested range");
371+
}
372+
```
373+
374+
### Why RangeResult?
375+
376+
**Benefits:**
377+
-**Explicit Contracts**: Know exactly what range was fulfilled
378+
-**Boundary Awareness**: Data sources signal truncation at physical boundaries
379+
-**No Exceptions for Normal Cases**: Out-of-bounds is expected, not exceptional
380+
-**Partial Fulfillment**: Handle cases where only part of requested range is available
381+
382+
### Bounded Data Sources Example
383+
384+
For data sources with physical boundaries (databases with min/max IDs, APIs with limits):
385+
386+
```csharp
387+
public class BoundedDatabaseSource : IDataSource<int, Record>
388+
{
389+
private const int MinId = 1000;
390+
private const int MaxId = 9999;
391+
392+
public async Task<RangeChunk<int, Record>> FetchAsync(
393+
Range<int> requested,
394+
CancellationToken ct)
395+
{
396+
var availableRange = Intervals.NET.Factories.Range.Closed(MinId, MaxId);
397+
var fulfillable = requested.Intersect(availableRange);
398+
399+
// No data available
400+
if (fulfillable == null)
401+
{
402+
return new RangeChunk<int, Record>(
403+
null, // Range must be null to signal no data available
404+
Array.Empty<Record>()
405+
);
406+
}
407+
408+
// Fetch available portion
409+
var data = await _db.FetchRecordsAsync(
410+
fulfillable.LowerBound.Value,
411+
fulfillable.UpperBound.Value,
412+
ct
413+
);
414+
415+
return new RangeChunk<int, Record>(fulfillable, data);
416+
}
417+
}
418+
419+
// Example scenarios:
420+
// Request [2000..3000] → Range = [2000..3000], 1001 records ✓
421+
// Request [500..1500] → Range = [1000..1500], 501 records (truncated) ✓
422+
// Request [0..999] → Range = null, empty data ✓
423+
```
424+
425+
### Handling Subset Requests
426+
427+
When requesting a subset of cached data, `RangeResult` returns only the requested range:
428+
429+
```csharp
430+
// Prime cache with large range
431+
await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(0, 1000), ct);
432+
433+
// Request subset (served from cache)
434+
var subset = await cache.GetDataAsync(
435+
Intervals.NET.Factories.Range.Closed(100, 200),
436+
ct
437+
);
438+
439+
// Result contains ONLY the requested subset
440+
Assert.Equal(101, subset.Data.Length); // [100, 200] = 101 elements
441+
Assert.Equal(subset.Range, Intervals.NET.Factories.Range.Closed(100, 200));
442+
```
443+
444+
**For complete boundary handling documentation, see:** [Boundary Handling Guide](docs/boundary-handling.md)
445+
446+
---
447+
336448
## 🔄 Resource Management
337449

338450
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
406518
);
407519
}
408520

409-
public ValueTask<ReadOnlyMemory<string>> GetDataAsync(Range<int> range, CancellationToken ct)
521+
public ValueTask<RangeResult<int, string>> GetDataAsync(Range<int> range, CancellationToken ct)
410522
=> _cache.GetDataAsync(range, ct);
411523

412524
public async ValueTask DisposeAsync()
@@ -757,9 +869,10 @@ see [Diagnostics Guide](docs/diagnostics.md).**
757869

758870
1. **[README - Quick Start](#-quick-start)** - Basic usage examples (you're already here!)
759871
2. **[README - Configuration Guide](#configuration)** - Understand the 5 key parameters
760-
3. **[Storage Strategies](docs/storage-strategies.md)** - Choose Snapshot vs CopyOnRead for your use case
761-
4. **[Glossary - Common Misconceptions](docs/glossary.md#common-misconceptions)** - Avoid common pitfalls
762-
5. **[Diagnostics](docs/diagnostics.md)** - Add optional instrumentation for visibility
872+
3. **[Boundary Handling](docs/boundary-handling.md)** - RangeResult usage, bounded data sources, partial fulfillment
873+
4. **[Storage Strategies](docs/storage-strategies.md)** - Choose Snapshot vs CopyOnRead for your use case
874+
5. **[Glossary - Common Misconceptions](docs/glossary.md#common-misconceptions)** - Avoid common pitfalls
875+
6. **[Diagnostics](docs/diagnostics.md)** - Add optional instrumentation for visibility
763876

764877
**When to use this path**: Building features, integrating the cache, performance tuning.
765878

benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/ExecutionStrategyBenchmarks.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ private void SetupCache(int? rebalanceQueueCapacity)
245245

246246
// Build initial range for first request
247247
var initialRange = Intervals.NET.Factories.Range.Closed<int>(
248-
InitialStart,
248+
InitialStart,
249249
InitialStart + BaseSpanSize - 1
250250
);
251251

benchmarks/SlidingWindowCache.Benchmarks/Benchmarks/UserFlowBenchmarks.cs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ public class UserFlowBenchmarks
7373
private Range<int> _partialHitBackwardRange;
7474
private Range<int> _fullMissRange;
7575

76-
private WindowCacheOptions _snapshotOptions;
77-
private WindowCacheOptions _copyOnReadOptions;
76+
private WindowCacheOptions? _snapshotOptions;
77+
private WindowCacheOptions? _copyOnReadOptions;
7878

7979
[GlobalSetup]
8080
public void GlobalSetup()
@@ -120,13 +120,13 @@ public void IterationSetup()
120120
_snapshotCache = new WindowCache<int, int, IntegerFixedStepDomain>(
121121
_dataSource,
122122
_domain,
123-
_snapshotOptions
123+
_snapshotOptions!
124124
);
125125

126126
_copyOnReadCache = new WindowCache<int, int, IntegerFixedStepDomain>(
127127
_dataSource,
128128
_domain,
129-
_copyOnReadOptions
129+
_copyOnReadOptions!
130130
);
131131

132132
// Prime both caches with known initial window
@@ -155,15 +155,15 @@ public void IterationCleanup()
155155
public async Task<ReadOnlyMemory<int>> User_FullHit_Snapshot()
156156
{
157157
// No rebalance triggered
158-
return await _snapshotCache!.GetDataAsync(_fullHitRange, CancellationToken.None);
158+
return (await _snapshotCache!.GetDataAsync(_fullHitRange, CancellationToken.None)).Data;
159159
}
160160

161161
[Benchmark]
162162
[BenchmarkCategory("FullHit")]
163163
public async Task<ReadOnlyMemory<int>> User_FullHit_CopyOnRead()
164164
{
165165
// No rebalance triggered
166-
return await _copyOnReadCache!.GetDataAsync(_fullHitRange, CancellationToken.None);
166+
return (await _copyOnReadCache!.GetDataAsync(_fullHitRange, CancellationToken.None)).Data;
167167
}
168168

169169
#endregion
@@ -175,31 +175,31 @@ public async Task<ReadOnlyMemory<int>> User_FullHit_CopyOnRead()
175175
public async Task<ReadOnlyMemory<int>> User_PartialHit_ForwardShift_Snapshot()
176176
{
177177
// Rebalance triggered, handled in cleanup
178-
return await _snapshotCache!.GetDataAsync(_partialHitForwardRange, CancellationToken.None);
178+
return (await _snapshotCache!.GetDataAsync(_partialHitForwardRange, CancellationToken.None)).Data;
179179
}
180180

181181
[Benchmark]
182182
[BenchmarkCategory("PartialHit")]
183183
public async Task<ReadOnlyMemory<int>> User_PartialHit_ForwardShift_CopyOnRead()
184184
{
185185
// Rebalance triggered, handled in cleanup
186-
return await _copyOnReadCache!.GetDataAsync(_partialHitForwardRange, CancellationToken.None);
186+
return (await _copyOnReadCache!.GetDataAsync(_partialHitForwardRange, CancellationToken.None)).Data;
187187
}
188188

189189
[Benchmark]
190190
[BenchmarkCategory("PartialHit")]
191191
public async Task<ReadOnlyMemory<int>> User_PartialHit_BackwardShift_Snapshot()
192192
{
193193
// Rebalance triggered, handled in cleanup
194-
return await _snapshotCache!.GetDataAsync(_partialHitBackwardRange, CancellationToken.None);
194+
return (await _snapshotCache!.GetDataAsync(_partialHitBackwardRange, CancellationToken.None)).Data;
195195
}
196196

197197
[Benchmark]
198198
[BenchmarkCategory("PartialHit")]
199199
public async Task<ReadOnlyMemory<int>> User_PartialHit_BackwardShift_CopyOnRead()
200200
{
201201
// Rebalance triggered, handled in cleanup
202-
return await _copyOnReadCache!.GetDataAsync(_partialHitBackwardRange, CancellationToken.None);
202+
return (await _copyOnReadCache!.GetDataAsync(_partialHitBackwardRange, CancellationToken.None)).Data;
203203
}
204204

205205
#endregion
@@ -212,7 +212,7 @@ public async Task<ReadOnlyMemory<int>> User_FullMiss_Snapshot()
212212
{
213213
// No overlap - full cache replacement
214214
// Rebalance triggered, handled in cleanup
215-
return await _snapshotCache!.GetDataAsync(_fullMissRange, CancellationToken.None);
215+
return (await _snapshotCache!.GetDataAsync(_fullMissRange, CancellationToken.None)).Data;
216216
}
217217

218218
[Benchmark]
@@ -221,7 +221,7 @@ public async Task<ReadOnlyMemory<int>> User_FullMiss_CopyOnRead()
221221
{
222222
// No overlap - full cache replacement
223223
// Rebalance triggered, handled in cleanup
224-
return await _copyOnReadCache!.GetDataAsync(_fullMissRange, CancellationToken.None);
224+
return (await _copyOnReadCache!.GetDataAsync(_fullMissRange, CancellationToken.None)).Data;
225225
}
226226

227227
#endregion

benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SlowDataSource.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using Intervals.NET;
22
using Intervals.NET.Domain.Default.Numeric;
3-
using Intervals.NET.Domain.Extensions.Fixed;
43
using SlidingWindowCache.Public;
54
using SlidingWindowCache.Public.Dto;
65

@@ -31,14 +30,14 @@ public SlowDataSource(IntegerFixedStepDomain domain, TimeSpan latency)
3130
/// Fetches data for a single range with simulated latency.
3231
/// Respects cancellation token to allow early exit during debounce or execution cancellation.
3332
/// </summary>
34-
public async Task<IEnumerable<int>> FetchAsync(Range<int> range, CancellationToken cancellationToken)
33+
public async Task<RangeChunk<int, int>> FetchAsync(Range<int> range, CancellationToken cancellationToken)
3534
{
3635
// Simulate I/O latency (network/database delay)
3736
// This delay is cancellable, allowing execution strategies to abort obsolete fetches
3837
await Task.Delay(_latency, cancellationToken).ConfigureAwait(false);
3938

4039
// Generate data after delay completes
41-
return GenerateDataForRange(range);
40+
return new RangeChunk<int, int>(range, GenerateDataForRange(range));
4241
}
4342

4443
/// <summary>

benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SynchronousDataSource.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ public SynchronousDataSource(IntegerFixedStepDomain domain)
2424
/// Fetches data for a single range with zero latency.
2525
/// Data generation: Returns the integer value at each position in the range.
2626
/// </summary>
27-
public Task<IEnumerable<int>> FetchAsync(Range<int> range, CancellationToken cancellationToken) =>
28-
Task.FromResult(GenerateDataForRange(range));
27+
public Task<RangeChunk<int, int>> FetchAsync(Range<int> range, CancellationToken cancellationToken) =>
28+
Task.FromResult(new RangeChunk<int, int>(range, GenerateDataForRange(range)));
2929

3030
/// <summary>
3131
/// Fetches data for multiple ranges with zero latency.

docs/actors-and-responsibilities.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ Handles user requests with minimal latency and maximal isolation from background
2323

2424
**Critical Contract:**
2525
```
26-
Every user access produces a rebalance intent containing delivered data.
26+
Every user access that results in assembled data publishes a rebalance intent containing
27+
that delivered data. Requests where IDataSource returns null (physical boundary misses)
28+
do not publish an intent — there is no data to embed (Invariant C.24e).
2729
The UserRequestHandler is READ-ONLY with respect to cache state.
2830
The UserRequestHandler NEVER invokes directly decision logic - it just publishes an intent.
2931
```
@@ -171,7 +173,7 @@ IntentController (User Thread for PublishIntent; Background Thread for ProcessIn
171173
**Enhanced Role (Decision-Driven Model):**
172174

173175
Now responsible for:
174-
- **Receiving intents** (on every user request) [IntentController.PublishIntent - User Thread]
176+
- **Receiving intents** (when user request produces assembled data) [IntentController.PublishIntent - User Thread]
175177
- **Owning and invoking DecisionEngine** [IntentController - Background Thread (intent processing loop), synchronous]
176178
- **Intent identity and versioning** via ExecutionRequest snapshot [IntentController]
177179
- **Cancellation coordination** based on validation results from owned DecisionEngine [IntentController - Background Thread]

0 commit comments

Comments
 (0)