Skip to content

Commit 0d62e14

Browse files
blaze6950Mykyta ZotovCopilot
authored
Feature/implement built in wrappers that simplifies data source configuration (#7)
* feat: implement multi-layer cache support with LayeredWindowCache and WindowCacheDataSourceAdapter; refactor: improve async delay handling and exception messages in data fetching methods * docs: README file has been updated to include validation for layered cache types; feat: validation method for layered cache compilation on WASM has been added * docs: update comments for clarity and consistency in IDataSource and LayeredWindowCache; style: improve formatting in StrongConsistencyModeTests for better readability * Update tests/SlidingWindowCache.Unit.Tests/Public/LayeredWindowCacheTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat: implement ReadOnlyMemoryEnumerable for zero-allocation enumeration of ReadOnlyMemory data; refactor: update WindowCacheDataSourceAdapter to utilize ReadOnlyMemoryEnumerable for lazy data access; fix: add null check for domain parameter in LayeredWindowCacheBuilder; test: add unit tests for LayeredWindowCacheBuilder and WindowCacheDataSourceAdapter * refactor: update data source adapter to use ReadOnlyMemoryEnumerable for improved memory efficiency; docs: enhance documentation for ReadOnlyMemoryEnumerable and WindowCacheDataSourceAdapter --------- Co-authored-by: Mykyta Zotov <mykyta.zotov@ihsmarkit.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 2cfdcad commit 0d62e14

23 files changed

Lines changed: 3146 additions & 51 deletions

File tree

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,58 @@ This is a thin composition of `GetDataAsync` followed by `WaitForIdleAsync`. The
351351

352352
`WaitForIdleAsync()` provides race-free synchronization with background operations for tests. Uses "was idle at some point" semantics — does not guarantee still idle after completion. See `docs/invariants.md` (Activity tracking invariants).
353353

354+
## Multi-Layer Cache
355+
356+
For workloads with high-latency data sources, you can compose multiple `WindowCache` instances into a layered stack. Each layer uses the layer below it as its data source, allowing you to trade memory for reduced data-source I/O.
357+
358+
```csharp
359+
await using var cache = LayeredWindowCacheBuilder<int, byte[], IntegerFixedStepDomain>
360+
.Create(realDataSource, domain)
361+
.AddLayer(new WindowCacheOptions( // L2: deep background cache
362+
leftCacheSize: 10.0,
363+
rightCacheSize: 10.0,
364+
readMode: UserCacheReadMode.CopyOnRead,
365+
leftThreshold: 0.3,
366+
rightThreshold: 0.3))
367+
.AddLayer(new WindowCacheOptions( // L1: user-facing cache
368+
leftCacheSize: 0.5,
369+
rightCacheSize: 0.5,
370+
readMode: UserCacheReadMode.Snapshot))
371+
.Build();
372+
373+
var result = await cache.GetDataAsync(range, ct);
374+
```
375+
376+
`LayeredWindowCache` implements `IWindowCache` and is `IAsyncDisposable` — it owns and disposes all layers when you dispose it.
377+
378+
**Recommended layer configuration pattern:**
379+
- **Inner layers** (closest to the data source): `CopyOnRead`, large buffer sizes (5–10×), handles the heavy prefetching
380+
- **Outer (user-facing) layer**: `Snapshot`, small buffer sizes (0.3–1.0×), zero-allocation reads
381+
382+
> **Important — buffer ratio requirement:** Inner layer buffers must be **substantially** larger
383+
> than outer layer buffers, not merely slightly larger. When the outer layer rebalances, it
384+
> fetches missing ranges from the inner layer via `GetDataAsync`. Each fetch publishes a
385+
> rebalance intent on the inner layer. If the inner layer's `NoRebalanceRange` is not wide
386+
> enough to contain the outer layer's full `DesiredCacheRange`, the inner layer will also
387+
> rebalance — and re-center toward only one side of the outer layer's gap, leaving it poorly
388+
> positioned for the next rebalance. With undersized inner buffers this becomes a continuous
389+
> cycle (cascading rebalance thrashing). Use a 5–10× ratio and `leftThreshold`/`rightThreshold`
390+
> of 0.2–0.3 on inner layers to ensure the inner layer's stability zone absorbs the outer
391+
> layer's rebalance fetches. See `docs/architecture.md` (Cascading Rebalance Behavior) and
392+
> `docs/scenarios.md` (Scenarios L6 and L7) for the full explanation.
393+
394+
**Three-layer example:**
395+
```csharp
396+
await using var cache = LayeredWindowCacheBuilder<int, byte[], IntegerFixedStepDomain>
397+
.Create(realDataSource, domain)
398+
.AddLayer(l3Options) // L3: 10× CopyOnRead — network/disk absorber
399+
.AddLayer(l2Options) // L2: 2× CopyOnRead — mid-level buffer
400+
.AddLayer(l1Options) // L1: 0.5× Snapshot — user-facing
401+
.Build();
402+
```
403+
404+
For detailed guidance see `docs/storage-strategies.md`.
405+
354406
## License
355407

356408
MIT

docs/architecture.md

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,160 @@ Disposal respects the single-writer architecture:
316316

317317
---
318318

319+
## Multi-Layer Caches
320+
321+
### Overview
322+
323+
Multiple `WindowCache` instances can be stacked into a cache pipeline where each layer's
324+
`IDataSource` is the layer below it. This is built into the library via three public types:
325+
326+
- **`WindowCacheDataSourceAdapter`** — adapts any `IWindowCache` as an `IDataSource` so it can
327+
serve as a backing store for an outer `WindowCache`.
328+
- **`LayeredWindowCacheBuilder`** — fluent builder that wires the layers together and returns a
329+
`LayeredWindowCache` that owns and disposes all of them.
330+
- **`LayeredWindowCache`** — thin `IWindowCache` wrapper that delegates `GetDataAsync` to the
331+
outermost layer, awaits all layers sequentially (outermost-to-innermost) on `WaitForIdleAsync`,
332+
and disposes all layers outermost-first on disposal.
333+
334+
### Architectural Properties
335+
336+
**Each layer is an independent `WindowCache`.**
337+
Every layer obeys the full single-writer architecture, decision-driven execution, and smart
338+
eventual consistency model described in this document. There is no shared state between layers.
339+
340+
**Data flows inward on miss, outward on return.**
341+
When the outermost layer does not have data in its window, it calls the adapter's `FetchAsync`,
342+
which calls `GetDataAsync` on the next inner layer. This cascades inward until the real data
343+
source is reached. Each layer then caches the data it fetched and returns it up the chain.
344+
345+
**Full-stack convergence via `WaitForIdleAsync`.**
346+
`WaitForIdleAsync` on `LayeredWindowCache` awaits all layers sequentially, outermost to innermost.
347+
The outermost layer must be awaited first, because its rebalance drives fetch requests (via the
348+
adapter) into inner layers — only once the outer layer is idle can inner layers be known to have
349+
received all pending work. This guarantees that calling `GetDataAndWaitForIdleAsync` on a
350+
`LayeredWindowCache` waits for the entire cache stack to converge, not just the user-facing layer.
351+
Each inner layer independently manages its own idle state via `AsyncActivityCounter`.
352+
353+
**Consistent model — not strong consistency between layers.**
354+
The adapter uses `GetDataAsync` (eventual consistency), not `GetDataAndWaitForIdleAsync`. Inner
355+
layers are not forced to converge before serving the outer layer. Each layer serves correct data
356+
immediately; prefetch optimization propagates asynchronously at each layer independently.
357+
358+
**No new concurrency model.** A layered cache is not a multi-consumer scenario. All user
359+
requests flow through the single outermost layer, which remains the sole logical consumer of the
360+
next inner layer (via the adapter). The single-consumer model holds at every layer boundary.
361+
362+
**Disposal order.** `LayeredWindowCache.DisposeAsync` disposes layers outermost-first:
363+
the user-facing layer is stopped first (no new requests flow into inner layers), then each inner
364+
layer is disposed in turn. This mirrors the single-writer disposal sequence at each layer.
365+
366+
### Recommended Layer Configuration
367+
368+
| Layer | `UserCacheReadMode` | Buffer size | Purpose |
369+
|---------------------------------------------|---------------------|-------------|----------------------------------------|
370+
| Innermost (deepest, closest to data source) | `CopyOnRead` | 5–10× | Wide prefetch window; absorbs I/O cost |
371+
| Intermediate (optional) | `CopyOnRead` | 1–3× | Narrows window toward working set |
372+
| Outermost (user-facing) | `Snapshot` | 0.3–1.0× | Zero-allocation reads; minimal memory |
373+
374+
Inner layers with `CopyOnRead` make cache writes cheap (growable list, no copy on write) while
375+
outer `Snapshot` layers make reads cheap (single contiguous array, zero per-read allocation).
376+
377+
### Cascading Rebalance Behavior
378+
379+
This is the most important configuration concern in a layered cache setup.
380+
381+
#### Mechanism
382+
383+
When L1 rebalances, its `CacheDataExtensionService` computes missing ranges
384+
(`DesiredCacheRange \ AssembledRangeData`) and calls the batch `FetchAsync(IEnumerable<Range>, ct)`
385+
on the `WindowCacheDataSourceAdapter`. Because the adapter only implements the single-range
386+
`FetchAsync` overload, the default `IDataSource` interface implementation dispatches one
387+
parallel call per missing range via `Task.WhenAll`.
388+
389+
Each call reaches L2's `GetDataAsync`, which:
390+
1. Serves the data immediately (from L2's cache or by fetching from L2's own data source)
391+
2. **Publishes a rebalance intent on L2** with that individual range
392+
393+
When L1's `DesiredCacheRange` extends beyond L2's current window on both sides, L1's rebalance
394+
produces two gap ranges (left and right). Both `GetDataAsync` calls on L2 happen in parallel.
395+
L2's intent loop processes whichever intent it sees last ("latest wins"), and if that range
396+
falls outside L2's `NoRebalanceRange`, L2 schedules its own background rebalance.
397+
398+
This is a **cascading rebalance**: L1's rebalance triggers L2's rebalance. Under sequential
399+
access with correct configuration this should be rare. Under misconfiguration it becomes a
400+
continuous cycle — every L1 rebalance triggers an L2 rebalance, which re-centers L2 toward
401+
just one gap side, leaving L2 poorly positioned for L1's next rebalance.
402+
403+
#### Natural Mitigations Already in Place
404+
405+
The system provides several natural defences against cascading rebalances, even before
406+
configuration is considered:
407+
408+
- **"Latest wins" semantics**: When two parallel `GetDataAsync` calls publish intents on L2,
409+
the intent loop processes only the surviving (latest) intent. At most one L2 rebalance is
410+
triggered per L1 rebalance burst, regardless of how many gap ranges L1 fetched.
411+
- **Debounce delay**: L2's debounce delay further coalesces rapid sequential intent publications.
412+
Parallel intents from a single L1 rebalance will typically be absorbed into one debounce window.
413+
- **Decision engine work avoidance**: If the surviving intent range falls within L2's
414+
`NoRebalanceRange`, L2's Decision Engine rejects rebalance at Stage 1 (fast path). No L2
415+
rebalance is triggered at all. This is the **desired steady-state** under correct configuration.
416+
417+
#### Configuration Requirements
418+
419+
The natural mitigations are only effective when L2's buffer is substantially larger than L1's.
420+
The goal is that L1's full `DesiredCacheRange` fits comfortably within L2's `NoRebalanceRange`
421+
during normal sequential access — making Stage 1 rejection the norm, not the exception.
422+
423+
**Buffer ratio rule of thumb:**
424+
425+
| Layer | `leftCacheSize` / `rightCacheSize` | `leftThreshold` / `rightThreshold` |
426+
|----------------|------------------------------------|--------------------------------------------|
427+
| L1 (outermost) | 0.3–1.0× | 0.1–0.2 (can be tight — L2 absorbs misses) |
428+
| L2 (inner) | 5–10× L1's buffer | 0.2–0.3 (wider stability zone) |
429+
| L3+ (deeper) | 3–5× the layer above | 0.2–0.3 |
430+
431+
With these ratios, L1's `DesiredCacheRange` (which expands L1's buffer around the request)
432+
typically falls well within L2's `NoRebalanceRange` (which is L2's buffer shrunk by its
433+
thresholds). L2's Decision Engine skips rebalance at Stage 1, and no cascading occurs.
434+
435+
**Why the ratio matters more than the absolute size:**
436+
437+
Suppose L1 has `leftCacheSize=1.0, rightCacheSize=1.0` and `requestedRange` has length 100.
438+
L1's `DesiredCacheRange` will be approximately `[request - 100, request + 100]` (length 300).
439+
For L2's Stage 1 to reject the rebalance, L2's `NoRebalanceRange` must contain that
440+
`[request - 100, request + 100]` interval. L2's `NoRebalanceRange` is derived from
441+
`CurrentCacheRange` by applying L2's thresholds inward. So L2 needs a `CurrentCacheRange`
442+
substantially larger than L1's `DesiredCacheRange`.
443+
444+
#### Anti-Pattern: Buffers Too Close in Size
445+
446+
**What goes wrong when L2's buffer is similar to L1's:**
447+
448+
1. User scrolls → L1 rebalances, extending to `[50, 300]`
449+
2. L1 fetches left gap `[50, 100)` and right gap `(250, 300]` from L2 in parallel
450+
3. Both ranges fall outside L2's `NoRebalanceRange` (L2's buffer isn't large enough to cover them)
451+
4. L2 re-centers toward the last-processed gap — say, `(250, 300]`
452+
5. L2's `CurrentCacheRange` is now `[200, 380]`
453+
6. User scrolls again → L1 rebalances to `[120, 370]`
454+
7. Left gap `[120, 200)` falls outside L2's window — L2 must fetch from its own data source
455+
8. L2 re-centers again → oscillation
456+
457+
**Symptoms:** `l2.RebalanceExecutionCompleted` count approaches `l1.RebalanceExecutionCompleted`.
458+
The inner layer provides no meaningful buffering benefit. Data source I/O per user request is
459+
not reduced compared to a single-layer cache.
460+
461+
**Resolution:** Increase L2's `leftCacheSize` and `rightCacheSize` to 5–10× L1's values, and
462+
set L2's `leftThreshold` / `rightThreshold` to 0.2–0.3.
463+
464+
### See Also
465+
466+
- `README.md` — Multi-Layer Cache usage examples and configuration warning
467+
- `docs/scenarios.md` — Scenarios L6 (cascading rebalance mechanics) and L7 (anti-pattern)
468+
- `docs/storage-strategies.md` — Storage strategy trade-offs for layered configs
469+
- `docs/components/public-api.md` — API reference for the three new public types
470+
471+
---
472+
319473
## Invariants
320474

321475
This document explains the model; the formal guarantees live in `docs/invariants.md`.

docs/components/overview.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ The system is easier to reason about when components are grouped by:
1818

1919
- Public facade: `WindowCache<TRange, TData, TDomain>`
2020
- Public extensions: `WindowCacheExtensions` — opt-in strong consistency mode (`GetDataAndWaitForIdleAsync`)
21+
- Multi-layer support: `WindowCacheDataSourceAdapter`, `LayeredWindowCacheBuilder`, `LayeredWindowCache`
2122
- User Path: assembles requested data and publishes intent
2223
- Intent loop: observes latest intent and runs analytical validation
2324
- Execution: performs debounced, cancellable rebalance work and mutates cache state
@@ -54,6 +55,34 @@ The system is easier to reason about when components are grouped by:
5455
├── 🟦 RebalanceExecutor<TRange, TData, TDomain>
5556
└── 🟦 CacheDataExtensionService<TRange, TData, TDomain>
5657
└── uses → 🟧 IDataSource<TRange, TData> (user-provided)
58+
59+
──────────────────────────── Multi-Layer Support ────────────────────────────
60+
61+
🟦 LayeredWindowCacheBuilder<TRange, TData, TDomain> [Fluent Builder]
62+
│ Static Create(dataSource, domain) → builder
63+
│ AddLayer(options, diagnostics?) → builder (fluent chain)
64+
│ Build() → LayeredWindowCache
65+
66+
│ internally wires:
67+
│ IDataSource → WindowCache → WindowCacheDataSourceAdapter
68+
│ │
69+
│ ▼
70+
│ WindowCache → WindowCacheDataSourceAdapter → ...
71+
│ │
72+
│ ▼ (outermost)
73+
└─────────────────────────────────► WindowCache
74+
(user-facing layer, index = LayerCount-1)
75+
76+
🟦 LayeredWindowCache<TRange, TData, TDomain> [IWindowCache wrapper]
77+
│ LayerCount: int
78+
│ GetDataAsync() → delegates to outermost WindowCache
79+
│ WaitForIdleAsync() → awaits all layers sequentially, outermost to innermost
80+
│ DisposeAsync() → disposes all layers outermost-first
81+
82+
🟦 WindowCacheDataSourceAdapter<TRange, TData, TDomain> [IDataSource adapter]
83+
│ Wraps IWindowCache as IDataSource
84+
│ FetchAsync() → calls inner cache's GetDataAsync()
85+
│ wraps ReadOnlyMemory<TData> in ReadOnlyMemoryEnumerable<TData> for RangeChunk (avoids temp TData[] alloc)
5786
```
5887

5988
**Component Type Legend:**

docs/components/public-api.md

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,55 @@ Composes `GetDataAsync` + `WaitForIdleAsync` into a single call. Returns the sam
145145

146146
**See**: `README.md` (Strong Consistency Mode section) and `docs/architecture.md` for broader context.
147147

148-
## See Also
148+
## Multi-Layer Cache
149+
150+
Three classes support building layered cache stacks where each layer's data source is the layer below it:
151+
152+
### WindowCacheDataSourceAdapter\<TRange, TData, TDomain\>
153+
154+
**File**: `src/SlidingWindowCache/Public/WindowCacheDataSourceAdapter.cs`
155+
156+
**Type**: `sealed class` implementing `IDataSource<TRange, TData>`
157+
158+
Wraps an `IWindowCache` as an `IDataSource`, allowing any `WindowCache` to act as the data source for an outer `WindowCache`. Data is retrieved using eventual consistency (`GetDataAsync`).
159+
160+
- Wraps `ReadOnlyMemory<TData>` (returned by `IWindowCache.GetDataAsync`) in a `ReadOnlyMemoryEnumerable<TData>` to satisfy the `IEnumerable<TData>` contract of `IDataSource.FetchAsync`. This avoids allocating a temporary `TData[]` copy — the wrapper holds only a reference to the existing backing array via `ReadOnlyMemory<TData>`, and the data is enumerated lazily in a single pass during the outer cache's rematerialization.
161+
- Does **not** own the wrapped cache; the caller is responsible for disposing it.
162+
163+
### LayeredWindowCache\<TRange, TData, TDomain\>
164+
165+
**File**: `src/SlidingWindowCache/Public/LayeredWindowCache.cs`
166+
167+
**Type**: `sealed class` implementing `IWindowCache<TRange, TData, TDomain>` and `IAsyncDisposable`
168+
169+
A thin wrapper that:
170+
- Delegates `GetDataAsync` to the outermost layer.
171+
- **`WaitForIdleAsync` awaits all layers sequentially, outermost to innermost.** The outer layer is awaited first because its rebalance drives fetch requests into inner layers. This ensures `GetDataAndWaitForIdleAsync` correctly waits for the entire cache stack to converge.
172+
- **Owns** all layer `WindowCache` instances and disposes them in reverse order (outermost first) when disposed.
173+
- Exposes `LayerCount` for inspection.
174+
175+
Typically created via `LayeredWindowCacheBuilder.Build()` rather than directly.
176+
177+
### LayeredWindowCacheBuilder\<TRange, TData, TDomain\>
178+
179+
**File**: `src/SlidingWindowCache/Public/LayeredWindowCacheBuilder.cs`
180+
181+
**Type**: `sealed class` — fluent builder
182+
183+
```csharp
184+
await using var cache = LayeredWindowCacheBuilder<int, byte[], IntegerFixedStepDomain>
185+
.Create(realDataSource, domain)
186+
.AddLayer(deepOptions) // L2: inner layer (CopyOnRead, large buffers)
187+
.AddLayer(userOptions) // L1: outer layer (Snapshot, small buffers)
188+
.Build();
189+
```
190+
191+
- `Create(dataSource, domain)` — factory entry point; validates both `dataSource` and `domain` are not null.
192+
- `AddLayer(options, diagnostics?)` — adds a layer on top; first call = innermost layer, last call = outermost (user-facing).
193+
- `Build()` — constructs all `WindowCache` instances, wires them via `WindowCacheDataSourceAdapter`, and wraps them in `LayeredWindowCache`.
194+
- Throws `InvalidOperationException` from `Build()` if no layers were added.
195+
196+
**See**: `README.md` (Multi-Layer Cache section) and `docs/storage-strategies.md` for recommended layer configuration patterns.
149197

150198
- `docs/boundary-handling.md`
151199
- `docs/diagnostics.md`

0 commit comments

Comments
 (0)