From cffc30de87a0c8e2fd16f13fb111a923d466f69a Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Fri, 27 Feb 2026 00:30:34 +0100 Subject: [PATCH 01/21] docs: update FaultyDataSource documentation for clarity and accuracy --- .../TestInfrastructure/FaultyDataSource.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/FaultyDataSource.cs b/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/FaultyDataSource.cs index 4107aa5..bc48107 100644 --- a/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/FaultyDataSource.cs +++ b/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/FaultyDataSource.cs @@ -6,8 +6,8 @@ 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. +/// allowing individual tests to inject faults (exceptions) or control returned data on a per-call basis. +/// Intended for exception-handling tests only. For boundary/null-Range scenarios use BoundedDataSource. /// /// The range boundary type. /// The data type. @@ -23,7 +23,7 @@ public sealed class FaultyDataSource : IDataSource /// 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. + /// the requested range — this class does not support returning a null Range. /// public FaultyDataSource(Func, IEnumerable> fetchSingleRange) { From a180510286d70110bd6ce7bda613c33b0729c5ad Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Fri, 27 Feb 2026 02:39:31 +0100 Subject: [PATCH 02/21] refactor: improve diagnostics and data source handling; refactor: update range chunk and intent structures; chore: add new test infrastructure project --- SlidingWindowCache.sln | 7 + docs/actors-to-components-mapping.md | 28 +-- docs/component-map.md | 4 +- docs/scenario-model.md | 10 +- .../Core/Planning/NoRebalanceRangePlanner.cs | 4 +- .../Core/Planning/ProportionalRangePlanner.cs | 4 +- .../Rebalance/Decision/RebalanceDecision.cs | 30 +-- .../Decision/RebalanceDecisionEngine.cs | 27 +-- .../Rebalance/Decision/RebalanceReason.cs | 27 +++ .../Decision/ThresholdRebalancePolicy.cs | 7 +- .../Execution/CacheDataExtensionService.cs | 48 ++-- ...hannelBasedRebalanceExecutionController.cs | 17 +- .../Rebalance/Execution/ExecutionRequest.cs | 72 ++++-- .../IRebalanceExecutionController.cs | 29 +-- .../Rebalance/Execution/RebalanceExecutor.cs | 33 +-- .../TaskBasedRebalanceExecutionController.cs | 17 +- .../Core/Rebalance/Intent/Intent.cs | 30 +++ .../Core/Rebalance/Intent/IntentController.cs | 39 +--- .../Core/State/CacheState.cs | 50 ++++- .../Core/UserPath/UserRequestHandler.cs | 87 ++++---- .../Concurrency/AsyncActivityCounter.cs | 6 +- .../IntervalsNetDomainExtensions.cs | 6 +- .../EventCounterCacheDiagnostics.cs | 40 ++-- .../Instrumentation/NoOpDiagnostics.cs | 2 +- .../Storage/CopyOnReadStorage.cs | 35 ++- .../Infrastructure/Storage/ICacheStorage.cs | 6 - .../Storage/SnapshotReadStorage.cs | 5 +- .../Public/Dto/RangeChunk.cs | 8 +- src/SlidingWindowCache/Public/IDataSource.cs | 20 +- src/SlidingWindowCache/Public/IWindowCache.cs | 128 +++++++++++ src/SlidingWindowCache/Public/WindowCache.cs | 209 ++++-------------- .../BoundaryHandlingTests.cs | 2 +- .../CacheDataSourceInteractionTests.cs | 13 +- .../ConcurrencyStabilityTests.cs | 5 +- .../DataSourceRangePropagationTests.cs | 10 +- .../RandomRangeRobustnessTests.cs | 2 +- .../RangeSemanticsContractTests.cs | 2 +- .../RebalanceExceptionHandlingTests.cs | 2 +- ...lidingWindowCache.Integration.Tests.csproj | 1 + .../UserPathExceptionHandlingTests.cs | 2 +- ...SlidingWindowCache.Invariants.Tests.csproj | 7 + .../WindowCacheInvariantTests.cs | 7 +- .../DataSources}/BoundedDataSource.cs | 51 +---- .../DataSources/DataGenerationHelpers.cs | 61 +++++ .../DataSources}/FaultyDataSource.cs | 2 +- .../DataSources}/SpyDataSource.cs | 58 +---- .../Helpers}/TestHelpers.cs | 95 ++------ ...ingWindowCache.Tests.Infrastructure.csproj | 28 +++ .../Storage/CopyOnReadStorageTests.cs | 15 -- .../Storage/SnapshotReadStorageTests.cs | 15 -- 50 files changed, 700 insertions(+), 713 deletions(-) create mode 100644 src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceReason.cs create mode 100644 src/SlidingWindowCache/Core/Rebalance/Intent/Intent.cs create mode 100644 src/SlidingWindowCache/Public/IWindowCache.cs rename tests/{SlidingWindowCache.Integration.Tests/TestInfrastructure => SlidingWindowCache.Tests.Infrastructure/DataSources}/BoundedDataSource.cs (61%) create mode 100644 tests/SlidingWindowCache.Tests.Infrastructure/DataSources/DataGenerationHelpers.cs rename tests/{SlidingWindowCache.Integration.Tests/TestInfrastructure => SlidingWindowCache.Tests.Infrastructure/DataSources}/FaultyDataSource.cs (97%) rename tests/{SlidingWindowCache.Integration.Tests/TestInfrastructure => SlidingWindowCache.Tests.Infrastructure/DataSources}/SpyDataSource.cs (70%) rename tests/{SlidingWindowCache.Invariants.Tests/TestInfrastructure => SlidingWindowCache.Tests.Infrastructure/Helpers}/TestHelpers.cs (89%) create mode 100644 tests/SlidingWindowCache.Tests.Infrastructure/SlidingWindowCache.Tests.Infrastructure.csproj diff --git a/SlidingWindowCache.sln b/SlidingWindowCache.sln index 46a2850..a418d9d 100644 --- a/SlidingWindowCache.sln +++ b/SlidingWindowCache.sln @@ -32,6 +32,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlidingWindowCache.Integrat EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlidingWindowCache.Unit.Tests", "tests\SlidingWindowCache.Unit.Tests\SlidingWindowCache.Unit.Tests.csproj", "{906F9E4F-0EFA-4FE8-8DA2-DDECE22B7306}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlidingWindowCache.Tests.Infrastructure", "tests\SlidingWindowCache.Tests.Infrastructure\SlidingWindowCache.Tests.Infrastructure.csproj", "{C1D2E3F4-A5B6-4C7D-8E9F-0A1B2C3D4E5F}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cicd", "cicd", "{9C6688E8-071B-48F5-9B84-4779B58822CC}" ProjectSection(SolutionItems) = preProject .github\workflows\slidingwindowcache.yml = .github\workflows\slidingwindowcache.yml @@ -67,6 +69,10 @@ Global {906F9E4F-0EFA-4FE8-8DA2-DDECE22B7306}.Debug|Any CPU.Build.0 = Debug|Any CPU {906F9E4F-0EFA-4FE8-8DA2-DDECE22B7306}.Release|Any CPU.ActiveCfg = Release|Any CPU {906F9E4F-0EFA-4FE8-8DA2-DDECE22B7306}.Release|Any CPU.Build.0 = Release|Any CPU + {C1D2E3F4-A5B6-4C7D-8E9F-0A1B2C3D4E5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1D2E3F4-A5B6-4C7D-8E9F-0A1B2C3D4E5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1D2E3F4-A5B6-4C7D-8E9F-0A1B2C3D4E5F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1D2E3F4-A5B6-4C7D-8E9F-0A1B2C3D4E5F}.Release|Any CPU.Build.0 = Release|Any CPU {8E83B41E-08E9-4AF4-8272-1AB2D2DEDBAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8E83B41E-08E9-4AF4-8272-1AB2D2DEDBAB}.Debug|Any CPU.Build.0 = Debug|Any CPU {8E83B41E-08E9-4AF4-8272-1AB2D2DEDBAB}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -79,6 +85,7 @@ Global {17AB54EA-D245-4867-A047-ED55B4D94C17} = {8C504091-1383-4EEB-879E-7A3769C3DF13} {0023794C-FAD3-490C-96E3-448C68ED2569} = {8C504091-1383-4EEB-879E-7A3769C3DF13} {906F9E4F-0EFA-4FE8-8DA2-DDECE22B7306} = {8C504091-1383-4EEB-879E-7A3769C3DF13} + {C1D2E3F4-A5B6-4C7D-8E9F-0A1B2C3D4E5F} = {8C504091-1383-4EEB-879E-7A3769C3DF13} {9C6688E8-071B-48F5-9B84-4779B58822CC} = {EB667A96-0E73-48B6-ACC8-C99369A59D0D} {8E83B41E-08E9-4AF4-8272-1AB2D2DEDBAB} = {EB0F4813-1FA9-4C40-A975-3B8C6BBFF8D5} EndGlobalSection diff --git a/docs/actors-to-components-mapping.md b/docs/actors-to-components-mapping.md index c8ec732..5f8deb7 100644 --- a/docs/actors-to-components-mapping.md +++ b/docs/actors-to-components-mapping.md @@ -277,7 +277,7 @@ This component should be: - fully synchronous - independent of execution context -**Critical Distinction:** While this is an internal tool of IntentManager/Executor pipeline, +**Critical Distinction:** While this is an internal tool of IntentController/Executor pipeline, it is **THE sole authority** for determining rebalance necessity. All execution decisions flow from this component's analytical validation. @@ -295,9 +295,9 @@ flow from this component's analytical validation. **Implemented as:** Two separate components working together as a unified policy: -1. **ThresholdRebalancePolicy** - - `internal readonly struct ThresholdRebalancePolicy` - - File: `src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs` +1. **NoRebalanceSatisfactionPolicy** + - `internal readonly struct NoRebalanceSatisfactionPolicy` + - File: `src/SlidingWindowCache/Core/Rebalance/Decision/NoRebalanceSatisfactionPolicy.cs` - Computes `NoRebalanceRange` - Checks if rebalance is needed based on threshold rules @@ -326,7 +326,7 @@ shape to target). ### Component Responsibilities -#### ThresholdRebalancePolicy (Threshold Rules) +#### NoRebalanceSatisfactionPolicy (Threshold Rules) - Computes `NoRebalanceRange` from `CurrentCacheRange` + threshold configuration - Determines if requested range falls outside no-rebalance zone - Enforces threshold-based rebalance triggering rules @@ -342,10 +342,10 @@ shape to target). Together, these components: - Compute `DesiredCacheRange` [ProportionalRangePlanner] -- Compute `NoRebalanceRange` [ThresholdRebalancePolicy] +- Compute `NoRebalanceRange` [NoRebalanceSatisfactionPolicy] - Encapsulate all sliding window rules: - left/right sizes [ProportionalRangePlanner] - - thresholds [ThresholdRebalancePolicy] + - thresholds [NoRebalanceSatisfactionPolicy] - expansion rules [ProportionalRangePlanner] ### Characteristics @@ -360,15 +360,15 @@ Together, these components: This actor defines the **canonical shape** of the cache. The split into two components reflects separation of concerns: -- **When to rebalance** (threshold-based triggering) → ThresholdRebalancePolicy +- **When to rebalance** (threshold-based triggering) → NoRebalanceSatisfactionPolicy - **What shape to target** (desired cache geometry) → ProportionalRangePlanner -Similar to RebalanceIntentManager, this logical actor is internally decomposed +Similar to RebalanceIntentController, this logical actor is internally decomposed but externally appears as a unified policy concept. --- -## 5. RebalanceIntentManager +## 5. RebalanceIntentController *(Intent & Concurrency Actor)* @@ -593,7 +593,7 @@ Its **conceptual separation is mandatory** even if physically co-located. | UserRequestHandler | Speed & availability | | DecisionEngine | Correctness of decision | | GeometryPolicy | Deterministic shape | -| IntentManager | Time & concurrency | +| IntentController | Time & concurrency | | RebalanceExecutor | Physical mutation | | CacheStateManager | Safety & consistency | @@ -613,7 +613,7 @@ UserRequestHandler ▼ Background / ThreadPool ─────────────────────── -RebalanceIntentManager +RebalanceIntentController ├── debounce / cancel obsolete intents ├── enforce single-flight └── schedule execution @@ -662,7 +662,7 @@ RebalanceExecutor **Contract:** *Every user access produces a rebalance intent.* -#### RebalanceIntentManager (Enhanced Role) +#### RebalanceIntentController (Enhanced Role) The IntentController ACTOR (implemented via `IntentController` + `IRebalanceExecutionController`) is the **orchestrator** responsible for: @@ -679,7 +679,7 @@ The IntentController ACTOR (implemented via `IntentController` + `IRebalanceExec #### RebalanceDecisionEngine (Clarified Role) -**Not a top-level actor** — internal tool of IntentManager/Executor pipeline. +**Not a top-level actor** — internal tool of IntentController/Executor pipeline. - ❌ Not visible to User Path - ✅ Invoked only in background diff --git a/docs/component-map.md b/docs/component-map.md index bf833e1..472f6cf 100644 --- a/docs/component-map.md +++ b/docs/component-map.md @@ -280,8 +280,8 @@ This section bridges architectural invariants (documented in [invariants.md](inv - **All-or-Nothing Updates**: Rematerialize operation succeeds completely or not at all **Source References**: -- `src/SlidingWindowCache/Infrastructure/Storage/ArrayCacheStorage.cs` - Array.Copy + Volatile.Write for atomic swap -- `src/SlidingWindowCache/Infrastructure/Storage/ListCacheStorage.cs` - List replacement + Volatile.Write +- `src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs` - Array.Copy + Volatile.Write for atomic swap +- `src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs` - List replacement + Volatile.Write - `src/SlidingWindowCache/Core/State/CacheState.cs` - Rematerialize method ensures atomicity ### Consistency Under Cancellation diff --git a/docs/scenario-model.md b/docs/scenario-model.md index e559a77..e916d29 100644 --- a/docs/scenario-model.md +++ b/docs/scenario-model.md @@ -82,12 +82,10 @@ The User Path is responsible only for: 2. Cache detects that it is not initialized 3. Cache requests RequestedRange from IDataSource in the user thread (this is unavoidable because the user request must be served) -4. Received data: - - is stored as CacheData - - CurrentCacheRange is set to RequestedRange - - LastRequestedRange is set to RequestedRange -5. Rebalance is triggered asynchronously (fire-and-forget background work) -6. Data is immediately returned to the user +4. A rebalance intent is published (fire-and-forget) with the fetched data +5. Data is immediately returned to the user +6. Rebalance execution (background) stores the data as CacheData, + sets CurrentCacheRange to RequestedRange, and sets LastRequestedRange to RequestedRange **Note:** The User Path does not expand the cache beyond RequestedRange. diff --git a/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs b/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs index d736f2e..d13f71d 100644 --- a/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs +++ b/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs @@ -68,8 +68,8 @@ public NoRebalanceRangePlanner(WindowCacheOptions options, TDomain domain) return cacheRange.ExpandByRatio( domain: _domain, - leftRatio: -(_options.LeftThreshold ?? 0), // Negate to shrink - rightRatio: -(_options.RightThreshold ?? 0) // Negate to shrink + leftRatio: -leftThreshold, // Negate to shrink + rightRatio: -rightThreshold // Negate to shrink ); } } diff --git a/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs b/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs index 5128809..9d4376e 100644 --- a/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs +++ b/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs @@ -44,7 +44,7 @@ namespace SlidingWindowCache.Core.Planning; /// E.33: Sliding window geometry is determined solely by configuration /// D.25, D.26: Analytical/pure (CPU-only), never mutates cache state /// -/// Related: (threshold calculation, when to rebalance logic) +/// Related: (threshold calculation, when to rebalance logic) /// See: for architectural overview. /// /// Type representing the boundaries of a window/range; must be comparable (see ) so intervals can be ordered and spanned. @@ -96,7 +96,7 @@ public ProportionalRangePlanner(WindowCacheOptions options, TDomain domain) /// Typical usage: Invoked during Stage 3 of the rebalance decision pipeline by RebalanceDecisionEngine.Evaluate(), which runs in the background intent processing loop (IntentController.ProcessIntentsAsync). Executes after stability checks (Stages 1-2) and before equality validation (Stage 4). /// /// See also: - /// + /// /// /// /// diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecision.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecision.cs index b9a0f02..3e4bc65 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecision.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecision.cs @@ -2,32 +2,6 @@ namespace SlidingWindowCache.Core.Rebalance.Decision; -/// -/// Specifies the reason for a rebalance decision outcome. -/// -internal enum RebalanceReason -{ - /// - /// Request falls within the current cache's no-rebalance range (Stage 1 stability). - /// - WithinCurrentNoRebalanceRange, - - /// - /// Request falls within the pending rebalance's desired no-rebalance range (Stage 2 stability). - /// - WithinPendingNoRebalanceRange, - - /// - /// Desired cache range equals current cache range (Stage 4 short-circuit). - /// - DesiredEqualsCurrent, - - /// - /// Rebalance is required to satisfy the request (Stage 5 execution). - /// - RebalanceRequired -} - /// /// Represents the result of a rebalance decision evaluation. /// @@ -38,7 +12,7 @@ internal readonly struct RebalanceDecision /// /// Gets a value indicating whether rebalance execution should proceed. /// - public bool ShouldSchedule { get; } + public bool IsExecutionRequired { get; } /// /// Gets the desired cache range if execution is allowed, otherwise null. @@ -61,7 +35,7 @@ private RebalanceDecision( Range? desiredNoRebalanceRange, RebalanceReason reason) { - ShouldSchedule = shouldSchedule; + IsExecutionRequired = shouldSchedule; DesiredRange = desiredRange; DesiredNoRebalanceRange = desiredNoRebalanceRange; Reason = reason; diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs index 0884da4..925bc67 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs @@ -1,4 +1,4 @@ -using Intervals.NET; +using Intervals.NET; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Planning; using SlidingWindowCache.Core.Rebalance.Execution; @@ -41,12 +41,12 @@ internal sealed class RebalanceDecisionEngine where TRange : IComparable where TDomain : IRangeDomain { - private readonly ThresholdRebalancePolicy _policy; + private readonly NoRebalanceSatisfactionPolicy _policy; private readonly ProportionalRangePlanner _planner; private readonly NoRebalanceRangePlanner _noRebalancePlanner; public RebalanceDecisionEngine( - ThresholdRebalancePolicy policy, + NoRebalanceSatisfactionPolicy policy, ProportionalRangePlanner planner, NoRebalanceRangePlanner noRebalancePlanner) { @@ -60,8 +60,9 @@ public RebalanceDecisionEngine( /// This is the SOLE AUTHORITY for rebalance necessity determination. /// /// The range requested by the user. - /// The current cache state snapshot. - /// The last rebalance execution request, if any. + /// The no-rebalance range of the current cache state, or null if none. + /// The range currently covered by the cache. + /// The desired no-rebalance range of the last pending execution request, or null if none. /// A decision indicating whether to schedule rebalance with explicit reasoning. /// /// Multi-Stage Validation Pipeline: @@ -70,15 +71,16 @@ public RebalanceDecisionEngine( /// All stages must confirm necessity before rebalance is scheduled. /// /// - public RebalanceDecision Evaluate( + public RebalanceDecision Evaluate( Range requestedRange, - CacheState currentCacheState, - ExecutionRequest? lastExecutionRequest) + Range? currentNoRebalanceRange, + Range currentCacheRange, + Range? pendingNoRebalanceRange) { // Stage 1: Current Cache Stability Check (fast path) // If requested range is fully contained within current NoRebalanceRange, skip rebalancing - if (currentCacheState.NoRebalanceRange.HasValue && - !_policy.ShouldRebalance(currentCacheState.NoRebalanceRange.Value, requestedRange)) + if (currentNoRebalanceRange.HasValue && + !_policy.ShouldRebalance(currentNoRebalanceRange.Value, requestedRange)) { return RebalanceDecision.Skip(RebalanceReason.WithinCurrentNoRebalanceRange); } @@ -86,8 +88,8 @@ public RebalanceDecision Evaluate( // Stage 2: Pending Rebalance Stability Check (anti-thrashing) // If there's a pending rebalance AND requested range will be covered by its NoRebalanceRange, // skip scheduling a new rebalance to avoid cancellation storms - if (lastExecutionRequest?.DesiredNoRebalanceRange != null && - !_policy.ShouldRebalance(lastExecutionRequest.DesiredNoRebalanceRange.Value, requestedRange)) + if (pendingNoRebalanceRange.HasValue && + !_policy.ShouldRebalance(pendingNoRebalanceRange.Value, requestedRange)) { return RebalanceDecision.Skip(RebalanceReason.WithinPendingNoRebalanceRange); } @@ -99,7 +101,6 @@ public RebalanceDecision Evaluate( // Stage 4: Equality Short Circuit // If desired range matches current cache range, no mutation needed - var currentCacheRange = currentCacheState.Cache.Range; if (desiredCacheRange.Equals(currentCacheRange)) { return RebalanceDecision.Skip(RebalanceReason.DesiredEqualsCurrent); diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceReason.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceReason.cs new file mode 100644 index 0000000..8bcb80a --- /dev/null +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceReason.cs @@ -0,0 +1,27 @@ +namespace SlidingWindowCache.Core.Rebalance.Decision; + +/// +/// Specifies the reason for a rebalance decision outcome. +/// +internal enum RebalanceReason +{ + /// + /// Request falls within the current cache's no-rebalance range (Stage 1 stability). + /// + WithinCurrentNoRebalanceRange, + + /// + /// Request falls within the pending rebalance's desired no-rebalance range (Stage 2 stability). + /// + WithinPendingNoRebalanceRange, + + /// + /// Desired cache range equals current cache range (Stage 4 short-circuit). + /// + DesiredEqualsCurrent, + + /// + /// Rebalance is required to satisfy the request (Stage 5 execution). + /// + RebalanceRequired +} diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs index 52a8997..b561aa8 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs @@ -9,18 +9,17 @@ namespace SlidingWindowCache.Core.Rebalance.Decision; /// . /// /// The type representing the range boundaries. -/// The type representing the domain of the ranges. /// /// Role: Rebalance Policy - Decision Evaluation /// Responsibility: Determine if a requested range violates the no-rebalance zone /// Characteristics: Pure function, stateless /// Execution Context: Background thread (intent processing loop) /// -/// Invoked by during Stages 1-2 (stability validation), +/// Invoked by during Stages 1-2 (stability validation), /// which executes in the background intent processing loop (see IntentController.ProcessIntentsAsync). /// /// -internal readonly struct ThresholdRebalancePolicy +internal readonly struct NoRebalanceSatisfactionPolicy where TRange : IComparable { /// @@ -32,4 +31,4 @@ internal readonly struct ThresholdRebalancePolicy /// True if rebalancing should occur (request is outside no-rebalance zone); otherwise false. public bool ShouldRebalance(Range noRebalanceRange, Range requested) => !noRebalanceRange.Contains(requested); -} \ 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 4d33b8d..cc90bc6 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs @@ -59,7 +59,7 @@ ICacheDiagnostics cacheDiagnostics /// /// The current cached data. /// The requested range that needs to be covered by the cache. - /// Cancellation token. + /// Cancellation token. /// /// Extended cache containing all existing data plus newly fetched data to cover the requested range. /// @@ -82,20 +82,30 @@ ICacheDiagnostics cacheDiagnostics public async Task> ExtendCacheAsync( RangeData currentCache, Range requested, - CancellationToken ct + CancellationToken cancellationToken ) { _cacheDiagnostics.DataSourceFetchMissingSegments(); - // Step 1: Calculate which ranges are missing - var missingRanges = CalculateMissingRanges(currentCache.Range, requested); + // Step 1: Calculate which ranges are missing (and record the expansion/replacement diagnostic) + var missingRanges = CalculateMissingRanges(currentCache.Range, requested, out bool isCacheExpanded); - // Step 2: Fetch the missing data from data source - var fetchedResults = await _dataSource.FetchAsync(missingRanges, ct) + // Step 2: Record the diagnostic event here (caller context), not inside the pure helper + if (isCacheExpanded) + { + _cacheDiagnostics.CacheExpanded(); + } + else + { + _cacheDiagnostics.CacheReplaced(); + } + + // Step 3: Fetch the missing data from data source + var fetchedResults = await _dataSource.FetchAsync(missingRanges, cancellationToken) .ConfigureAwait(false); - // Step 3: Union fetched data with current cache (UnionAll will filter null ranges) - return UnionAll(currentCache, fetchedResults, _domain); + // Step 4: Union fetched data with current cache (UnionAll will filter null ranges) + return UnionAll(currentCache, fetchedResults); } /// @@ -104,25 +114,30 @@ CancellationToken ct /// /// The range currently covered by the cache. /// The range that needs to be covered. + /// + /// Set to when the existing cache overlaps with the requested range + /// (expansion case); when there is no overlap (replacement case). + /// /// - /// An enumerable of missing ranges that need to be fetched, or null if there's no intersection - /// (meaning the entire requested range needs to be fetched). + /// An enumerable of missing ranges that need to be fetched, or the full requested range + /// when there is no intersection (meaning the entire requested range needs to be fetched). /// - private IEnumerable> CalculateMissingRanges( + private static IEnumerable> CalculateMissingRanges( Range currentRange, - Range requestedRange + Range requestedRange, + out bool isCacheExpanded ) { var intersection = currentRange.Intersect(requestedRange); if (intersection.HasValue) { - _cacheDiagnostics.CacheExpanded(); + isCacheExpanded = true; // Calculate the missing segments using range subtraction return requestedRange.Except(intersection.Value); } - _cacheDiagnostics.CacheReplaced(); + isCacheExpanded = false; // No overlap - indicate that entire requested range is missing // This signals to fetch the whole requested range without trying to calculate missing segments, as they are all missing. return [requestedRange]; @@ -147,8 +162,7 @@ Range requestedRange /// private RangeData UnionAll( RangeData current, - IEnumerable> rangeChunks, - TDomain domain + IEnumerable> rangeChunks ) { // Combine existing data with fetched data @@ -165,7 +179,7 @@ TDomain domain // 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(chunk.Data.ToRangeData(chunk.Range!.Value, 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 6106da7..cf2e2d7 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs @@ -245,7 +245,7 @@ public async ValueTask PublishExecutionRequest( desiredNoRebalanceRange, cancellationTokenSource ); - Interlocked.Exchange(ref _lastExecutionRequest, request); + Volatile.Write(ref _lastExecutionRequest, request); // Enqueue execution request to bounded channel // BACKPRESSURE: This will await if channel is at capacity, creating backpressure on intent processing loop @@ -302,8 +302,10 @@ private async Task ProcessExecutionRequestsAsync() { _cacheDiagnostics.RebalanceExecutionStarted(); - var (intent, desiredRange, desiredNoRebalanceRange, cancellationTokenSource) = request; - var cancellationToken = cancellationTokenSource.Token; + var intent = request.Intent; + var desiredRange = request.DesiredRange; + var desiredNoRebalanceRange = request.DesiredNoRebalanceRange; + var cancellationToken = request.CancellationToken; try { @@ -313,6 +315,11 @@ await Task.Delay(_debounceDelay, cancellationToken) .ConfigureAwait(false); // Step 2: Check cancellation after debounce - avoid wasted I/O work + // NOTE: We check IsCancellationRequested explicitly here rather than relying solely on the + // OperationCanceledException catch below. Task.Delay can complete normally just as cancellation + // is signalled (a race), so we may reach here with cancellation requested but no exception thrown. + // This explicit check provides a clean diagnostic event path (RebalanceExecutionCancelled) for + // that case, separate from the exception-based cancellation path in the catch block below. if (cancellationToken.IsCancellationRequested) { _cacheDiagnostics.RebalanceExecutionCancelled(); @@ -385,7 +392,7 @@ public async ValueTask DisposeAsync() return; // Already disposed } - _lastExecutionRequest?.Cancel(); + Volatile.Read(ref _lastExecutionRequest)?.Cancel(); // Complete the channel - signals execution loop to exit after current operation _executionChannel.Writer.Complete(); @@ -404,6 +411,6 @@ public async ValueTask DisposeAsync() } // Dispose last execution request if present - _lastExecutionRequest?.Dispose(); + Volatile.Read(ref _lastExecutionRequest)?.Dispose(); } } diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/ExecutionRequest.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/ExecutionRequest.cs index a15c974..975ad3f 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/ExecutionRequest.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/ExecutionRequest.cs @@ -15,8 +15,9 @@ namespace SlidingWindowCache.Core.Rebalance.Execution; /// Architectural Role: /// /// This record encapsulates the validated rebalance decision from IntentController and carries it -/// through the execution pipeline. It includes the CancellationTokenSource for cancellation coordination -/// when superseded by newer rebalance requests. +/// through the execution pipeline. It owns a (held as a private +/// field) and exposes only the derived to consumers, ensuring that +/// only this class controls cancellation and disposal of the token source. /// /// Lifecycle: /// @@ -29,18 +30,54 @@ namespace SlidingWindowCache.Core.Rebalance.Execution; /// Thread Safety: /// /// The Cancel() and Dispose() methods are designed to be safe for multiple calls and handle -/// disposal races gracefully by catching and ignoring exceptions. +/// disposal races gracefully by catching and ignoring ObjectDisposedException. /// /// -internal record ExecutionRequest( - Intent Intent, - Range DesiredRange, - Range? DesiredNoRebalanceRange, - CancellationTokenSource CancellationTokenSource -) +internal sealed class ExecutionRequest : IDisposable where TRange : IComparable where TDomain : IRangeDomain { + private readonly CancellationTokenSource _cts; + + /// + /// The rebalance intent that triggered this execution request. + /// + public Intent Intent { get; } + + /// + /// The desired cache range for this rebalance operation. + /// + public Range DesiredRange { get; } + + /// + /// The desired no-rebalance range for this rebalance operation, or null if not applicable. + /// + public Range? DesiredNoRebalanceRange { get; } + + /// + /// The cancellation token for this execution request. Cancelled when superseded or disposed. + /// + public CancellationToken CancellationToken => _cts.Token; + + /// + /// Initializes a new execution request with the specified intent, ranges, and cancellation token source. + /// + /// The rebalance intent that triggered this request. + /// The desired cache range. + /// The desired no-rebalance range, or null. + /// The cancellation token source owned by this request. + public ExecutionRequest( + Intent intent, + Range desiredRange, + Range? desiredNoRebalanceRange, + CancellationTokenSource cts) + { + Intent = intent; + DesiredRange = desiredRange; + DesiredNoRebalanceRange = desiredNoRebalanceRange; + _cts = cts; + } + /// /// Cancels this execution request by cancelling its CancellationTokenSource. /// Safe to call multiple times and handles disposal races gracefully. @@ -53,7 +90,7 @@ CancellationTokenSource CancellationTokenSource /// /// Exception Handling: /// - /// Catches and ignores all exceptions to handle disposal races gracefully. + /// Catches and ignores ObjectDisposedException to handle disposal races gracefully. /// This follows the "best-effort cancellation" pattern for background operations. /// /// @@ -61,12 +98,11 @@ public void Cancel() { try { - CancellationTokenSource.Cancel(); + _cts.Cancel(); } - catch + catch (ObjectDisposedException) { - // Ignore disposal errors - cancellation is best-effort - // If CancellationTokenSource is already disposed, we don't care + // CancellationTokenSource already disposed - cancellation is best-effort } } @@ -82,7 +118,7 @@ public void Cancel() /// /// Exception Handling: /// - /// Catches and ignores all exceptions to ensure cleanup always completes without + /// Catches and ignores ObjectDisposedException to ensure cleanup always completes without /// propagating exceptions during disposal. /// /// @@ -90,11 +126,11 @@ public void Dispose() { try { - CancellationTokenSource.Dispose(); + _cts.Dispose(); } - catch + catch (ObjectDisposedException) { - // Ignore disposal errors - best-effort cleanup + // Already disposed - best-effort cleanup } } } diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/IRebalanceExecutionController.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/IRebalanceExecutionController.cs index 7ec7b30..f184c39 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/IRebalanceExecutionController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/IRebalanceExecutionController.cs @@ -65,7 +65,7 @@ namespace SlidingWindowCache.Core.Rebalance.Execution; /// with execution controllers - requests flow through IntentController after validation. /// /// -internal interface IRebalanceExecutionController +internal interface IRebalanceExecutionController : IAsyncDisposable where TRange : IComparable where TDomain : IRangeDomain { @@ -129,31 +129,4 @@ ValueTask PublishExecutionRequest( /// /// ExecutionRequest? LastExecutionRequest { get; } - - /// - /// Disposes the execution controller and releases all managed resources. - /// Gracefully shuts down execution processing and waits for completion. - /// - /// A ValueTask representing the asynchronous disposal operation. - /// - /// Disposal Behavior (All Implementations): - /// - /// Mark as disposed (prevent new execution requests) - /// Cancel any pending execution requests - /// Complete/signal the serialization mechanism (channel/task chain) - /// Wait for current execution to complete gracefully - /// Clean up resources (CancellationTokenSource, etc.) - /// - /// Thread Safety: - /// - /// All implementations must be idempotent and thread-safe. Multiple concurrent disposal - /// calls should result in only one actual disposal operation. - /// - /// Graceful Shutdown: - /// - /// No timeout is enforced per architectural decision. Disposal waits for current execution - /// to complete naturally (typically milliseconds). Cancellation signals early exit. - /// - /// - ValueTask DisposeAsync(); } diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs index 4dd392f..ae8a7f2 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs @@ -82,7 +82,7 @@ public async Task ExecuteAsync( CancellationToken cancellationToken) { // Use delivered data as the base - this is what the user received - var baseRangeData = intent.AvailableRangeData; + var baseRangeData = intent.AssembledRangeData; // Cancellation check before expensive I/O // Satisfies Invariant 34a: "Rebalance Execution MUST yield to User Path requests immediately" @@ -98,40 +98,15 @@ public async Task ExecuteAsync( cancellationToken.ThrowIfCancellationRequested(); // Phase 2: Trim to desired range (rebalancing-specific: discard data outside desired range) - baseRangeData = extended[desiredRange]; + var normalizedData = extended[desiredRange]; // Final cancellation check before applying mutation // Ensures we don't apply obsolete rebalance results cancellationToken.ThrowIfCancellationRequested(); - // Phase 3: Apply cache state mutations - UpdateCacheState(baseRangeData, intent.RequestedRange, desiredNoRebalanceRange); + // Phase 3: Apply cache state mutations (single writer — all fields updated atomically) + _state.UpdateCacheState(normalizedData, intent.RequestedRange, desiredNoRebalanceRange); _cacheDiagnostics.RebalanceExecutionCompleted(); } - - /// - /// Updates cache state with rebalanced data. This is the ONLY location where cache mutations occur. - /// SINGLE-WRITER: Only Rebalance Execution writes to cache state. - /// - /// The normalized data to write to cache. - /// The original range requested by the user, used to update LastRequested field. - /// The pre-computed no-rebalance range for the target state. - private void UpdateCacheState( - RangeData normalizedData, - Range requestedRange, - Range? desiredNoRebalanceRange) - { - // Phase 1: Update the cache with the rebalanced data (atomic mutation) - // SINGLE-WRITER: This is the ONLY place where cache state is written - _state.Cache.Rematerialize(normalizedData); - - // Phase 2: Update LastRequested to the original user's requested range - // SINGLE-WRITER: Only Rebalance Execution writes to LastRequested - _state.LastRequested = requestedRange; - - // Phase 3: Update the no-rebalance range using pre-computed value from DecisionEngine - // SINGLE-WRITER: Only Rebalance Execution writes to NoRebalanceRange - _state.NoRebalanceRange = desiredNoRebalanceRange; - } } \ No newline at end of file diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs index 166b4d8..31faf07 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs @@ -224,7 +224,7 @@ public ValueTask PublishExecutionRequest( // Chain execution to previous task (lock-free using volatile write - single-writer context) // Read current task, create new chained task, and update atomically - var previousTask = _currentExecutionTask; + var previousTask = Volatile.Read(ref _currentExecutionTask); var newTask = ChainExecutionAsync(previousTask, request); Volatile.Write(ref _currentExecutionTask, newTask); @@ -309,8 +309,10 @@ private async Task ExecuteRequestAsync(ExecutionRequest { _cacheDiagnostics.RebalanceExecutionStarted(); - var (intent, desiredRange, desiredNoRebalanceRange, cancellationTokenSource) = request; - var cancellationToken = cancellationTokenSource.Token; + var intent = request.Intent; + var desiredRange = request.DesiredRange; + var desiredNoRebalanceRange = request.DesiredNoRebalanceRange; + var cancellationToken = request.CancellationToken; try { @@ -320,6 +322,11 @@ await Task.Delay(_debounceDelay, cancellationToken) .ConfigureAwait(false); // Step 2: Check cancellation after debounce - avoid wasted I/O work + // NOTE: We check IsCancellationRequested explicitly here rather than relying solely on the + // OperationCanceledException catch below. Task.Delay can complete normally just as cancellation + // is signalled (a race), so we may reach here with cancellation requested but no exception thrown. + // This explicit check provides a clean diagnostic event path (RebalanceExecutionCancelled) for + // that case, separate from the exception-based cancellation path in the catch block below. if (cancellationToken.IsCancellationRequested) { _cacheDiagnostics.RebalanceExecutionCancelled(); @@ -398,7 +405,7 @@ public async ValueTask DisposeAsync() } // Cancel last execution request (signals early exit) - _lastExecutionRequest?.Cancel(); + Volatile.Read(ref _lastExecutionRequest)?.Cancel(); // Capture current task chain reference (volatile read - no lock needed) var currentTask = Volatile.Read(ref _currentExecutionTask); @@ -417,6 +424,6 @@ public async ValueTask DisposeAsync() } // Dispose last execution request if present - _lastExecutionRequest?.Dispose(); + Volatile.Read(ref _lastExecutionRequest)?.Dispose(); } } diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/Intent.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/Intent.cs new file mode 100644 index 0000000..f7b4a48 --- /dev/null +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/Intent.cs @@ -0,0 +1,30 @@ +using Intervals.NET; +using Intervals.NET.Data; +using Intervals.NET.Domain.Abstractions; + +namespace SlidingWindowCache.Core.Rebalance.Intent; + +/// +/// Represents the intent to rebalance the cache based on a requested range and the currently assembled range data. +/// +/// +/// The range requested by the user that triggered the rebalance evaluation. This is the range for which the user is seeking data. +/// +/// +/// The current range of data available in the cache along with its associated data and domain information. This represents the state of the cache before any rebalance execution. +/// +/// +/// The type representing the range boundaries. Must implement to allow for range comparisons and calculations. +/// +/// +/// The type of data being cached. This is the type of the elements stored within the ranges in the cache. +/// +/// +/// The type representing the domain of the ranges. Must implement to provide necessary domain-specific operations for range calculations and validations. +/// +internal record Intent( + Range RequestedRange, + RangeData AssembledRangeData +) + where TRange : IComparable + where TDomain : IRangeDomain; diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index dbf2ffc..fdaf2c8 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -1,5 +1,4 @@ using Intervals.NET; -using Intervals.NET.Data; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.Rebalance.Execution; @@ -9,31 +8,6 @@ namespace SlidingWindowCache.Core.Rebalance.Intent; -/// -/// Represents the intent to rebalance the cache based on a requested range and the currently available range data. -/// -/// -/// The range requested by the user that triggered the rebalance evaluation. This is the range for which the user is seeking data. -/// -/// -/// The current range of data available in the cache along with its associated data and domain information. This represents the state of the cache before any rebalance execution. -/// -/// -/// The type representing the range boundaries. Must implement to allow for range comparisons and calculations. -/// -/// -/// The type of data being cached. This is the type of the elements stored within the ranges in the cache. -/// -/// -/// The type representing the domain of the ranges. Must implement to provide necessary domain-specific operations for range calculations and validations. -/// -public record Intent( - Range RequestedRange, - RangeData AvailableRangeData -) - where TRange : IComparable - where TDomain : IRangeDomain; - /// /// Manages the lifecycle of rebalance intents using a single-threaded loop with burst resistance. /// This is the IntentController actor - fast, CPU-bound decision and coordination logic. @@ -240,15 +214,16 @@ private async Task ProcessIntentsAsync() var lastExecutionRequest = _executionController.LastExecutionRequest; var decision = _decisionEngine.Evaluate( requestedRange: intent.RequestedRange, - currentCacheState: _state, - lastExecutionRequest: lastExecutionRequest + currentNoRebalanceRange: _state.NoRebalanceRange, + currentCacheRange: _state.Storage.Range, + pendingNoRebalanceRange: lastExecutionRequest?.DesiredNoRebalanceRange ); // Record decision reason for observability - RecordReason(decision.Reason); + RecordDecisionOutcome(decision.Reason); // If decision says skip, continue (decrement happens in finally) - if (!decision.ShouldSchedule) + if (!decision.IsExecutionRequired) { continue; } @@ -295,7 +270,7 @@ await _executionController.PublishExecutionRequest( /// Records the skip reason for diagnostic and observability purposes. /// Maps decision reasons to diagnostic events. /// - private void RecordReason(RebalanceReason reason) + private void RecordDecisionOutcome(RebalanceReason reason) { switch (reason) { @@ -341,7 +316,7 @@ private void RecordReason(RebalanceReason reason) /// but do not prevent subsequent cleanup steps. /// /// - internal async ValueTask DisposeAsync() + public async ValueTask DisposeAsync() { // Idempotent check using lock-free Interlocked.CompareExchange if (Interlocked.CompareExchange(ref _disposeState, 1, 0) != 0) diff --git a/src/SlidingWindowCache/Core/State/CacheState.cs b/src/SlidingWindowCache/Core/State/CacheState.cs index e1e223c..ea8a2f1 100644 --- a/src/SlidingWindowCache/Core/State/CacheState.cs +++ b/src/SlidingWindowCache/Core/State/CacheState.cs @@ -1,4 +1,4 @@ -using Intervals.NET; +using Intervals.NET; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Infrastructure.Storage; using SlidingWindowCache.Public; @@ -19,6 +19,15 @@ namespace SlidingWindowCache.Core.State; /// /// The type representing the domain of the ranges. Must implement . /// +/// +/// Single-Writer Architecture: +/// +/// All mutations to this state MUST go through which is the +/// sole method that writes to the three mutable fields. This enforces the Single-Writer invariant: +/// only Rebalance Execution (via RebalanceExecutor) may mutate cache state. +/// The User Path is strictly read-only with respect to all fields on this class. +/// +/// internal sealed class CacheState where TRange : IComparable where TDomain : IRangeDomain @@ -26,26 +35,26 @@ internal sealed class CacheState /// /// The current cached data along with its range. /// - public ICacheStorage Cache { get; } + public ICacheStorage Storage { get; } /// /// The last requested range that triggered a cache access. /// /// - /// SINGLE-WRITER: Only Rebalance Execution Path may write to this field. + /// SINGLE-WRITER: Only Rebalance Execution Path may write to this field, via . /// User Path is read-only with respect to cache state. /// - public Range? LastRequested { get; internal set; } + public Range? LastRequested { get; private set; } /// /// The range within which no rebalancing should occur. /// It is based on configured threshold policies. /// /// - /// SINGLE-WRITER: Only Rebalance Execution Path may write to this field. + /// SINGLE-WRITER: Only Rebalance Execution Path may write to this field, via . /// This field is recomputed after each successful rebalance execution. /// - public Range? NoRebalanceRange { get; internal set; } + public Range? NoRebalanceRange { get; private set; } /// /// Gets the domain defining the range characteristics for this cache instance. @@ -59,7 +68,32 @@ internal sealed class CacheState /// The domain defining the range characteristics. public CacheState(ICacheStorage cacheStorage, TDomain domain) { - Cache = cacheStorage; + Storage = cacheStorage; Domain = domain; } -} \ No newline at end of file + + /// + /// Applies a complete cache state mutation atomically. + /// This is the ONLY method that may write to the mutable fields on this class. + /// + /// The normalized range data to write into storage. + /// The original range requested by the user, stored as . + /// The pre-computed no-rebalance range for the new state. + /// + /// Single-Writer Contract: + /// + /// MUST only be called from Rebalance Execution context (i.e., RebalanceExecutor.UpdateCacheState). + /// The execution controller guarantees that no two rebalance executions run concurrently, + /// so no additional synchronization is needed here. + /// + /// + internal void UpdateCacheState( + Intervals.NET.Data.RangeData normalizedData, + Range requestedRange, + Range? noRebalanceRange) + { + Storage.Rematerialize(normalizedData); + LastRequested = requestedRange; + NoRebalanceRange = noRebalanceRange; + } +} diff --git a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs index 68503f1..9dbc2ce 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; @@ -133,7 +133,7 @@ public async ValueTask> HandleRequestAsync( } // Check if cache is cold (never used) - use ToRangeData to detect empty cache - var cacheStorage = _state.Cache; + var cacheStorage = _state.Storage; var isColdStart = !_state.LastRequested.HasValue; RangeData? assembledData = null; @@ -148,24 +148,8 @@ public async ValueTask> HandleRequestAsync( { // Scenario 1: Cold Start // Cache has never been populated - fetch data ONLY for requested range - _cacheDiagnostics.DataSourceFetchSingleRange(); - var fetchedChunk = await _dataSource.FetchAsync(requestedRange, cancellationToken); - - // 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; - } - + (assembledData, actualRange, resultData) = + await FetchSingleRangeAsync(requestedRange, cancellationToken).ConfigureAwait(false); _cacheDiagnostics.UserRequestFullCacheMiss(); } else @@ -199,7 +183,7 @@ public async ValueTask> HandleRequestAsync( cacheStorage.ToRangeData(), requestedRange, cancellationToken - ); + ).ConfigureAwait(false); _cacheDiagnostics.UserRequestPartialCacheHit(); @@ -226,26 +210,8 @@ public async ValueTask> HandleRequestAsync( // Scenario 4: Full Cache Miss (Non-intersecting Jump) // RequestedRange does NOT intersect CurrentCacheRange // Fetch ONLY the requested range from IDataSource - // NOTE: The logic is similar to cold start - _cacheDiagnostics.DataSourceFetchSingleRange(); - var fetchedChunk = await _dataSource.FetchAsync(requestedRange, cancellationToken) - .ConfigureAwait(false); - - // 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; - } - + (assembledData, actualRange, resultData) = + await FetchSingleRangeAsync(requestedRange, cancellationToken).ConfigureAwait(false); _cacheDiagnostics.UserRequestFullCacheMiss(); } } @@ -315,4 +281,43 @@ internal async ValueTask DisposeAsync() // Dispose intent controller (cascades to execution controller) await _intentController.DisposeAsync().ConfigureAwait(false); } + + /// + /// Fetches data for a single range directly from the data source, without involving the cache. + /// Used by Scenario 1 (cold start) and Scenario 4 (full cache miss / non-intersecting jump). + /// + /// The range to fetch. + /// A cancellation token to cancel the operation. + /// + /// A tuple of (assembledData, actualRange, resultData). assembledData is null and + /// actualRange is null when the data source reports no data is available for the range + /// (physical boundary miss). + /// + /// + /// Execution Context: User Thread (called from ) + /// + /// This helper centralises the fetch-and-materialise pattern shared by the cold-start and + /// full-miss scenarios. It emits the DataSourceFetchSingleRange diagnostic event and + /// handles the null-Range contract of . + /// + /// + private async ValueTask<(RangeData? assembledData, Range? actualRange, ReadOnlyMemory resultData)> + FetchSingleRangeAsync(Range requestedRange, CancellationToken cancellationToken) + { + _cacheDiagnostics.DataSourceFetchSingleRange(); + var fetchedChunk = await _dataSource.FetchAsync(requestedRange, cancellationToken) + .ConfigureAwait(false); + + // Handle boundary: chunk.Range may be null or truncated + if (fetchedChunk.Range.HasValue) + { + var assembledData = fetchedChunk.Data.ToRangeData(fetchedChunk.Range.Value, _state.Domain); + var actualRange = fetchedChunk.Range.Value; + var resultData = new ReadOnlyMemory(assembledData.Data.ToArray()); + return (assembledData, actualRange, resultData); + } + + // No data available for requested range (physical boundary miss) + return (null, null, ReadOnlyMemory.Empty); + } } \ No newline at end of file diff --git a/src/SlidingWindowCache/Infrastructure/Concurrency/AsyncActivityCounter.cs b/src/SlidingWindowCache/Infrastructure/Concurrency/AsyncActivityCounter.cs index 07efc5c..ee0ece2 100644 --- a/src/SlidingWindowCache/Infrastructure/Concurrency/AsyncActivityCounter.cs +++ b/src/SlidingWindowCache/Infrastructure/Concurrency/AsyncActivityCounter.cs @@ -190,8 +190,10 @@ public void DecrementActivity() // Sanity check - counter should never go negative if (newCount < 0) { - // This indicates a bug - decrement without matching increment - // Restore to 0 and throw to alert developers + // This indicates a bug: a DecrementActivity() call without a matching IncrementActivity(). + // Restore to 0 so the counter doesn't remain invalid, then throw unconditionally. + // Intentionally supersedes any in-flight exception: counter underflow is an unrecoverable + // logic fault and the root cause MUST be surfaced, even inside finally blocks or catch handlers. Interlocked.CompareExchange(ref _activityCount, 0, newCount); throw new InvalidOperationException( $"AsyncActivityCounter decremented below zero. This indicates unbalanced IncrementActivity/DecrementActivity calls."); diff --git a/src/SlidingWindowCache/Infrastructure/Extensions/IntervalsNetDomainExtensions.cs b/src/SlidingWindowCache/Infrastructure/Extensions/IntervalsNetDomainExtensions.cs index 337a361..a571bb9 100644 --- a/src/SlidingWindowCache/Infrastructure/Extensions/IntervalsNetDomainExtensions.cs +++ b/src/SlidingWindowCache/Infrastructure/Extensions/IntervalsNetDomainExtensions.cs @@ -36,7 +36,7 @@ internal static class IntervalsNetDomainExtensions /// /// Thrown when the domain does not implement either IFixedStepDomain or IVariableStepDomain. /// - public static RangeValue Span(this Range range, TDomain domain) + internal static RangeValue Span(this Range range, TDomain domain) where TRange : IComparable where TDomain : IRangeDomain => domain switch { @@ -64,7 +64,7 @@ public static RangeValue Span(this Range range, T /// /// Thrown when the domain does not implement either IFixedStepDomain or IVariableStepDomain. /// - public static Range Expand( + internal static Range Expand( this Range range, TDomain domain, long left, @@ -98,7 +98,7 @@ public static Range Expand( /// /// Thrown when the domain does not implement either IFixedStepDomain or IVariableStepDomain. /// - public static Range ExpandByRatio( + internal static Range ExpandByRatio( this Range range, TDomain domain, double leftRatio, diff --git a/src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs b/src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs index d2769ae..eb5a1d6 100644 --- a/src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs +++ b/src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs @@ -5,7 +5,7 @@ namespace SlidingWindowCache.Infrastructure.Instrumentation; /// /// Default implementation of that uses thread-safe counters to track cache events and metrics. /// -public class EventCounterCacheDiagnostics : ICacheDiagnostics +public sealed class EventCounterCacheDiagnostics : ICacheDiagnostics { private int _userRequestServed; private int _cacheExpanded; @@ -126,24 +126,24 @@ void ICacheDiagnostics.RebalanceExecutionFailed(Exception ex) /// public void Reset() { - _userRequestServed = 0; - _cacheExpanded = 0; - _cacheReplaced = 0; - _rebalanceIntentPublished = 0; - _rebalanceIntentCancelled = 0; - _rebalanceExecutionStarted = 0; - _rebalanceExecutionCompleted = 0; - _rebalanceExecutionCancelled = 0; - _rebalanceSkippedCurrentNoRebalanceRange = 0; - _rebalanceSkippedPendingNoRebalanceRange = 0; - _rebalanceSkippedSameRange = 0; - _rebalanceScheduled = 0; - _userRequestFullCacheHit = 0; - _userRequestPartialCacheHit = 0; - _userRequestFullCacheMiss = 0; - _dataSourceFetchSingleRange = 0; - _dataSourceFetchMissingSegments = 0; - _dataSegmentUnavailable = 0; - _rebalanceExecutionFailed = 0; + Volatile.Write(ref _userRequestServed, 0); + Volatile.Write(ref _cacheExpanded, 0); + Volatile.Write(ref _cacheReplaced, 0); + Volatile.Write(ref _rebalanceIntentPublished, 0); + Volatile.Write(ref _rebalanceIntentCancelled, 0); + Volatile.Write(ref _rebalanceExecutionStarted, 0); + Volatile.Write(ref _rebalanceExecutionCompleted, 0); + Volatile.Write(ref _rebalanceExecutionCancelled, 0); + Volatile.Write(ref _rebalanceSkippedCurrentNoRebalanceRange, 0); + Volatile.Write(ref _rebalanceSkippedPendingNoRebalanceRange, 0); + Volatile.Write(ref _rebalanceSkippedSameRange, 0); + Volatile.Write(ref _rebalanceScheduled, 0); + Volatile.Write(ref _userRequestFullCacheHit, 0); + Volatile.Write(ref _userRequestPartialCacheHit, 0); + Volatile.Write(ref _userRequestFullCacheMiss, 0); + Volatile.Write(ref _dataSourceFetchSingleRange, 0); + Volatile.Write(ref _dataSourceFetchMissingSegments, 0); + Volatile.Write(ref _dataSegmentUnavailable, 0); + Volatile.Write(ref _rebalanceExecutionFailed, 0); } } \ No newline at end of file diff --git a/src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs b/src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs index 227c761..211d7d3 100644 --- a/src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs +++ b/src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs @@ -3,7 +3,7 @@ /// /// No-op implementation of ICacheDiagnostics for production use where performance is critical and diagnostics are not needed. /// -public class NoOpDiagnostics : ICacheDiagnostics +public sealed class NoOpDiagnostics : ICacheDiagnostics { /// public void CacheExpanded() diff --git a/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs b/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs index 19cc58f..ca23f86 100644 --- a/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs +++ b/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs @@ -4,7 +4,6 @@ using Intervals.NET.Domain.Abstractions; using Intervals.NET.Extensions; using SlidingWindowCache.Infrastructure.Extensions; -using SlidingWindowCache.Public.Configuration; namespace SlidingWindowCache.Infrastructure.Storage; @@ -84,9 +83,6 @@ public CopyOnReadStorage(TDomain domain) _domain = domain; } - /// - public UserCacheReadMode Mode => UserCacheReadMode.CopyOnRead; - /// public Range Range { get; private set; } @@ -125,6 +121,35 @@ public void Rematerialize(RangeData rangeData) // Atomically swap buffers: staging becomes active, old active becomes staging for next use // This swap is the only point where active storage is replaced, satisfying Invariant B.12 (atomic changes) + // + // TODO: Known race condition between Read() and Rematerialize(): + // Read() and Rematerialize() can overlap — Read() is called on the User thread while Rematerialize() + // runs on the Rebalance Execution thread. The tuple-swap here ( (_activeStorage, _stagingBuffer) = ... ) + // is NOT atomic at the CPU level: it is two separate field writes. A concurrent Read() could observe + // _activeStorage mid-swap, seeing the new reference for _activeStorage but old data for Range (or vice versa). + // This is a real data race. + // + // All known fix approaches share the same fundamental race: + // - Making _activeStorage volatile only fixes visibility, not atomicity of the two-field update. + // - Wrapping both fields in a single holder object and replacing the holder via Interlocked.Exchange + // would achieve atomic reference replacement, but at the cost of allocating a new holder object on + // every Rematerialize() call and losing the buffer-reuse advantage that is CopyOnReadStorage's + // primary design goal. + // - Using a lock on both Read() and Rematerialize() would be correct but violates the lock-free + // User Path requirement (Invariant A.2: User Path must never block on rebalance operations). + // + // The race is accepted as-is because: + // 1. WindowCache is designed for a single logical consumer (single coherent access pattern). + // The User thread and Rebalance thread have an implicit ordering: Rematerialize() is only + // called after data is fetched, which only happens after a user request triggers a rebalance. + // 2. In practice, the window of inconsistency is sub-microsecond (two field writes), and the + // observable effect (slightly stale data on one read) is within the "smart eventual consistency" + // model that WindowCache guarantees. + // 3. Fixing it cleanly would require either accepting per-Rematerialize allocations (defeating + // CopyOnReadStorage's purpose) or violating the lock-free User Path invariant. + // + // If strict atomic swap is required, use SnapshotReadStorage instead (allocates a new array per + // Rematerialize but achieves safe reference replacement via a single field write to _data). (_activeStorage, _stagingBuffer) = (_stagingBuffer, _activeStorage); // Update range to reflect new active storage (part of atomic change) @@ -182,7 +207,7 @@ public ReadOnlyMemory Read(Range range) /// /// /// - /// Returns a representing + /// Returns a representing /// the current active storage. The returned data is a lazy enumerable over the active list. /// /// diff --git a/src/SlidingWindowCache/Infrastructure/Storage/ICacheStorage.cs b/src/SlidingWindowCache/Infrastructure/Storage/ICacheStorage.cs index df50873..7cbc5c2 100644 --- a/src/SlidingWindowCache/Infrastructure/Storage/ICacheStorage.cs +++ b/src/SlidingWindowCache/Infrastructure/Storage/ICacheStorage.cs @@ -1,7 +1,6 @@ using Intervals.NET; using Intervals.NET.Data; using Intervals.NET.Domain.Abstractions; -using SlidingWindowCache.Public.Configuration; namespace SlidingWindowCache.Infrastructure.Storage; @@ -25,11 +24,6 @@ internal interface ICacheStorage where TRange : IComparable where TDomain : IRangeDomain { - /// - /// Gets the read mode this strategy implements. - /// - UserCacheReadMode Mode { get; } - /// /// Gets the current range of data stored in internal storage. /// diff --git a/src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs b/src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs index 934d958..843bc56 100644 --- a/src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs +++ b/src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs @@ -2,8 +2,8 @@ using Intervals.NET.Data; using Intervals.NET.Data.Extensions; using Intervals.NET.Domain.Abstractions; +using Intervals.NET.Extensions; using SlidingWindowCache.Infrastructure.Extensions; -using SlidingWindowCache.Public.Configuration; namespace SlidingWindowCache.Infrastructure.Storage; @@ -37,9 +37,6 @@ public SnapshotReadStorage(TDomain domain) _domain = domain; } - /// - public UserCacheReadMode Mode => UserCacheReadMode.Snapshot; - /// public Range Range { get; private set; } diff --git a/src/SlidingWindowCache/Public/Dto/RangeChunk.cs b/src/SlidingWindowCache/Public/Dto/RangeChunk.cs index c239cee..a65d5d0 100644 --- a/src/SlidingWindowCache/Public/Dto/RangeChunk.cs +++ b/src/SlidingWindowCache/Public/Dto/RangeChunk.cs @@ -5,8 +5,8 @@ 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. /// -/// The type representing range boundaries. -/// The type of data elements. +/// 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). @@ -28,5 +28,5 @@ namespace SlidingWindowCache.Public.Dto; /// // Request [600..700] → Return RangeChunk(null, empty enumerable) /// /// -public record RangeChunk(Range? Range, IEnumerable Data) - where TRangeType : IComparable; \ No newline at end of file +public sealed record RangeChunk(Range? Range, IEnumerable Data) + where TRange : IComparable; \ No newline at end of file diff --git a/src/SlidingWindowCache/Public/IDataSource.cs b/src/SlidingWindowCache/Public/IDataSource.cs index 0d545f4..28ebb44 100644 --- a/src/SlidingWindowCache/Public/IDataSource.cs +++ b/src/SlidingWindowCache/Public/IDataSource.cs @@ -8,10 +8,10 @@ namespace SlidingWindowCache.Public; /// Implementations must provide a method to fetch data for a single range. /// The batch fetching method has a default implementation that can be overridden for optimization. /// -/// +/// /// The type representing range boundaries. Must implement . /// -/// +/// /// The type of data being fetched. /// /// @@ -52,7 +52,7 @@ namespace SlidingWindowCache.Public; /// } /// /// -public interface IDataSource where TRangeType : IComparable +public interface IDataSource where TRange : IComparable { /// /// Fetches data for the specified range asynchronously. @@ -65,7 +65,7 @@ public interface IDataSource where TRangeType : IComparab /// /// /// A task that represents the asynchronous fetch operation. - /// The task result contains an enumerable of data of type + /// The task result contains an enumerable of data of type /// for the specified range. /// /// @@ -104,8 +104,8 @@ public interface IDataSource where TRangeType : IComparab /// /// See documentation on boundary handling for detailed guidance. /// - Task> FetchAsync( - Range range, + Task> FetchAsync( + Range range, CancellationToken cancellationToken ); @@ -121,14 +121,14 @@ CancellationToken cancellationToken /// /// /// A task that represents the asynchronous fetch operation. - /// The task result contains an enumerable of + /// The task result contains an enumerable of /// for the specified ranges. Each RangeChunk may have a null Range if no data is available. /// /// /// Default Behavior: /// /// The default implementation fetches each range in parallel by calling - /// for each range. + /// for each range. /// This provides automatic parallelization without additional implementation effort. /// /// When to Override: @@ -147,8 +147,8 @@ CancellationToken cancellationToken /// truncated ranges for partial availability). /// /// - async Task>> FetchAsync( - IEnumerable> ranges, + async Task>> FetchAsync( + IEnumerable> ranges, CancellationToken cancellationToken ) { diff --git a/src/SlidingWindowCache/Public/IWindowCache.cs b/src/SlidingWindowCache/Public/IWindowCache.cs new file mode 100644 index 0000000..eaf3d3b --- /dev/null +++ b/src/SlidingWindowCache/Public/IWindowCache.cs @@ -0,0 +1,128 @@ +using Intervals.NET; +using Intervals.NET.Domain.Abstractions; +using SlidingWindowCache.Public.Dto; + +namespace SlidingWindowCache.Public; + +/// +/// Represents a sliding window cache that retrieves and caches data for specified ranges, +/// with automatic rebalancing based on access patterns. +/// +/// +/// The type representing the range boundaries. Must implement . +/// +/// +/// The type of data being cached. +/// +/// +/// The type representing the domain of the ranges. Must implement . +/// Supports both fixed-step (O(1)) and variable-step (O(N)) domains. While variable-step domains +/// have O(N) complexity for range calculations, this cost is negligible compared to data source I/O. +/// +/// +/// Domain Flexibility: +/// +/// This cache works with any implementation, whether fixed-step +/// or variable-step. The in-memory cost of O(N) step counting (microseconds) is orders of magnitude +/// smaller than typical data source operations (milliseconds to seconds via network/disk I/O). +/// +/// Examples: +/// +/// Fixed-step: DateTimeDayFixedStepDomain, IntegerFixedStepDomain (O(1) operations) +/// Variable-step: Business days, months, custom calendars (O(N) operations, still fast) +/// +/// Resource Management: +/// +/// WindowCache manages background processing tasks and resources that require explicit disposal. +/// Always call when done using the cache instance. +/// +/// Disposal Behavior: +/// +/// Gracefully stops background rebalance processing loops +/// Disposes internal synchronization primitives (semaphores, cancellation tokens) +/// After disposal, all methods throw +/// Safe to call multiple times (idempotent) +/// Does not require timeout - completes when background tasks finish current work +/// +/// Usage Pattern: +/// +/// await using var cache = new WindowCache<int, int, IntegerFixedStepDomain>(...); +/// var data = await cache.GetDataAsync(range, cancellationToken); +/// // DisposeAsync automatically called at end of scope +/// +/// +public interface IWindowCache : IAsyncDisposable + where TRange : IComparable + where TDomain : IRangeDomain +{ + /// + /// Retrieves data for the specified range, utilizing the sliding window cache mechanism. + /// + /// + /// The range for which to retrieve data. + /// + /// + /// A cancellation token to cancel the operation. + /// + /// + /// A task that represents the asynchronous operation. The task result contains a + /// with the actual available range and data. + /// + /// + /// 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); + + /// + /// Waits for the cache to reach an idle state (no pending intent and no executing rebalance). + /// + /// + /// A cancellation token to cancel the wait operation. + /// + /// + /// A task that completes when the cache reaches idle state. + /// + /// + /// Idle State Definition: + /// + /// The cache is considered idle when: + /// + /// No pending intent is awaiting processing + /// No rebalance execution is currently running + /// + /// + /// Use Cases: + /// + /// Testing: Ensure cache has stabilized before assertions + /// Cold start synchronization: Wait for initial rebalance to complete + /// Diagnostics: Verify cache has converged to optimal state + /// + /// + Task WaitForIdleAsync(CancellationToken cancellationToken = default); +} diff --git a/src/SlidingWindowCache/Public/WindowCache.cs b/src/SlidingWindowCache/Public/WindowCache.cs index 7f3445e..81d2be8 100644 --- a/src/SlidingWindowCache/Public/WindowCache.cs +++ b/src/SlidingWindowCache/Public/WindowCache.cs @@ -14,129 +14,6 @@ namespace SlidingWindowCache.Public; -/// -/// Represents a sliding window cache that retrieves and caches data for specified ranges, -/// with automatic rebalancing based on access patterns. -/// -/// -/// The type representing the range boundaries. Must implement . -/// -/// -/// The type of data being cached. -/// -/// -/// The type representing the domain of the ranges. Must implement . -/// Supports both fixed-step (O(1)) and variable-step (O(N)) domains. While variable-step domains -/// have O(N) complexity for range calculations, this cost is negligible compared to data source I/O. -/// -/// -/// Domain Flexibility: -/// -/// This cache works with any implementation, whether fixed-step -/// or variable-step. The in-memory cost of O(N) step counting (microseconds) is orders of magnitude -/// smaller than typical data source operations (milliseconds to seconds via network/disk I/O). -/// -/// Examples: -/// -/// Fixed-step: DateTimeDayFixedStepDomain, IntegerFixedStepDomain (O(1) operations) -/// Variable-step: Business days, months, custom calendars (O(N) operations, still fast) -/// -/// Resource Management: -/// -/// WindowCache manages background processing tasks and resources that require explicit disposal. -/// Always call when done using the cache instance. -/// -/// Disposal Behavior: -/// -/// Gracefully stops background rebalance processing loops -/// Disposes internal synchronization primitives (semaphores, cancellation tokens) -/// After disposal, all methods throw -/// Safe to call multiple times (idempotent) -/// Does not require timeout - completes when background tasks finish current work -/// -/// Usage Pattern: -/// -/// await using var cache = new WindowCache<int, int, IntegerFixedStepDomain>(...); -/// var data = await cache.GetDataAsync(range, cancellationToken); -/// // DisposeAsync automatically called at end of scope -/// -/// -public interface IWindowCache : IAsyncDisposable - where TRange : IComparable - where TDomain : IRangeDomain -{ - /// - /// Retrieves data for the specified range, utilizing the sliding window cache mechanism. - /// - /// - /// The range for which to retrieve data. - /// - /// - /// A cancellation token to cancel the operation. - /// - /// - /// A task that represents the asynchronous operation. The task result contains a - /// with the actual available range and data. - /// - /// - /// 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); - - /// - /// Waits for the cache to reach an idle state (no pending intent and no executing rebalance). - /// - /// - /// A cancellation token to cancel the wait operation. - /// - /// - /// A task that completes when the cache reaches idle state. - /// - /// - /// Idle State Definition: - /// - /// The cache is considered idle when: - /// - /// No pending intent is awaiting processing - /// No rebalance execution is currently running - /// - /// - /// Use Cases: - /// - /// Testing: Ensure cache has stabilized before assertions - /// Cold start synchronization: Wait for initial rebalance to complete - /// Diagnostics: Verify cache has converged to optimal state - /// - /// - Task WaitForIdleAsync(CancellationToken cancellationToken = default); -} - /// /// /// Architecture: @@ -204,7 +81,7 @@ public WindowCache( var state = new CacheState(cacheStorage, domain); // Initialize all internal actors following corrected execution context model - var rebalancePolicy = new ThresholdRebalancePolicy(); + var rebalancePolicy = new NoRebalanceSatisfactionPolicy(); var rangePlanner = new ProportionalRangePlanner(options, domain); var noRebalancePlanner = new NoRebalanceRangePlanner(options, domain); var cacheFetcher = new CacheDataExtensionService(dataSource, domain, cacheDiagnostics); @@ -240,53 +117,55 @@ public WindowCache( dataSource, cacheDiagnostics ); + } - return; - - // Factory method to create the appropriate execution controller based on the specified rebalance queue capacity - static IRebalanceExecutionController CreateExecutionController( - RebalanceExecutor executor, - WindowCacheOptions options, - ICacheDiagnostics cacheDiagnostics, - AsyncActivityCounter activityCounter - ) + /// + /// Creates the appropriate execution controller based on the specified rebalance queue capacity. + /// + private static IRebalanceExecutionController CreateExecutionController( + RebalanceExecutor executor, + WindowCacheOptions options, + ICacheDiagnostics cacheDiagnostics, + AsyncActivityCounter activityCounter + ) + { + if (options.RebalanceQueueCapacity == null) { - if (options.RebalanceQueueCapacity == null) - { - // Unbounded strategy: Task-based serialization (default, recommended for most scenarios) - return new TaskBasedRebalanceExecutionController( - executor, - options.DebounceDelay, - cacheDiagnostics, - activityCounter - ); - } - else - { - // Bounded strategy: Channel-based serialization with backpressure support - return new ChannelBasedRebalanceExecutionController( - executor, - options.DebounceDelay, - cacheDiagnostics, - activityCounter, - options.RebalanceQueueCapacity.Value - ); - } + // Unbounded strategy: Task-based serialization (default, recommended for most scenarios) + return new TaskBasedRebalanceExecutionController( + executor, + options.DebounceDelay, + cacheDiagnostics, + activityCounter + ); } - - // Factory method to create the appropriate cache storage based on the specified read mode in options - static ICacheStorage CreateCacheStorage( - TDomain fixedStepDomain, - WindowCacheOptions windowCacheOptions - ) => windowCacheOptions.ReadMode switch + else { - UserCacheReadMode.Snapshot => new SnapshotReadStorage(fixedStepDomain), - UserCacheReadMode.CopyOnRead => new CopyOnReadStorage(fixedStepDomain), - _ => throw new ArgumentOutOfRangeException(nameof(windowCacheOptions.ReadMode), - windowCacheOptions.ReadMode, "Unknown read mode.") - }; + // Bounded strategy: Channel-based serialization with backpressure support + return new ChannelBasedRebalanceExecutionController( + executor, + options.DebounceDelay, + cacheDiagnostics, + activityCounter, + options.RebalanceQueueCapacity.Value + ); + } } + /// + /// Creates the appropriate cache storage based on the specified read mode in options. + /// + private static ICacheStorage CreateCacheStorage( + TDomain domain, + WindowCacheOptions windowCacheOptions + ) => windowCacheOptions.ReadMode switch + { + UserCacheReadMode.Snapshot => new SnapshotReadStorage(domain), + UserCacheReadMode.CopyOnRead => new CopyOnReadStorage(domain), + _ => throw new ArgumentOutOfRangeException(nameof(windowCacheOptions.ReadMode), + windowCacheOptions.ReadMode, "Unknown read mode.") + }; + /// /// /// This method acts as a thin delegation layer to the internal actor. diff --git a/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs index 91b39cc..9f1a397 100644 --- a/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs @@ -1,5 +1,5 @@ using Intervals.NET.Domain.Default.Numeric; -using SlidingWindowCache.Integration.Tests.TestInfrastructure; +using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; diff --git a/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs b/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs index bff7fee..9648663 100644 --- a/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs @@ -1,6 +1,6 @@ using Intervals.NET.Domain.Default.Numeric; using Intervals.NET.Domain.Extensions.Fixed; -using SlidingWindowCache.Integration.Tests.TestInfrastructure; +using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; @@ -258,10 +258,6 @@ public async Task Rebalance_SequentialRequests_CacheAdaptsToPattern() Assert.Equal((int)range.Span(_domain), loopResult.Data.Length); await cache.WaitForIdleAsync(); } - - // ASSERT - System handled sequential pattern without errors - // Each request returned correct data - Assert.True(true, "Sequential pattern handled successfully"); } #endregion @@ -320,10 +316,6 @@ public async Task NoRedundantFetches_SubsetOfCache_NoAdditionalFetch() Assert.Equal(11, array.Length); Assert.Equal(150, array[0]); Assert.Equal(160, array[^1]); - - // ASSERT - Subset request should ideally hit cache without new fetch - // (Background rebalance may occur, but subset data should be cached) - Assert.True(true, $"Subset request completed with fetch count: {_dataSource.TotalFetchCount}"); } #endregion @@ -367,9 +359,6 @@ public async Task DataSourceCalls_MultipleCacheMisses_EachTriggersFetch() Assert.True(_dataSource.TotalFetchCount >= 1, $"Cache miss should trigger fetch for range {range}"); } - - // ASSERT - All data correct - Assert.True(true, "All cache misses triggered DataSource calls"); } #endregion diff --git a/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs b/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs index 0bc5e60..63f5fa0 100644 --- a/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs @@ -1,6 +1,6 @@ using Intervals.NET; using Intervals.NET.Domain.Default.Numeric; -using SlidingWindowCache.Integration.Tests.TestInfrastructure; +using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; @@ -356,9 +356,6 @@ public async Task RapidFire_100RequestsMinimalDelay_NoDeadlock() Assert.Equal(21, result.Data.Length); } - - // ASSERT - Completed without deadlock - Assert.True(true); } #endregion diff --git a/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs b/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs index 6991277..023676e 100644 --- a/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs @@ -1,5 +1,5 @@ using Intervals.NET.Domain.Default.Numeric; -using SlidingWindowCache.Integration.Tests.TestInfrastructure; +using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; @@ -10,7 +10,13 @@ namespace SlidingWindowCache.Integration.Tests; /// Tests that validate the EXACT ranges propagated to IDataSource in different cache scenarios. /// These tests provide precise behavioral contracts ("alibi") proving the cache requests /// correct ranges from the data source in every state transition. -/// +/// +/// Note: These are intentional white-box tests. They verify internal +/// range propagation details (e.g. exact segment boundaries sent to IDataSource) to guard +/// against regressions in the User Path and Rebalance Execution logic. This level of +/// specificity is deliberate — it documents and locks in the precise data-fetch contracts +/// that the rest of the architecture depends on. +/// /// Scenarios covered: /// - User Path: Cache miss (cold start) /// - User Path: Cache hit (full cache coverage) diff --git a/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs b/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs index 82b473b..1d0d50a 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs @@ -1,7 +1,7 @@ using Intervals.NET; using Intervals.NET.Domain.Default.Numeric; using Intervals.NET.Domain.Extensions.Fixed; -using SlidingWindowCache.Integration.Tests.TestInfrastructure; +using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; diff --git a/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs b/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs index c6b9dc5..4ba3bc2 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs @@ -1,6 +1,6 @@ using Intervals.NET.Domain.Default.Numeric; using Intervals.NET.Domain.Extensions.Fixed; -using SlidingWindowCache.Integration.Tests.TestInfrastructure; +using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; diff --git a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs index 69b8991..03a8e5c 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs @@ -1,7 +1,7 @@ using Intervals.NET; using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Infrastructure.Instrumentation; -using SlidingWindowCache.Integration.Tests.TestInfrastructure; +using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; diff --git a/tests/SlidingWindowCache.Integration.Tests/SlidingWindowCache.Integration.Tests.csproj b/tests/SlidingWindowCache.Integration.Tests/SlidingWindowCache.Integration.Tests.csproj index 90c9f79..5b3e712 100644 --- a/tests/SlidingWindowCache.Integration.Tests/SlidingWindowCache.Integration.Tests.csproj +++ b/tests/SlidingWindowCache.Integration.Tests/SlidingWindowCache.Integration.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs index f5fa10c..0dcecfd 100644 --- a/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs @@ -1,6 +1,6 @@ using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Infrastructure.Instrumentation; -using SlidingWindowCache.Integration.Tests.TestInfrastructure; +using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; diff --git a/tests/SlidingWindowCache.Invariants.Tests/SlidingWindowCache.Invariants.Tests.csproj b/tests/SlidingWindowCache.Invariants.Tests/SlidingWindowCache.Invariants.Tests.csproj index 3e6abce..c5d97e5 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/SlidingWindowCache.Invariants.Tests.csproj +++ b/tests/SlidingWindowCache.Invariants.Tests/SlidingWindowCache.Invariants.Tests.csproj @@ -21,8 +21,15 @@ + + + + + + + diff --git a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs index 91746cc..2044631 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs @@ -2,7 +2,7 @@ using Intervals.NET.Domain.Extensions.Fixed; using Intervals.NET.Extensions; using SlidingWindowCache.Infrastructure.Instrumentation; -using SlidingWindowCache.Invariants.Tests.TestInfrastructure; +using SlidingWindowCache.Tests.Infrastructure.Helpers; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; @@ -631,11 +631,6 @@ public async Task Invariant_C20_DecisionEngineExitsEarlyForObsoleteIntent() Assert.True(_cacheDiagnostics.RebalanceScheduled <= _cacheDiagnostics.RebalanceIntentPublished, $"Scheduled executions ({_cacheDiagnostics.RebalanceScheduled}) should not exceed published intents ({_cacheDiagnostics.RebalanceIntentPublished})"); - // Intent cancellations indicate early exit occurred (obsolete intents discarded) - // System should have cancelled some intents due to obsolescence - var totalCancellations = _cacheDiagnostics.RebalanceIntentCancelled + - _cacheDiagnostics.RebalanceExecutionCancelled; - // At least one rebalance should complete successfully (system converged to final state) Assert.True(_cacheDiagnostics.RebalanceExecutionCompleted >= 1, $"Expected at least 1 rebalance to complete, but found {_cacheDiagnostics.RebalanceExecutionCompleted}"); diff --git a/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/BoundedDataSource.cs b/tests/SlidingWindowCache.Tests.Infrastructure/DataSources/BoundedDataSource.cs similarity index 61% rename from tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/BoundedDataSource.cs rename to tests/SlidingWindowCache.Tests.Infrastructure/DataSources/BoundedDataSource.cs index b3940b1..18a6c45 100644 --- a/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/BoundedDataSource.cs +++ b/tests/SlidingWindowCache.Tests.Infrastructure/DataSources/BoundedDataSource.cs @@ -3,7 +3,7 @@ using SlidingWindowCache.Public; using SlidingWindowCache.Public.Dto; -namespace SlidingWindowCache.Integration.Tests.TestInfrastructure; +namespace SlidingWindowCache.Tests.Infrastructure.DataSources; /// /// A test IDataSource implementation that simulates a bounded data source with physical limits. @@ -47,7 +47,7 @@ public Task> FetchAsync(Range requested, CancellationT } // Fetch available portion (non-null fulfillable) - var data = GenerateDataForRange(fulfillable.Value); + var data = DataGenerationHelpers.GenerateDataForRange(fulfillable.Value); return Task.FromResult(new RangeChunk(fulfillable.Value, data)); } @@ -69,51 +69,4 @@ public async Task>> FetchAsync( 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.Tests.Infrastructure/DataSources/DataGenerationHelpers.cs b/tests/SlidingWindowCache.Tests.Infrastructure/DataSources/DataGenerationHelpers.cs new file mode 100644 index 0000000..8372b82 --- /dev/null +++ b/tests/SlidingWindowCache.Tests.Infrastructure/DataSources/DataGenerationHelpers.cs @@ -0,0 +1,61 @@ +using Intervals.NET; + +namespace SlidingWindowCache.Tests.Infrastructure.DataSources; + +/// +/// Shared data generation logic for test data sources. +/// Encapsulates the range-to-data mapping used by and +/// , eliminating duplication across test projects. +/// +public static class DataGenerationHelpers +{ + /// + /// Generates sequential integer data for a range, respecting boundary inclusivity. + /// + /// The range to generate data for. + /// A list of sequential integers corresponding to the range. + public 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.Tests.Infrastructure/DataSources/FaultyDataSource.cs similarity index 97% rename from tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/FaultyDataSource.cs rename to tests/SlidingWindowCache.Tests.Infrastructure/DataSources/FaultyDataSource.cs index bc48107..20103eb 100644 --- a/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/FaultyDataSource.cs +++ b/tests/SlidingWindowCache.Tests.Infrastructure/DataSources/FaultyDataSource.cs @@ -2,7 +2,7 @@ using SlidingWindowCache.Public; using SlidingWindowCache.Public.Dto; -namespace SlidingWindowCache.Integration.Tests.TestInfrastructure; +namespace SlidingWindowCache.Tests.Infrastructure.DataSources; /// /// A configurable IDataSource that delegates fetch calls through a user-supplied callback, diff --git a/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/SpyDataSource.cs b/tests/SlidingWindowCache.Tests.Infrastructure/DataSources/SpyDataSource.cs similarity index 70% rename from tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/SpyDataSource.cs rename to tests/SlidingWindowCache.Tests.Infrastructure/DataSources/SpyDataSource.cs index bbb5787..278f25c 100644 --- a/tests/SlidingWindowCache.Integration.Tests/TestInfrastructure/SpyDataSource.cs +++ b/tests/SlidingWindowCache.Tests.Infrastructure/DataSources/SpyDataSource.cs @@ -1,9 +1,9 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using Intervals.NET; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Dto; -namespace SlidingWindowCache.Integration.Tests.TestInfrastructure; +namespace SlidingWindowCache.Tests.Infrastructure.DataSources; /// /// A test spy/fake IDataSource implementation that records all fetch calls for verification. @@ -91,7 +91,7 @@ public Task> FetchAsync(Range range, CancellationToken _singleFetchCalls.Add(range); Interlocked.Increment(ref _totalFetchCount); - var data = GenerateDataForRange(range); + var data = DataGenerationHelpers.GenerateDataForRange(range); return Task.FromResult(new RangeChunk(range, data)); } @@ -109,58 +109,10 @@ public async Task>> FetchAsync( var chunks = new List>(); foreach (var range in rangesList) { - var data = GenerateDataForRange(range); + var data = DataGenerationHelpers.GenerateDataForRange(range); chunks.Add(new RangeChunk(range, data)); } return await Task.FromResult(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; - } -} \ No newline at end of file +} diff --git a/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs b/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs similarity index 89% rename from tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs rename to tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs index 6870135..7d9de07 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs +++ b/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs @@ -6,8 +6,9 @@ using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Dto; +using SlidingWindowCache.Tests.Infrastructure.DataSources; -namespace SlidingWindowCache.Invariants.Tests.TestInfrastructure; +namespace SlidingWindowCache.Tests.Infrastructure.Helpers; /// /// Helper methods for creating test components. @@ -150,46 +151,7 @@ public static Mock> CreateMockDataSource(IntegerFixedStepD await Task.Delay(fetchDelay.Value, ct); } - // Use Intervals.NET domain to properly calculate range span - var span = range.Span(domain); - var data = new List((int)span); - - // Generate data respecting range inclusivity - var start = (int)range.Start; - var end = (int)range.End; - - switch (range) - { - case { IsStartInclusive: true, IsEndInclusive: true }: - for (var i = start; i <= end; i++) - { - data.Add(i); - } - - break; - case { IsStartInclusive: true, IsEndInclusive: false }: - for (var i = start; i < end; i++) - { - data.Add(i); - } - - break; - case { IsStartInclusive: false, IsEndInclusive: true }: - for (var i = start + 1; i <= end; i++) - { - data.Add(i); - } - - break; - default: - for (var i = start + 1; i < end; i++) - { - data.Add(i); - } - - break; - } - + var data = DataGenerationHelpers.GenerateDataForRange(range); return new RangeChunk(range, data); }); @@ -234,39 +196,7 @@ public static (Mock> mock, List> fetchedRanges) await Task.Delay(fetchDelay.Value, ct); } - var span = range.Span(domain); - var data = new List((int)span); - var start = (int)range.Start; - var end = (int)range.End; - - switch (range) - { - case { IsStartInclusive: true, IsEndInclusive: true }: - for (var i = start; i <= end; i++) - { - data.Add(i); - } - break; - case { IsStartInclusive: true, IsEndInclusive: false }: - for (var i = start; i < end; i++) - { - data.Add(i); - } - break; - case { IsStartInclusive: false, IsEndInclusive: true }: - for (var i = start + 1; i <= end; i++) - { - data.Add(i); - } - break; - default: - for (var i = start + 1; i < end; i++) - { - data.Add(i); - } - break; - } - + var data = DataGenerationHelpers.GenerateDataForRange(range); return new RangeChunk(range, data); }); @@ -297,6 +227,17 @@ public static WindowCache CreateCache( EventCounterCacheDiagnostics cacheDiagnostics) => new(mockDataSource.Object, domain, options, cacheDiagnostics); + /// + /// Creates a WindowCache instance backed by a . + /// Used by integration tests that need a concrete (non-mock) data source with fetch recording. + /// + public static WindowCache CreateCache( + SpyDataSource dataSource, + IntegerFixedStepDomain domain, + WindowCacheOptions options, + EventCounterCacheDiagnostics cacheDiagnostics) => + new(dataSource, domain, options, cacheDiagnostics); + /// /// Creates a WindowCache with default options and returns both cache and mock data source. /// @@ -341,7 +282,7 @@ public static void AssertUserDataCorrect(ReadOnlyMemory data, Range ra /// during range analysis (when determining what data needs to be fetched). They track planning, not actual /// cache mutations. This assertion verifies that User Path didn't call ExtendCacheAsync, which would /// increment these counters. Actual cache mutations (via Rematerialize) only occur in Rebalance Execution. - /// + /// /// In test scenarios, prior rebalance operations typically expand the cache enough that subsequent /// User Path requests are full hits, avoiding calls to ExtendCacheAsync entirely. /// @@ -545,11 +486,11 @@ public static void AssertRebalanceSkippedSameRange(EventCounterCacheDiagnostics /// /// Decision Pipeline Stages: /// - Stage 1: Current NoRebalanceRange check → SkippedCurrentNoRebalanceRange - /// - Stage 2: Pending NoRebalanceRange check → SkippedPendingNoRebalanceRange + /// - Stage 2: Pending NoRebalanceRange check → SkippedPendingNoRebalanceRange /// - Stage 3: DesiredCacheRange == CurrentCacheRange → SkippedSameRange /// - All stages pass → RebalanceScheduled /// - Intent superseded before decision → IntentCancelled - /// + /// /// Execution Lifecycle: /// - Scheduled → ExecutionStarted (unless cancelled between scheduling and execution) /// - Started → (Completed | ExecutionCancelled | ExecutionFailed) diff --git a/tests/SlidingWindowCache.Tests.Infrastructure/SlidingWindowCache.Tests.Infrastructure.csproj b/tests/SlidingWindowCache.Tests.Infrastructure/SlidingWindowCache.Tests.Infrastructure.csproj new file mode 100644 index 0000000..1a57a00 --- /dev/null +++ b/tests/SlidingWindowCache.Tests.Infrastructure/SlidingWindowCache.Tests.Infrastructure.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + false + + + + + + + + + + + + + + + + + + + diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/CopyOnReadStorageTests.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/CopyOnReadStorageTests.cs index daacd07..6123281 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/CopyOnReadStorageTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/CopyOnReadStorageTests.cs @@ -1,7 +1,6 @@ using Intervals.NET.Data.Extensions; using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Infrastructure.Storage; -using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Unit.Tests.Infrastructure.Extensions; using static SlidingWindowCache.Unit.Tests.Infrastructure.Storage.TestInfrastructure.StorageTestHelpers; @@ -15,20 +14,6 @@ public class CopyOnReadStorageTests { #region Interface Contract Tests - [Fact] - public void Mode_ReturnsCopyOnRead() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - - // ACT - var mode = storage.Mode; - - // ASSERT - Assert.Equal(UserCacheReadMode.CopyOnRead, mode); - } - [Fact] public void Range_InitiallyEmpty() { diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/SnapshotReadStorageTests.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/SnapshotReadStorageTests.cs index f6e5dee..7d70560 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/SnapshotReadStorageTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/SnapshotReadStorageTests.cs @@ -1,7 +1,6 @@ using Intervals.NET.Data.Extensions; using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Infrastructure.Storage; -using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Unit.Tests.Infrastructure.Extensions; using static SlidingWindowCache.Unit.Tests.Infrastructure.Storage.TestInfrastructure.StorageTestHelpers; @@ -15,20 +14,6 @@ public class SnapshotReadStorageTests { #region Interface Contract Tests - [Fact] - public void Mode_ReturnsSnapshot() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - - // ACT - var mode = storage.Mode; - - // ASSERT - Assert.Equal(UserCacheReadMode.Snapshot, mode); - } - [Fact] public void Range_InitiallyEmpty() { From a2128755b0558507d2c79ba5a4a8f17fdca8d413 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Fri, 27 Feb 2026 03:16:42 +0100 Subject: [PATCH 03/21] refactor: improve thread safety and synchronization in CopyOnReadStorage; enhance documentation for clarity --- docs/component-map.md | 10 +- docs/invariants.md | 4 + docs/storage-strategies.md | 68 ++++--- .../Storage/CopyOnReadStorage.cs | 189 +++++++++--------- .../Storage/CopyOnReadStorageTests.cs | 130 ++++++++++++ 5 files changed, 280 insertions(+), 121 deletions(-) diff --git a/docs/component-map.md b/docs/component-map.md index 472f6cf..7411a41 100644 --- a/docs/component-map.md +++ b/docs/component-map.md @@ -753,16 +753,18 @@ internal sealed class CopyOnReadStorage : ICacheStorage< **Characteristics**: - ✅ Cheap rematerialization (amortized O(1) when capacity sufficient) -- ❌ Expensive reads (allocates + copies) -- ✅ Correct enumeration (staging buffer prevents corruption) +- ❌ Expensive reads (acquires lock + allocates + copies) +- ✅ Correct enumeration (staging buffer prevents corruption during LINQ-derived expansion) - ✅ No LOH pressure (List growth strategy) - ✅ Satisfies Invariants A.3.8, A.3.9a, B.11-12 +- ✅ Read/Rematerialize synchronized via `_lock` (mid-swap observation impossible) +- ⚠️ Small lock contention cost on each `Read()` (bounded to swap duration) **Ownership**: Owned by CacheState (single instance) -**Internal State**: Two `List` (swapped atomically) +**Internal State**: Two `List` (swapped under `_lock`); `_lock` object -**Thread Safety**: Not thread-safe (single consumer model) +**Thread Safety**: `Read()` and `Rematerialize()` are synchronized via `_lock`; `ToRangeData()` is unsynchronized and must only be called from the rebalance path (conditionally compliant with Invariant A.2 — see storage-strategies.md) **Best for**: Rematerialization-heavy workloads, large sliding windows, background cache layers diff --git a/docs/invariants.md b/docs/invariants.md index 356fdce..1de8074 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -192,6 +192,10 @@ without polling or timing dependencies. **A.2** 🟢 **[Behavioral — Test: `Invariant_A2_2_UserPathNeverWaitsForRebalance`]** The User Path **never waits for rebalance execution** to complete. - *Observable via*: Request completion time vs. debounce delay - *Test verifies*: Request completes in <500ms with 1-second debounce +- *Conditional compliance*: `CopyOnReadStorage` acquires a short-lived `_lock` in `Read()` shared with + `Rematerialize()`. The lock is held only for the buffer swap and `Range` update — not for data fetching + or the full rebalance cycle. Contention is sub-millisecond and bounded. `SnapshotReadStorage` remains + fully lock-free. See [Storage Strategies Guide](storage-strategies.md#invariant-a2---user-path-never-waits-for-rebalance-conditional-compliance) for details. **A.3** 🔵 **[Architectural]** The User Path is the **sole source of rebalance intent**. diff --git a/docs/storage-strategies.md b/docs/storage-strategies.md index 548f3bb..a79ce65 100644 --- a/docs/storage-strategies.md +++ b/docs/storage-strategies.md @@ -20,7 +20,7 @@ This guide explains when to use each strategy and their trade-offs. |------------------------|-----------------------------------|-----------------------------------| | **Read Cost** | O(1) - zero allocation | O(n) - allocates and copies | | **Rematerialize Cost** | O(n) - always allocates new array | O(1)* - reuses capacity | -| **Memory Pattern** | Single array, replaced atomically | Dual buffers, swapped atomically | +| **Memory Pattern** | Single array, replaced atomically | Dual buffers, swap synchronized by lock | | **Buffer Growth** | Always allocates exact size | Grows but never shrinks | | **LOH Risk** | High for >85KB arrays | Lower (List growth strategy) | | **Best For** | Read-heavy workloads | Rematerialization-heavy workloads | @@ -148,32 +148,42 @@ storage during enumeration corrupts the data. **Rematerialize:** ```csharp -_stagingBuffer.Clear(); // Preserves capacity -_stagingBuffer.AddRange(rangeData.Data); // Single-pass enumeration -(_activeStorage, _stagingBuffer) = (_stagingBuffer, _activeStorage); // Atomic swap -Range = rangeData.Range; +// Enumerate outside the lock (may be a LINQ chain over _activeStorage) +_stagingBuffer.Clear(); +_stagingBuffer.AddRange(rangeData.Data); + +lock (_lock) +{ + (_activeStorage, _stagingBuffer) = (_stagingBuffer, _activeStorage); // Swap under lock + Range = rangeData.Range; +} ``` **Read:** ```csharp -if (!Range.Contains(range)) - throw new ArgumentOutOfRangeException(nameof(range), ...); +lock (_lock) +{ + if (!Range.Contains(range)) + throw new ArgumentOutOfRangeException(nameof(range), ...); -var result = new TData[length]; // Allocates -for (var i = 0; i < length; i++) - result[i] = _activeStorage[(int)startOffset + i]; -return new ReadOnlyMemory(result); + var result = new TData[length]; // Allocates + for (var i = 0; i < length; i++) + result[i] = _activeStorage[(int)startOffset + i]; + return new ReadOnlyMemory(result); +} ``` ### Characteristics - ✅ **Cheap rematerialization**: Reuses capacity, no allocation if size ≤ capacity - ✅ **No LOH pressure**: List growth strategy avoids large single allocations -- ✅ **Correct enumeration**: Staging buffer prevents corruption +- ✅ **Correct enumeration**: Staging buffer prevents corruption during LINQ-derived expansion - ✅ **Amortized performance**: Cost decreases over time as capacity stabilizes -- ❌ **Expensive reads**: Each read allocates and copies +- ✅ **Safe concurrent access**: `Read()` and `Rematerialize()` share a lock; mid-swap observation is impossible +- ❌ **Expensive reads**: Each read acquires a lock, allocates, and copies - ❌ **Higher memory**: Two buffers instead of one +- ⚠️ **Lock contention**: Reader briefly blocks if rematerialization is in progress (bounded to a single `Rematerialize()` call duration) ### Memory Behavior @@ -275,12 +285,12 @@ This composition leverages the strengths of both strategies: ### CopyOnRead Storage -| Operation | Time | Allocation | -|----------------------|------|---------------| -| Read | O(n) | n × sizeof(T) | -| Rematerialize (cold) | O(n) | n × sizeof(T) | -| Rematerialize (warm) | O(n) | 0 bytes** | -| ToRangeData | O(1) | 0 bytes* | +| Operation | Time | Allocation | Notes | +|----------------------|------|---------------|------------------------------| +| Read | O(n) | n × sizeof(T) | Lock acquired + copy | +| Rematerialize (cold) | O(n) | n × sizeof(T) | Enumerate outside lock | +| Rematerialize (warm) | O(n) | 0 bytes** | Enumerate outside lock | +| ToRangeData | O(1) | 0 bytes* | Not synchronized (rebalance path only) | *Returns lazy enumerable **When capacity is sufficient @@ -338,9 +348,9 @@ cache.Rematerialize(extendedData); ### Buffer Swap Invariants -1. **Active storage is immutable during reads**: Never mutated until swap -2. **Staging buffer is write-only during rematerialization**: Cleared, filled, swapped -3. **Swap is atomic**: Single tuple assignment +1. **Active storage is immutable during reads**: Never mutated until swap; lock prevents concurrent observation mid-swap +2. **Staging buffer is write-only during rematerialization**: Cleared and filled outside the lock, then swapped under lock +3. **Swap is lock-protected**: `Read()` and `Rematerialize()` share `_lock`; a reader always sees a consistent `(_activeStorage, Range)` pair 4. **Buffers never shrink**: Capacity grows monotonically, amortizing allocation cost ### Memory Growth Example @@ -384,9 +394,17 @@ The staging buffer pattern directly supports key system invariants: ### Invariant B.11-12 - Atomic Consistency -- Tuple swap `(_activeStorage, _stagingBuffer) = (_stagingBuffer, _activeStorage)` is atomic -- Range update happens after swap, completing atomic change -- No intermediate inconsistent states +- Swap and Range update both happen inside `lock (_lock)`, so `Read()` always observes a consistent `(_activeStorage, Range)` pair +- No intermediate inconsistent state is observable + +### Invariant A.2 - User Path Never Waits for Rebalance (Conditional Compliance) + +- `CopyOnReadStorage` is **conditionally compliant**: `Read()` acquires `_lock`, which is also held by + `Rematerialize()` for the duration of the buffer swap and Range update (a fast, bounded operation). +- Contention is limited to the swap itself — not the full rebalance cycle (fetch + decision + execution). + The enumeration into the staging buffer happens **before** the lock is acquired, so the lock hold time + is just the cost of two field writes and a property assignment. +- `SnapshotReadStorage` remains fully lock-free if strict A.2 compliance is required. ### Invariant B.15 - Cancellation Safety diff --git a/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs b/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs index ca23f86..0f554c0 100644 --- a/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs +++ b/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.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; @@ -26,18 +26,33 @@ namespace SlidingWindowCache.Infrastructure.Storage; /// This storage maintains two internal lists: /// /// -/// _activeStorage - Immutable during reads, used for serving data -/// _stagingBuffer - Write-only during rematerialization, reused across operations +/// _activeStorage - Serves data to Read() operations; never mutated during reads +/// _stagingBuffer - Write-only during rematerialization; reused across operations /// /// Rematerialization Process: /// +/// Acquire _lock /// Clear staging buffer (preserves capacity) /// Enumerate incoming range data into staging buffer (single-pass) -/// Atomically swap staging buffer with active storage +/// Swap staging buffer with active storage +/// Update Range to reflect new active storage +/// Release _lock /// /// -/// This ensures that active storage is never mutated during enumeration, preventing correctness issues -/// when range data is derived from the same storage (e.g., during cache expansion per Invariant A.3.8). +/// This ensures that active storage is never observed mid-swap by a concurrent Read() call, +/// preventing data races when range data is derived from the same storage (e.g., during cache expansion +/// per Invariant A.3.8). +/// +/// Synchronization: +/// +/// Read() and Rematerialize() share a single _lock object. +/// This is the accepted trade-off for buffer reuse: contention is bounded to the duration of a +/// single Rematerialize() call (a sub-millisecond linear copy), not the full rebalance cycle. +/// ToRangeData() is only called by the rebalance path (the same thread as Rematerialize()) +/// and is therefore not synchronized. +/// +/// +/// See Invariant A.2 for the conditional compliance note regarding this lock. /// /// Memory Behavior: /// @@ -48,8 +63,8 @@ namespace SlidingWindowCache.Infrastructure.Storage; /// /// Read Behavior: /// -/// Each read operation allocates a new array and copies data from active storage (copy-on-read semantics). -/// This is a trade-off for cheaper rematerialization compared to Snapshot mode. +/// Each read operation acquires the lock, allocates a new array, and copies data from active storage +/// (copy-on-read semantics). This is a trade-off for cheaper rematerialization compared to Snapshot mode. /// /// When to Use: /// @@ -65,10 +80,14 @@ internal sealed class CopyOnReadStorage : ICacheStorage< { private readonly TDomain _domain; - // Active storage: immutable during reads, serves data to Read() operations + // Shared lock: acquired by both Read() and Rematerialize() to prevent observation of mid-swap state. + // ToRangeData() is not synchronized because it is only called from the rebalance path. + private readonly object _lock = new(); + + // Active storage: serves data to Read() operations; never mutated while _lock is held by Read() private List _activeStorage = []; - // Staging buffer: write-only during rematerialization, reused across operations + // Staging buffer: write-only during Rematerialize(); reused across operations // This buffer may grow but never shrinks, amortizing allocation cost private List _stagingBuffer = []; @@ -93,15 +112,24 @@ public CopyOnReadStorage(TDomain domain) /// This method implements a dual-buffer pattern to satisfy Invariants A.3.8, B.11-12: /// /// + /// Acquire _lock (shared with Read()) /// Clear staging buffer (preserves capacity for reuse) /// Enumerate range data into staging buffer (single-pass, no double enumeration) - /// Atomically swap buffers: staging becomes active, old active becomes staging + /// Swap buffers: staging becomes active, old active becomes staging + /// Update Range to reflect new active storage /// /// /// Why this pattern? When contains data derived from /// the same storage (e.g., during cache expansion via LINQ operations like Concat/Union), direct /// mutation of active storage would corrupt the enumeration. The staging buffer ensures active - /// storage remains immutable during enumeration, satisfying Invariant A.3.9a (cache contiguity). + /// storage remains unchanged during enumeration, satisfying Invariant A.3.9a (cache contiguity). + /// + /// + /// Why the lock? The buffer swap consists of two separate field writes, which are + /// not atomic at the CPU level. Without the lock, a concurrent Read() on the User thread could + /// observe _activeStorage mid-swap (new list reference but stale Range, or vice versa), + /// producing incorrect results. The lock eliminates this window. Contention is bounded to the duration + /// of this method call, not the full rebalance cycle. /// /// /// Memory efficiency: The staging buffer reuses capacity across rematerializations, @@ -111,97 +139,73 @@ public CopyOnReadStorage(TDomain domain) /// public void Rematerialize(RangeData rangeData) { - // Clear staging buffer (preserves capacity for reuse) - _stagingBuffer.Clear(); - - // Single-pass enumeration: materialize incoming range data into staging buffer - // This is safe even if rangeData.Data is based on _activeStorage (e.g., LINQ chains during expansion) - // because we never mutate _activeStorage during enumeration - _stagingBuffer.AddRange(rangeData.Data); - - // Atomically swap buffers: staging becomes active, old active becomes staging for next use - // This swap is the only point where active storage is replaced, satisfying Invariant B.12 (atomic changes) - // - // TODO: Known race condition between Read() and Rematerialize(): - // Read() and Rematerialize() can overlap — Read() is called on the User thread while Rematerialize() - // runs on the Rebalance Execution thread. The tuple-swap here ( (_activeStorage, _stagingBuffer) = ... ) - // is NOT atomic at the CPU level: it is two separate field writes. A concurrent Read() could observe - // _activeStorage mid-swap, seeing the new reference for _activeStorage but old data for Range (or vice versa). - // This is a real data race. - // - // All known fix approaches share the same fundamental race: - // - Making _activeStorage volatile only fixes visibility, not atomicity of the two-field update. - // - Wrapping both fields in a single holder object and replacing the holder via Interlocked.Exchange - // would achieve atomic reference replacement, but at the cost of allocating a new holder object on - // every Rematerialize() call and losing the buffer-reuse advantage that is CopyOnReadStorage's - // primary design goal. - // - Using a lock on both Read() and Rematerialize() would be correct but violates the lock-free - // User Path requirement (Invariant A.2: User Path must never block on rebalance operations). - // - // The race is accepted as-is because: - // 1. WindowCache is designed for a single logical consumer (single coherent access pattern). - // The User thread and Rebalance thread have an implicit ordering: Rematerialize() is only - // called after data is fetched, which only happens after a user request triggers a rebalance. - // 2. In practice, the window of inconsistency is sub-microsecond (two field writes), and the - // observable effect (slightly stale data on one read) is within the "smart eventual consistency" - // model that WindowCache guarantees. - // 3. Fixing it cleanly would require either accepting per-Rematerialize allocations (defeating - // CopyOnReadStorage's purpose) or violating the lock-free User Path invariant. - // - // If strict atomic swap is required, use SnapshotReadStorage instead (allocates a new array per - // Rematerialize but achieves safe reference replacement via a single field write to _data). - (_activeStorage, _stagingBuffer) = (_stagingBuffer, _activeStorage); - - // Update range to reflect new active storage (part of atomic change) - Range = rangeData.Range; + // Enumerate incoming data BEFORE acquiring the lock. + // rangeData.Data may be a lazy LINQ chain over _activeStorage (e.g., during cache expansion). + // Holding the lock during enumeration would block concurrent Read() calls for the full + // enumeration duration. Instead, we materialize into a local staging buffer first, then + // acquire the lock only for the fast swap operation. + _stagingBuffer.Clear(); // Preserves capacity + _stagingBuffer.AddRange(rangeData.Data); // Single-pass enumeration outside the lock + + lock (_lock) + { + // Swap buffers: staging (now filled) becomes active; old active becomes staging for next use. + // Range update is inside the lock so Read() always observes a consistent (list, Range) pair. + // There is no case when during Read the read buffer is changed due to lock. + (_activeStorage, _stagingBuffer) = (_stagingBuffer, _activeStorage); + Range = rangeData.Range; + } } /// /// /// Copy-on-Read Semantics: /// - /// Each read allocates a new array and copies the requested data from active storage. - /// This is the trade-off for cheaper rematerialization: reads are more expensive, - /// but rematerialization avoids allocating a new backing array each time. + /// Each read acquires _lock, allocates a new array, and copies the requested data from + /// active storage. The lock prevents observing active storage mid-swap during a concurrent + /// Rematerialize() call, ensuring the returned data is always consistent with Range. /// /// - /// Active storage is immutable during this operation, ensuring correctness within - /// the single-consumer model (Invariant A.1-1: no concurrent execution). + /// This is the trade-off for cheaper rematerialization: reads are more expensive (lock + alloc + copy), + /// but rematerialization avoids allocating a new backing array each time. /// /// public ReadOnlyMemory Read(Range range) { - if (_activeStorage.Count == 0) + lock (_lock) { - return ReadOnlyMemory.Empty; + if (_activeStorage.Count == 0) + { + return ReadOnlyMemory.Empty; + } + + // Validate that the requested range is within the stored range + if (!Range.Contains(range)) + { + throw new ArgumentOutOfRangeException(nameof(range), + $"Requested range {range} is not contained within the cached range {Range}"); + } + + // Calculate the offset and length for the requested range + var startOffset = _domain.Distance(Range.Start.Value, range.Start.Value); + var length = (int)range.Span(_domain); + + // Validate bounds before accessing storage + if (startOffset < 0 || length < 0 || (int)startOffset + length > _activeStorage.Count) + { + throw new ArgumentOutOfRangeException(nameof(range), + $"Calculated offset {startOffset} and length {length} exceed storage bounds (storage count: {_activeStorage.Count})"); + } + + // Allocate a new array and copy the requested data (copy-on-read semantics) + var result = new TData[length]; + for (var i = 0; i < length; i++) + { + result[i] = _activeStorage[(int)startOffset + i]; + } + + return new ReadOnlyMemory(result); } - - // Validate that the requested range is within the stored range - if (!Range.Contains(range)) - { - throw new ArgumentOutOfRangeException(nameof(range), - $"Requested range {range} is not contained within the cached range {Range}"); - } - - // Calculate the offset and length for the requested range - var startOffset = _domain.Distance(Range.Start.Value, range.Start.Value); - var length = (int)range.Span(_domain); - - // Validate bounds before accessing storage - if (startOffset < 0 || length < 0 || (int)startOffset + length > _activeStorage.Count) - { - throw new ArgumentOutOfRangeException(nameof(range), - $"Calculated offset {startOffset} and length {length} exceed storage bounds (storage count: {_activeStorage.Count})"); - } - - // Allocate a new array and copy the requested data (copy-on-read semantics) - var result = new TData[length]; - for (var i = 0; i < length; i++) - { - result[i] = _activeStorage[(int)startOffset + i]; - } - - return new ReadOnlyMemory(result); } /// @@ -211,9 +215,10 @@ public ReadOnlyMemory Read(Range range) /// the current active storage. The returned data is a lazy enumerable over the active list. /// /// - /// This method is safe because active storage is immutable during reads and only replaced - /// atomically during rematerialization (Invariant B.12). + /// This method is only called from the rebalance path — the same thread that calls + /// Rematerialize() — so it is not synchronized. It must not be called concurrently + /// with Rematerialize(). /// /// public RangeData ToRangeData() => _activeStorage.ToRangeData(Range, _domain); -} \ No newline at end of file +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/CopyOnReadStorageTests.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/CopyOnReadStorageTests.cs index 6123281..ff761ff 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/CopyOnReadStorageTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/CopyOnReadStorageTests.cs @@ -494,6 +494,136 @@ public void StagingPattern_MultipleQuickRematerializations_MaintainsCorrectness( #endregion + #region Thread Safety Tests + + [Fact] + public async Task ThreadSafety_ConcurrentReadAndRematerialize_NeverCorruptsData() + { + // ARRANGE - Verify that concurrent Read() and Rematerialize() calls never produce + // corrupted or inconsistent data. This directly tests the invariant enforced by _lock: + // a Read() must never observe _activeStorage mid-swap (new list reference but stale Range). + var domain = CreateFixedStepDomain(); + var storage = new CopyOnReadStorage(domain); + storage.Rematerialize(CreateRangeData(0, 9, domain)); + + const int iterations = 2_000; + var exceptions = new System.Collections.Concurrent.ConcurrentBag(); + var cts = new CancellationTokenSource(); + + // Writer task: continuously rematerializes with alternating ranges + var writer = Task.Run(() => + { + for (var i = 0; i < iterations && !cts.Token.IsCancellationRequested; i++) + { + // Alternate between two distinct ranges so a corrupted swap would be detectable + var start = (i % 2 == 0) ? 0 : 100; + var end = start + 9; + storage.Rematerialize(CreateRangeData(start, end, domain)); + } + }); + + // Reader task: continuously reads and verifies data consistency + var reader = Task.Run(() => + { + for (var i = 0; i < iterations && !cts.Token.IsCancellationRequested; i++) + { + try + { + // Read whatever range is currently active — both legal values are [0,9] and [100,109] + var currentRange = storage.Range; + if (currentRange.Start.Value == 0 || currentRange.Start.Value == 100) + { + var data = storage.Read(currentRange); + // Verify data is internally consistent: each element equals its range position + var expectedStart = currentRange.Start.Value; + for (var j = 0; j < data.Length; j++) + { + if (data.Span[j] != expectedStart + j) + throw new InvalidOperationException( + $"Data corruption at index {j}: expected {expectedStart + j}, got {data.Span[j]}. Range={currentRange}"); + } + } + } + catch (ArgumentOutOfRangeException) + { + // Acceptable: Range and _activeStorage are updated under the lock together, + // but we read Range before acquiring the lock in the reader loop above. + // A stale Range read that no longer matches _activeStorage will throw here. + // This is a benign TOCTOU on the Range property itself (which is not locked + // outside of Rematerialize), not a data corruption — ignore it. + } + catch (Exception ex) + { + exceptions.Add(ex); + cts.Cancel(); + } + } + }); + + await Task.WhenAll(writer, reader); + + // ASSERT - No data corruption detected + Assert.Empty(exceptions); + } + + [Fact] + public async Task ThreadSafety_ConcurrentRematerializeWithDerivedData_NeverCorrupts() + { + // ARRANGE - Verify that the staging buffer + lock pattern prevents corruption when + // rangeData.Data is a LINQ chain over _activeStorage (the expansion scenario). + // This is the primary correctness scenario for the dual-buffer design. + var domain = CreateFixedStepDomain(); + var storage = new CopyOnReadStorage(domain); + storage.Rematerialize(CreateRangeData(0, 9, domain)); + + const int iterations = 500; + var exceptions = new System.Collections.Concurrent.ConcurrentBag(); + + // Writer task: repeatedly expands by deriving new data from current active storage + var writer = Task.Run(() => + { + for (var i = 0; i < iterations; i++) + { + var currentData = storage.ToRangeData(); + // Build new data as a LINQ chain over current active storage (tied to _activeStorage) + var newData = currentData.Data.Concat(Enumerable.Range(0, 5)).ToArray(); + var newRange = CreateRange(0, newData.Length - 1); + storage.Rematerialize(newData.ToRangeData(newRange, domain)); + + // Reset to small range to keep the test bounded + storage.Rematerialize(CreateRangeData(0, 9, domain)); + } + }); + + // Reader task: reads while writer is expanding + var reader = Task.Run(() => + { + for (var i = 0; i < iterations * 4; i++) + { + try + { + var data = storage.Read(storage.Range); + Assert.True(data.Length > 0); + } + catch (ArgumentOutOfRangeException) + { + // Benign TOCTOU on Range property read (see above) — not a data corruption + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + }); + + await Task.WhenAll(writer, reader); + + // ASSERT - No data corruption detected + Assert.Empty(exceptions); + } + + #endregion + #region Domain-Agnostic Tests [Fact] From 7d0c12fd1a03b7b79933f6bc936275aea27470fe Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Fri, 27 Feb 2026 03:47:48 +0100 Subject: [PATCH 04/21] refactor: update documentation to reflect changes in cache state management --- AGENTS.md | 6 +-- README.md | 2 +- docs/actors-and-responsibilities.md | 10 ++-- docs/actors-to-components-mapping.md | 2 +- docs/architecture-model.md | 2 +- docs/cache-state-machine.md | 18 ++++---- docs/component-map.md | 38 +++++++-------- docs/glossary.md | 4 +- docs/invariants.md | 8 ++-- docs/scenario-model.md | 46 +++++++++---------- .../Execution/CacheDataExtensionService.cs | 2 +- ...hannelBasedRebalanceExecutionController.cs | 2 +- .../Rebalance/Execution/RebalanceExecutor.cs | 2 +- .../TaskBasedRebalanceExecutionController.cs | 2 +- .../Core/Rebalance/Intent/IntentController.cs | 2 +- .../Core/State/CacheState.cs | 12 +++-- .../Core/UserPath/UserRequestHandler.cs | 20 ++++---- .../Storage/CopyOnReadStorage.cs | 4 ++ .../Storage/SnapshotReadStorage.cs | 5 +- .../EventCounterCacheDiagnostics.cs | 2 +- .../Instrumentation/ICacheDiagnostics.cs | 2 +- .../Instrumentation/NoOpDiagnostics.cs | 2 +- src/SlidingWindowCache/Public/WindowCache.cs | 4 +- .../BoundaryHandlingTests.cs | 2 +- .../CacheDataSourceInteractionTests.cs | 2 +- .../ConcurrencyStabilityTests.cs | 2 +- .../DataSourceRangePropagationTests.cs | 2 +- .../RandomRangeRobustnessTests.cs | 2 +- .../RangeSemanticsContractTests.cs | 2 +- .../RebalanceExceptionHandlingTests.cs | 2 +- .../UserPathExceptionHandlingTests.cs | 2 +- .../README.md | 12 ++--- .../WindowCacheInvariantTests.cs | 2 +- .../Helpers/TestHelpers.cs | 2 +- .../Instrumentation/NoOpDiagnosticsTests.cs | 42 ----------------- 35 files changed, 117 insertions(+), 154 deletions(-) rename src/SlidingWindowCache/{Infrastructure => Public}/Instrumentation/EventCounterCacheDiagnostics.cs (99%) rename src/SlidingWindowCache/{Infrastructure => Public}/Instrumentation/ICacheDiagnostics.cs (99%) rename src/SlidingWindowCache/{Infrastructure => Public}/Instrumentation/NoOpDiagnostics.cs (96%) delete mode 100644 tests/SlidingWindowCache.Unit.Tests/Infrastructure/Instrumentation/NoOpDiagnosticsTests.cs diff --git a/AGENTS.md b/AGENTS.md index 4a3116c..d9467e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,7 @@ using Intervals.NET; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Planning; using SlidingWindowCache.Core.State; -using SlidingWindowCache.Infrastructure.Instrumentation; +using SlidingWindowCache.Public.Instrumentation; ``` ### XML Documentation @@ -207,7 +207,7 @@ catch (Exception ex) ### Concurrency Patterns **Single-Writer Architecture (CRITICAL):** -- User Path: READ-ONLY (never mutates Cache, LastRequested, or NoRebalanceRange) +- User Path: READ-ONLY (never mutates Cache, IsInitialized, or NoRebalanceRange) - Rebalance Execution: SINGLE WRITER (sole authority for cache mutations) - Serialization: Channel-based with single reader/single writer (intent processing loop) @@ -342,6 +342,7 @@ refactor: AsyncActivityCounter lock has been removed and replaced with lock-free - `src/SlidingWindowCache/Public/WindowCache.cs` - Main cache facade - `src/SlidingWindowCache/Public/IDataSource.cs` - Data source contract - `src/SlidingWindowCache/Public/Configuration/` - Configuration classes +- `src/SlidingWindowCache/Public/Instrumentation/` - Diagnostics **Core Logic:** - `src/SlidingWindowCache/Core/UserPath/` - User request handling (read-only) @@ -351,7 +352,6 @@ refactor: AsyncActivityCounter lock has been removed and replaced with lock-free **Infrastructure:** - `src/SlidingWindowCache/Infrastructure/Storage/` - Storage strategies -- `src/SlidingWindowCache/Infrastructure/Instrumentation/` - Diagnostics - `src/SlidingWindowCache/Infrastructure/Concurrency/` - Async coordination ## CI/CD diff --git a/README.md b/README.md index bfd4239..0217892 100644 --- a/README.md +++ b/README.md @@ -828,7 +828,7 @@ For production systems, consider: ### Using Diagnostics ```csharp -using SlidingWindowCache.Infrastructure.Instrumentation; +using SlidingWindowCache.Public.Instrumentation; // Create diagnostics instance var diagnostics = new EventCounterCacheDiagnostics(); diff --git a/docs/actors-and-responsibilities.md b/docs/actors-and-responsibilities.md index 96f3188..d41acb1 100644 --- a/docs/actors-and-responsibilities.md +++ b/docs/actors-and-responsibilities.md @@ -51,7 +51,7 @@ The UserRequestHandler NEVER invokes directly decision logic - it just publishes - ❌ **NEVER computes DesiredCacheRange** (belongs to GeometryPolicy) - ❌ **NEVER decides whether to rebalance** (belongs to DecisionEngine) - ❌ **NEVER writes to cache** (no Rematerialize calls) -- ❌ **NEVER writes to LastRequested** +- ❌ **NEVER writes to IsInitialized** - ❌ **NEVER writes to NoRebalanceRange** **Responsibility Type:** ensures and enforces fast, correct user access with strict read-only boundaries @@ -220,7 +220,7 @@ The **ONLY component** that mutates cache state (single-writer architecture). Pe **Single-Writer Guarantee:** Rebalance Executor is the ONLY component that mutates: - Cache data and range (via `Cache.Rematerialize()`) -- `LastRequested` field +- `IsInitialized` field - `NoRebalanceRange` field This eliminates race conditions and ensures consistent cache state. @@ -244,7 +244,7 @@ Executor is **mechanically simple** with no analytical logic: - Expanding to DesiredCacheRange by fetching only truly missing ranges - Trimming excess data outside DesiredCacheRange - Writing to Cache.Rematerialize() - - Writing to LastRequested + - Writing to IsInitialized (= true) - Recomputing NoRebalanceRange - 36. May replace / expand / shrink cache to achieve normalization - 37. Requests data only for missing subranges (not covered by delivered data) @@ -292,9 +292,9 @@ This table maps **actors** to the scenarios they participate in and clarifies ** | Scenario | User Path | Decision Engine | Geometry Policy | IntentController | Rebalance Executor | Cache State Manager | Notes | |-----------------------------------------|-------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------|----------------------------|------------------------------------------|----------------------------------------------------------------------------------------|--------------------------------------------------------|--------------------------------------------| -| **U1 – Cold Cache** | Requests data from IDataSource, returns data to user, publishes rebalance intent | – | Computes DesiredCacheRange | Receives intent | Executes rebalance asynchronously (writes LastRequested, CurrentCacheRange, CacheData) | Validates atomic update of CacheData/CurrentCacheRange | User served directly | +| **U1 – Cold Cache** | Requests data from IDataSource, returns data to user, publishes rebalance intent | – | Computes DesiredCacheRange | Receives intent | Executes rebalance asynchronously (writes IsInitialized, CurrentCacheRange, CacheData) | Validates atomic update of CacheData/CurrentCacheRange | User served directly | | **U2 – Full Cache Hit (Exact)** | Reads from cache, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes if rebalance required | Monitors consistency | Minimal I/O | -| **U3 – Full Cache Hit (Shifted)** | Reads subrange from cache, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes if rebalance required | Monitors consistency | Cache hit but different LastRequestedRange | +| **U3 – Full Cache Hit (Shifted)** | Reads subrange from cache, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes if rebalance required | Monitors consistency | Cache hit but shifted range | | **U4 – Partial Cache Hit** | Reads intersection, requests missing from IDataSource, merges locally, returns data to user, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes merge and normalization | Ensures atomic merge & consistency | Temporary excess data allowed | | **U5 – Full Cache Miss (Jump)** | Requests full range from IDataSource, returns data to user, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes full normalization | Ensures atomic replacement | No cached data usable | | **D1 – NoRebalanceRange Block** | – | Checks NoRebalanceRange, decides no execution | – | Receives intent (blocked) | – | – | Fast path skip | diff --git a/docs/actors-to-components-mapping.md b/docs/actors-to-components-mapping.md index 5f8deb7..fab7ed0 100644 --- a/docs/actors-to-components-mapping.md +++ b/docs/actors-to-components-mapping.md @@ -177,7 +177,7 @@ It delegates all behavioral logic to internal actors. - from IDataSource - or mixed - Updates: - - LastRequestedRange + - IsInitialized (set to true after first rebalance execution) - CacheData / CurrentCacheRange **only to cover RequestedRange** - Triggers rebalance intent - Never blocks on rebalance diff --git a/docs/architecture-model.md b/docs/architecture-model.md index 950ddfe..dceed05 100644 --- a/docs/architecture-model.md +++ b/docs/architecture-model.md @@ -58,7 +58,7 @@ The cache implements a **single-writer** concurrency model: Only `RebalanceExecutor` may write to `CacheState` fields: - Cache data and range (via `Cache.Rematerialize()` atomic swap) -- `LastRequested` property (via `internal set` - restricted to rebalance execution) +- `IsInitialized` property (via `internal set` - restricted to rebalance execution) - `NoRebalanceRange` property (via `internal set` - restricted to rebalance execution) All other components have read-only access to cache state (public getters only). diff --git a/docs/cache-state-machine.md b/docs/cache-state-machine.md index 35c4d32..f21fd3d 100644 --- a/docs/cache-state-machine.md +++ b/docs/cache-state-machine.md @@ -18,7 +18,7 @@ The cache exists in one of three states: - **Characteristics:** - `CurrentCacheRange == null` - `CacheData == null` - - `LastRequestedRange == null` + - `IsInitialized == false` - `NoRebalanceRange == null` ### 2. **Initialized** @@ -88,7 +88,7 @@ The cache exists in one of three states: - **Mutation:** Performed by Rebalance Execution ONLY (single-writer) - Set `CacheData` = delivered data from intent - Set `CurrentCacheRange` = delivered range - - Set `LastRequestedRange` = `RequestedRange` + - Set `IsInitialized` = true - **Atomicity:** Changes applied atomically (Invariant 12) - **Postcondition:** Cache enters `Initialized` state after rebalance execution completes - **Note:** User Path is read-only; initial cache population is performed by Rebalance Execution @@ -105,7 +105,7 @@ The cache exists in one of three states: 6. **Work avoidance:** If validation rejects (NoRebalanceRange containment, pending coverage, Desired==Current), no cancellation occurs and execution skipped entirely 7. Rebalance Execution writes to cache (background, only if validated as necessary) - **Mutation:** Performed by Rebalance Execution ONLY (single-writer architecture) - - User Path does NOT mutate cache, LastRequested, or NoRebalanceRange (read-only) + - User Path does NOT mutate cache, IsInitialized, or NoRebalanceRange (read-only) - Rebalance Execution normalizes cache to DesiredCacheRange (only if validated) - **Concurrency:** User Path is read-only; no race conditions - **Cancellation Model:** Mechanical coordination tool (prevents concurrent executions), NOT decision mechanism; validation determines necessity @@ -120,7 +120,7 @@ The cache exists in one of three states: - Merge delivered data with fetched data - Trim to `DesiredCacheRange` (normalization) - Set `CacheData` and `CurrentCacheRange` via `Rematerialize()` - - Set `LastRequestedRange` = original requested range from intent + - Set `IsInitialized` = true - Recompute `NoRebalanceRange` - **Atomicity:** Changes applied atomically (Invariant 12) - **Postcondition:** Cache returns to stable `Initialized` state @@ -149,14 +149,14 @@ The cache exists in one of three states: |---------------|---------------------|----------------------------------------------------------------------------------------------------------------------| | Uninitialized | ❌ None | ✅ Initial cache write (after first user request) | | Initialized | ❌ None | ❌ Not active | -| Rebalancing | ❌ None | ✅ All cache mutations (expand, trim, write to cache/LastRequested/NoRebalanceRange)
⚠️ MUST yield on cancellation | +| Rebalancing | ❌ None | ✅ All cache mutations (expand, trim, write to cache/IsInitialized/NoRebalanceRange)
⚠️ MUST yield on cancellation | ### Mutation Rules Summary **User Path mutations (Invariant 8 - NEW):** - ❌ **NONE** - User Path is read-only with respect to cache state - User Path NEVER calls `Cache.Rematerialize()` -- User Path NEVER writes to `LastRequested` +- User Path NEVER writes to `IsInitialized` - User Path NEVER writes to `NoRebalanceRange` **Rebalance Execution mutations (Invariant 36, 36a):** @@ -164,7 +164,7 @@ The cache exists in one of three states: 2. Expanding to `DesiredCacheRange` (fetch only truly missing ranges) 3. Trimming excess data outside `DesiredCacheRange` 4. Writing to `Cache.Rematerialize()` (cache data and range) -5. Writing to `LastRequested` +5. Writing to `IsInitialized` (= true) 6. Recomputing and writing to `NoRebalanceRange` **Single-Writer Architecture (Invariant -1):** @@ -237,7 +237,7 @@ User requests [100, 200] → User Path returns data to user immediately → User Path publishes intent with delivered data → Rebalance Execution writes to cache (first cache write) -→ Sets CacheData, CurrentCacheRange, LastRequested +→ Sets CacheData, CurrentCacheRange, IsInitialized → Triggers rebalance (fire-and-forget) State: Initialized ``` @@ -285,7 +285,7 @@ State: Rebalancing (R2 executing - will eventually replace cache) This state machine enforces three critical architectural constraints: 1. **Single-Writer Architecture:** Only Rebalance Execution mutates cache state (Invariant 36) -2. **User Path Read-Only:** User Path never mutates cache, LastRequested, or NoRebalanceRange (Invariant 8) +2. **User Path Read-Only:** User Path never mutates cache, IsInitialized, or NoRebalanceRange (Invariant 8) 3. **User Priority via Cancellation:** User requests cancel rebalance to prevent interference, not for mutation exclusion (Invariants 0, 0a) The state machine guarantees: diff --git a/docs/component-map.md b/docs/component-map.md index 7411a41..ddecf52 100644 --- a/docs/component-map.md +++ b/docs/component-map.md @@ -772,14 +772,14 @@ internal sealed class CopyOnReadStorage : ICacheStorage< --- -### 3. Diagnostics Infrastructure +### 3. Diagnostics (Public) #### 🟧 ICacheDiagnostics ```csharp public interface ICacheDiagnostics ``` -**File**: `src/SlidingWindowCache/Infrastructure/Instrumentation/ICacheDiagnostics.cs` +**File**: `src/SlidingWindowCache/Public/Instrumentation/ICacheDiagnostics.cs` **Type**: Interface (public) @@ -836,7 +836,7 @@ public interface ICacheDiagnostics public class EventCounterCacheDiagnostics : ICacheDiagnostics ``` -**File**: `src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs` +**File**: `src/SlidingWindowCache/Public/Instrumentation/EventCounterCacheDiagnostics.cs` **Type**: Class (public, thread-safe) @@ -883,7 +883,7 @@ public class EventCounterCacheDiagnostics : ICacheDiagnostics public class NoOpDiagnostics : ICacheDiagnostics ``` -**File**: `src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs` +**File**: `src/SlidingWindowCache/Public/Instrumentation/NoOpDiagnostics.cs` **Type**: Class (public, singleton-compatible) @@ -925,7 +925,7 @@ internal sealed class CacheState **State Components**: - Cache storage instance (ICacheStorage implementation) -- Last requested range (tracks user's most recent request) +- IsInitialized flag (tracks whether cache has been initialized) - No-rebalance range (stable region where rebalancing is suppressed) - Domain instance (for range calculations) @@ -935,11 +935,11 @@ internal sealed class CacheState **Shared with** (read/write): - **UserRequestHandler** ⊳ (READ-ONLY) - - Reads: `Cache.Range`, `Cache.Read()`, `Cache.ToRangeData()`, `LastRequested` + - Reads: `Cache.Range`, `Cache.Read()`, `Cache.ToRangeData()`, `IsInitialized` - ❌ Does NOT write to CacheState - **RebalanceExecutor** ⊲⊳ (SOLE WRITER) - Reads: `Cache.Range`, `Cache.ToRangeData()` - - Writes: `Cache.Rematerialize()`, `NoRebalanceRange`, `LastRequested` + - Writes: `Cache.Rematerialize()`, `NoRebalanceRange`, `IsInitialized` - **RebalanceDecisionEngine** ⊳ (via IntentController.ProcessIntentsAsync) - Reads: `NoRebalanceRange`, `Cache.Range` @@ -983,14 +983,14 @@ public async ValueTask> HandleRequestAsync( ``` **Operation Flow**: -1. **Check cold start** - `_state.LastRequested.HasValue` +1. **Check cold start** - `!_state.IsInitialized` 2. **Serve from cache or data source** - varies by scenario (cold start / full hit / partial hit / full miss) 3. **Publish rebalance intent** - `_intentController.PublishIntent(intent)` with assembled data (fire-and-forget) 4. **Return data** - return assembled `ReadOnlyMemory` **Reads from**: - ⊳ `_state.Cache` (Range, Read, ToRangeData) -- ⊳ `_state.LastRequested` (cold-start detection) +- ⊳ `_state.IsInitialized` (cold-start detection) - ⊳ `_state.Domain` **Writes to**: @@ -1004,7 +1004,7 @@ public async ValueTask> HandleRequestAsync( **Characteristics**: - ✅ Executes in **User Thread** - ✅ Always serves user requests (never waits for rebalance) -- ✅ **READ-ONLY with respect to CacheState** (never writes Cache, LastRequested, or NoRebalanceRange) +- ✅ **READ-ONLY with respect to CacheState** (never writes Cache, IsInitialized, or NoRebalanceRange) - ✅ Always triggers rebalance intent after serving - ❌ **Never** trims or normalizes cache - ❌ **Never** invokes decision logic @@ -1376,7 +1376,7 @@ internal sealed class RebalanceExecutor **Writes to**: - ⊲ `_state.Cache` (via Rematerialize - normalizes to DesiredCacheRange) -- ⊲ `_state.LastRequested` +- ⊲ `_state.IsInitialized` - ⊲ `_state.NoRebalanceRange` **Uses**: @@ -1725,7 +1725,7 @@ Creates and wires all internal components in dependency order: │ │ │ │ │ │ │ ❌ NEVER writes to CacheState │ │ │ │ │ │ ❌ NEVER calls Cache.Rematerialize() │ │ │ │ │ -│ ❌ NEVER writes LastRequested or NoRebalanceRange │ │ │ │ │ +│ ❌ NEVER writes IsInitialized or NoRebalanceRange │ │ │ │ │ └─────────────────────────────────────────────────────┼───┼───┼───┼───┘ │ │ │ │ ══════════════════════════════════════════════╪═══╪═══╪═══╪═══ @@ -1807,7 +1807,7 @@ Creates and wires all internal components in dependency order: │ 8. UpdateCacheState(rebalanced, requestedRange, desiredNRR) │ │ │ └─ _state.Cache.Rematerialize(rebalanced) ────────────────┐ │ │ │ └─ _state.NoRebalanceRange = desiredNRR ──────────────────┼───┤ │ -│ └─ _state.LastRequested = requestedRange ─────────────────┼───┤ │ +│ └─ _state.IsInitialized = true ───────────────────┼───┤ │ │ finally: _executionSemaphore.Release() │ │ │ └─────────────────────────────────────────────────────────────────┼───┼─┘ │ │ @@ -1817,12 +1817,12 @@ Creates and wires all internal components in dependency order: │ │ │ │ Properties: │ │ │ ├─ ICacheStorage Cache ◄─ RebalanceExecutor (SOLE WRITER) ─────────┤ │ -│ ├─ Range? LastRequested ◄─ RebalanceExecutor │ │ +│ ├─ bool IsInitialized ◄─ RebalanceExecutor │ │ │ ├─ Range? NoRebalanceRange ◄─ RebalanceExecutor │ │ │ └─ TDomain Domain (readonly) │ │ │ │ │ │ Read by: │ │ -│ ├─ UserRequestHandler (Cache.Range, Cache.Read, Cache.ToRangeData, LastRequested) +│ ├─ UserRequestHandler (Cache.Range, Cache.Read, Cache.ToRangeData, IsInitialized) │ ├─ RebalanceExecutor (Cache.Range, Cache.ToRangeData) │ │ │ └─ RebalanceDecisionEngine (NoRebalanceRange, Cache.Range) │ │ └──────────────────────────────────────────────────────────────────────┼──┘ @@ -1897,8 +1897,8 @@ Creates and wires all internal components in dependency order: - **Purpose**: Normalize cache to DesiredCacheRange using delivered data from intent - **When**: Rebalance execution completes (background) - **Scope**: Expands, trims, or replaces cache as needed -- ✏️ Writes `LastRequested` property - - **Purpose**: Record the range that triggered this rebalance +- ✏️ Writes `IsInitialized` property + - **Purpose**: Mark cache as initialized after first successful rebalance - **When**: After successful rebalance execution - ✏️ Writes `NoRebalanceRange` property - **Purpose**: Update threshold zone after normalization @@ -1907,7 +1907,7 @@ Creates and wires all internal components in dependency order: **UserRequestHandler** (READ-ONLY): - ❌ Does NOT write to CacheState - ❌ Does NOT call `Cache.Rematerialize()` -- ❌ Does NOT write to `LastRequested` or `NoRebalanceRange` +- ❌ Does NOT write to `IsInitialized` or `NoRebalanceRange` - ✅ Only reads from cache and IDataSource - ✅ Publishes intent with delivered data for Rebalance Execution to process @@ -2118,7 +2118,7 @@ The Sliding Window Cache follows a **single consumer model** as documented in `d │ │ │ CACHE MUTATION │ │ │ │ │ (SINGLE WRITER) │ │ │ │ │ • Cache.Rematerialize() │ │ -│ │ │ • LastRequested = ... │ │ +│ │ │ • IsInitialized = true │ │ │ │ │ • NoRebalanceRange = ... │ │ │ │ └──────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ diff --git a/docs/glossary.md b/docs/glossary.md index d830201..9edc1f6 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -49,7 +49,7 @@ Mathematical domain for range operations. Must implement `IRangeDomain`. ## Architectural Patterns ### Single-Writer Architecture -Only ONE component (`RebalanceExecutor`) mutates shared state (Cache, LastRequested, NoRebalanceRange). All others read-only. Eliminates write-write conflicts. See [Architecture Model](architecture-model.md#single-writer-architecture) | [Component Map - Implementation](component-map.md#single-writer-architecture). +Only ONE component (`RebalanceExecutor`) mutates shared state (Cache, IsInitialized, NoRebalanceRange). All others read-only. Eliminates write-write conflicts. See [Architecture Model](architecture-model.md#single-writer-architecture) | [Component Map - Implementation](component-map.md#single-writer-architecture). ### Decision-Driven Execution Multi-stage validation pipeline separating decisions from execution. `RebalanceDecisionEngine` is sole authority for rebalance necessity. Execution proceeds only if all stages pass. Prevents thrashing. See [Architecture Model](architecture-model.md#decision-driven-execution) | [Invariants D.29](invariants.md#d-rebalance-decision-path-invariants). @@ -114,7 +114,7 @@ Delays execution (e.g., 100ms) to let bursts settle. Cancels previous if new sch **Activity**: Operation tracked by `AsyncActivityCounter`. System idle when count = 0. **Idle State**: No intents/rebalances executing. **"Was Idle" NOT "Is Idle"** - `WaitForIdleAsync()` = was idle at some point. See [Invariants H.49](invariants.md#h-activity-tracking--idle-detection-invariants). **Stabilization**: Reaching stable state (rebalances done, cache = desired, no pending intents). Not persistent. -**Cache State**: Mutable container (`Cache`, `LastRequested`, `NoRebalanceRange`). Only mutated by `RebalanceExecutor`. See [Invariant F.36](invariants.md#f-rebalance-execution-invariants). +**Cache State**: Mutable container (`Cache`, `IsInitialized`, `NoRebalanceRange`). Only mutated by `RebalanceExecutor`. See [Invariant F.36](invariants.md#f-rebalance-execution-invariants). **Execution Request**: Rebalance request from `IntentController` → `RebalanceExecutionController`. Contains desired ranges, intent data, cancellation token. --- diff --git a/docs/invariants.md b/docs/invariants.md index 1de8074..0abd40f 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -261,7 +261,7 @@ without polling or timing dependencies. **Formal Specification:** - User Path has read-only access to cache state - No write operations permitted in User Path -- Cache, LastRequested, and NoRebalanceRange are immutable from User Path perspective +- Cache, IsInitialized, and NoRebalanceRange are immutable from User Path perspective **Rationale:** Enforces single-writer architecture, eliminating write-write races and simplifying concurrency reasoning. @@ -272,7 +272,7 @@ without polling or timing dependencies. **Formal Specification:** - User Path is strictly read-only with respect to cache state - User Path never triggers cache rematerialization -- User Path never updates LastRequested or NoRebalanceRange +- User Path never updates IsInitialized or NoRebalanceRange - All cache mutations exclusively performed by Rebalance Execution (single-writer) **Rationale:** Enforces single-writer architecture at the strictest level, preventing any mutation-related bugs in User Path. @@ -644,7 +644,7 @@ leftThreshold.HasValue && rightThreshold.HasValue **Formal Specification:** - Only one component has write permission to cache state -- Exclusive mutation authority: Cache, LastRequested, NoRebalanceRange +- Exclusive mutation authority: Cache, IsInitialized, NoRebalanceRange - All other components are read-only **Rationale:** Single-writer architecture eliminates all write-write races and simplifies concurrency reasoning. @@ -656,7 +656,7 @@ leftThreshold.HasValue && rightThreshold.HasValue - **Expanding to DesiredCacheRange** by fetching only truly missing ranges - **Trimming excess data** outside `DesiredCacheRange` - **Writing to cache** via `Cache.Rematerialize()` - - **Writing to LastRequested** with original requested range + - **Writing to IsInitialized** = true after successful rebalance - **Recomputing NoRebalanceRange** based on final cache range - *Observable via*: After rebalance, cache serves data from expanded range - *Test verifies*: Cache covers larger area after rebalance completes diff --git a/docs/scenario-model.md b/docs/scenario-model.md index e916d29..6194387 100644 --- a/docs/scenario-model.md +++ b/docs/scenario-model.md @@ -22,8 +22,8 @@ The following terms are used consistently across all scenarios: - **RequestedRange** A range requested by the user. -- **LastRequestedRange** - The most recent range served by the User Path. +- **IsInitialized** + Whether the cache has been initialized (i.e., Rebalance Execution has written to the cache at least once). - **CurrentCacheRange** The range of data currently stored in the cache. @@ -72,7 +72,7 @@ The User Path is responsible only for: ### Preconditions -- `LastRequestedRange == null` +- `IsInitialized == false` - `CurrentCacheRange == null` - `CacheData == null` @@ -85,49 +85,47 @@ The User Path is responsible only for: 4. A rebalance intent is published (fire-and-forget) with the fetched data 5. Data is immediately returned to the user 6. Rebalance execution (background) stores the data as CacheData, - sets CurrentCacheRange to RequestedRange, and sets LastRequestedRange to RequestedRange + sets CurrentCacheRange to RequestedRange, and sets IsInitialized to true **Note:** The User Path does not expand the cache beyond RequestedRange. --- -## User Scenario U2 — Full Cache Hit (Exact Match with LastRequestedRange) +## User Scenario U2 — Full Cache Hit (Within NoRebalanceRange) ### Preconditions -- Cache is initialized -- `RequestedRange == LastRequestedRange` +- `IsInitialized == true` - `CurrentCacheRange.Contains(RequestedRange) == true` +- `NoRebalanceRange.Contains(RequestedRange) == true` ### Action Sequence 1. User requests RequestedRange 2. Cache detects a full cache hit 3. Data is read from CacheData -4. LastRequestedRange is updated -5. Rebalance is triggered asynchronously - (because `NoRebalanceRange.Contains(RequestedRange)` may be false) -6. Data is returned to the user +4. Rebalance intent is published but Decision Engine skips execution + (because `NoRebalanceRange.Contains(RequestedRange) == true`) +5. Data is returned to the user --- -## User Scenario U3 — Full Cache Hit (Shifted Range) +## User Scenario U3 — Full Cache Hit (Outside NoRebalanceRange) ### Preconditions -- Cache is initialized -- `RequestedRange != LastRequestedRange` +- `IsInitialized == true` - `CurrentCacheRange.Contains(RequestedRange) == true` +- `NoRebalanceRange.Contains(RequestedRange) == false` ### Action Sequence 1. User requests RequestedRange 2. Cache detects that all requested data is available 3. Subrange is read from CacheData -4. LastRequestedRange is updated -5. Rebalance is triggered asynchronously -6. Data is returned to the user +4. Rebalance is triggered asynchronously +5. Data is returned to the user --- @@ -135,7 +133,7 @@ The User Path does not expand the cache beyond RequestedRange. ### Preconditions -- Cache is initialized +- `IsInitialized == true` - `CurrentCacheRange.Intersects(RequestedRange) == true` - `CurrentCacheRange.Contains(RequestedRange) == false` @@ -148,9 +146,8 @@ The User Path does not expand the cache beyond RequestedRange. - merges cached and newly fetched data (cache expansion) - does **not** trim excess data - updates CurrentCacheRange to cover both old and new data -5. LastRequestedRange is updated -6. Rebalance is triggered asynchronously -7. RequestedRange data is returned to the user +5. Rebalance is triggered asynchronously +6. RequestedRange data is returned to the user **Note:** Cache expansion is permitted here because RequestedRange intersects CurrentCacheRange, @@ -162,7 +159,7 @@ preserving cache contiguity. Excess data may temporarily remain in CacheData for ### Preconditions -- Cache is initialized +- `IsInitialized == true` - `CurrentCacheRange.Intersects(RequestedRange) == false` ### Action Sequence @@ -174,9 +171,8 @@ preserving cache contiguity. Excess data may temporarily remain in CacheData for 5. Cache: - **fully replaces** CacheData with new data - **fully replaces** CurrentCacheRange with RequestedRange -6. LastRequestedRange is updated -7. Rebalance is triggered asynchronously -8. Data is returned to the user +6. Rebalance is triggered asynchronously +7. Data is returned to the user **Critical Note:** Partial cache expansion is FORBIDDEN in this case, as it would create logical gaps diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs index cc90bc6..c6d2b4b 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs @@ -3,9 +3,9 @@ using Intervals.NET.Data.Extensions; using Intervals.NET.Domain.Abstractions; using Intervals.NET.Extensions; -using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Dto; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Core.Rebalance.Execution; diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs index cf2e2d7..80dc420 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs @@ -3,7 +3,7 @@ using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Intent; using SlidingWindowCache.Infrastructure.Concurrency; -using SlidingWindowCache.Infrastructure.Instrumentation; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Core.Rebalance.Execution; diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs index ae8a7f2..f20b0dd 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs @@ -3,7 +3,7 @@ using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Intent; using SlidingWindowCache.Core.State; -using SlidingWindowCache.Infrastructure.Instrumentation; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Core.Rebalance.Execution; diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs index 31faf07..5f5d45c 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs @@ -2,7 +2,7 @@ using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Intent; using SlidingWindowCache.Infrastructure.Concurrency; -using SlidingWindowCache.Infrastructure.Instrumentation; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Core.Rebalance.Execution; diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index fdaf2c8..52a8da6 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -4,7 +4,7 @@ using SlidingWindowCache.Core.Rebalance.Execution; using SlidingWindowCache.Core.State; using SlidingWindowCache.Infrastructure.Concurrency; -using SlidingWindowCache.Infrastructure.Instrumentation; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Core.Rebalance.Intent; diff --git a/src/SlidingWindowCache/Core/State/CacheState.cs b/src/SlidingWindowCache/Core/State/CacheState.cs index ea8a2f1..4d99e49 100644 --- a/src/SlidingWindowCache/Core/State/CacheState.cs +++ b/src/SlidingWindowCache/Core/State/CacheState.cs @@ -1,7 +1,6 @@ using Intervals.NET; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Infrastructure.Storage; -using SlidingWindowCache.Public; namespace SlidingWindowCache.Core.State; @@ -38,13 +37,16 @@ internal sealed class CacheState public ICacheStorage Storage { get; } /// - /// The last requested range that triggered a cache access. + /// Indicates whether the cache has been populated at least once (i.e., a rebalance execution + /// has completed successfully at least once). /// /// /// SINGLE-WRITER: Only Rebalance Execution Path may write to this field, via . /// User Path is read-only with respect to cache state. + /// false means the cache is in a cold/uninitialized state; true means it has + /// been populated at least once and the User Path may read from the storage. /// - public Range? LastRequested { get; private set; } + public bool IsInitialized { get; private set; } /// /// The range within which no rebalancing should occur. @@ -77,7 +79,7 @@ public CacheState(ICacheStorage cacheStorage, TDomain do /// This is the ONLY method that may write to the mutable fields on this class. /// /// The normalized range data to write into storage. - /// The original range requested by the user, stored as . + /// The original range requested by the user; used to populate the storage and mark the cache as initialized. /// The pre-computed no-rebalance range for the new state. /// /// Single-Writer Contract: @@ -93,7 +95,7 @@ internal void UpdateCacheState( Range? noRebalanceRange) { Storage.Rematerialize(normalizedData); - LastRequested = requestedRange; + IsInitialized = true; NoRebalanceRange = noRebalanceRange; } } diff --git a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs index 9dbc2ce..0b9178a 100644 --- a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs +++ b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs @@ -6,9 +6,9 @@ using SlidingWindowCache.Core.Rebalance.Execution; using SlidingWindowCache.Core.Rebalance.Intent; using SlidingWindowCache.Core.State; -using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Dto; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Core.UserPath; @@ -109,7 +109,7 @@ ICacheDiagnostics cacheDiagnostics /// ✅ May READ from cache /// ✅ May READ from IDataSource /// ❌ NEVER writes to Cache (no Rematerialize calls) - /// ❌ NEVER writes to LastRequested + /// ❌ NEVER writes to IsInitialized /// ❌ NEVER writes to NoRebalanceRange /// /// @@ -134,7 +134,7 @@ public async ValueTask> HandleRequestAsync( // Check if cache is cold (never used) - use ToRangeData to detect empty cache var cacheStorage = _state.Storage; - var isColdStart = !_state.LastRequested.HasValue; + var isColdStart = !_state.IsInitialized; RangeData? assembledData = null; var exceptionOccurred = false; @@ -309,15 +309,15 @@ internal async ValueTask DisposeAsync() .ConfigureAwait(false); // Handle boundary: chunk.Range may be null or truncated - if (fetchedChunk.Range.HasValue) + if (!fetchedChunk.Range.HasValue) { - var assembledData = fetchedChunk.Data.ToRangeData(fetchedChunk.Range.Value, _state.Domain); - var actualRange = fetchedChunk.Range.Value; - var resultData = new ReadOnlyMemory(assembledData.Data.ToArray()); - return (assembledData, actualRange, resultData); + // No data available for requested range (physical boundary miss) + return (null, null, ReadOnlyMemory.Empty); } - // No data available for requested range (physical boundary miss) - return (null, null, ReadOnlyMemory.Empty); + var assembledData = fetchedChunk.Data.ToRangeData(fetchedChunk.Range.Value, _state.Domain); + var actualRange = fetchedChunk.Range.Value; + var resultData = new ReadOnlyMemory(assembledData.Data.ToArray()); + return (assembledData, actualRange, resultData); } } \ No newline at end of file diff --git a/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs b/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs index 0f554c0..de49ff5 100644 --- a/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs +++ b/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs @@ -85,10 +85,14 @@ internal sealed class CopyOnReadStorage : ICacheStorage< private readonly object _lock = new(); // Active storage: serves data to Read() operations; never mutated while _lock is held by Read() + // volatile is NOT needed: both Read() and the swap in Rematerialize() access this field + // exclusively under _lock, which provides full acquire/release fence semantics. private List _activeStorage = []; // Staging buffer: write-only during Rematerialize(); reused across operations // This buffer may grow but never shrinks, amortizing allocation cost + // volatile is NOT needed: _stagingBuffer is only accessed by the rebalance thread outside the lock, + // and inside _lock during the swap — it never crosses thread boundaries directly. private List _stagingBuffer = []; /// diff --git a/src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs b/src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs index 843bc56..a541296 100644 --- a/src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs +++ b/src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs @@ -24,7 +24,10 @@ internal sealed class SnapshotReadStorage : ICacheStorag where TDomain : IRangeDomain { private readonly TDomain _domain; - private TData[] _storage = []; + // volatile: Rematerialize() (rebalance thread) and Read() (user thread) access this field + // concurrently without a lock. volatile provides the acquire/release fence needed to ensure + // the user thread always observes the latest array reference published by the rebalance thread. + private volatile TData[] _storage = []; /// /// Initializes a new instance of the class. diff --git a/src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs b/src/SlidingWindowCache/Public/Instrumentation/EventCounterCacheDiagnostics.cs similarity index 99% rename from src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs rename to src/SlidingWindowCache/Public/Instrumentation/EventCounterCacheDiagnostics.cs index eb5a1d6..c02e7ca 100644 --- a/src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs +++ b/src/SlidingWindowCache/Public/Instrumentation/EventCounterCacheDiagnostics.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace SlidingWindowCache.Infrastructure.Instrumentation; +namespace SlidingWindowCache.Public.Instrumentation; /// /// Default implementation of that uses thread-safe counters to track cache events and metrics. diff --git a/src/SlidingWindowCache/Infrastructure/Instrumentation/ICacheDiagnostics.cs b/src/SlidingWindowCache/Public/Instrumentation/ICacheDiagnostics.cs similarity index 99% rename from src/SlidingWindowCache/Infrastructure/Instrumentation/ICacheDiagnostics.cs rename to src/SlidingWindowCache/Public/Instrumentation/ICacheDiagnostics.cs index 8d86e57..702f018 100644 --- a/src/SlidingWindowCache/Infrastructure/Instrumentation/ICacheDiagnostics.cs +++ b/src/SlidingWindowCache/Public/Instrumentation/ICacheDiagnostics.cs @@ -1,4 +1,4 @@ -namespace SlidingWindowCache.Infrastructure.Instrumentation; +namespace SlidingWindowCache.Public.Instrumentation; /// /// Instance-based diagnostics interface for tracking cache behavioral events in DEBUG mode. diff --git a/src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs b/src/SlidingWindowCache/Public/Instrumentation/NoOpDiagnostics.cs similarity index 96% rename from src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs rename to src/SlidingWindowCache/Public/Instrumentation/NoOpDiagnostics.cs index 211d7d3..b49145f 100644 --- a/src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs +++ b/src/SlidingWindowCache/Public/Instrumentation/NoOpDiagnostics.cs @@ -1,4 +1,4 @@ -namespace SlidingWindowCache.Infrastructure.Instrumentation; +namespace SlidingWindowCache.Public.Instrumentation; /// /// No-op implementation of ICacheDiagnostics for production use where performance is critical and diagnostics are not needed. diff --git a/src/SlidingWindowCache/Public/WindowCache.cs b/src/SlidingWindowCache/Public/WindowCache.cs index 81d2be8..f14db00 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; @@ -7,10 +7,10 @@ using SlidingWindowCache.Core.State; using SlidingWindowCache.Core.UserPath; using SlidingWindowCache.Infrastructure.Concurrency; -using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Infrastructure.Storage; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Dto; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Public; diff --git a/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs index 9f1a397..ebc3b01 100644 --- a/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs @@ -1,8 +1,8 @@ using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Tests.Infrastructure.DataSources; -using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Integration.Tests; diff --git a/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs b/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs index 9648663..dbc0622 100644 --- a/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs @@ -1,9 +1,9 @@ using Intervals.NET.Domain.Default.Numeric; using Intervals.NET.Domain.Extensions.Fixed; using SlidingWindowCache.Tests.Infrastructure.DataSources; -using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Integration.Tests; diff --git a/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs b/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs index 63f5fa0..a3dd306 100644 --- a/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/ConcurrencyStabilityTests.cs @@ -1,9 +1,9 @@ using Intervals.NET; using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Tests.Infrastructure.DataSources; -using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Integration.Tests; diff --git a/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs b/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs index 023676e..9667576 100644 --- a/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs @@ -1,8 +1,8 @@ using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Tests.Infrastructure.DataSources; -using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Integration.Tests; diff --git a/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs b/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs index 1d0d50a..583616a 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RandomRangeRobustnessTests.cs @@ -2,9 +2,9 @@ using Intervals.NET.Domain.Default.Numeric; using Intervals.NET.Domain.Extensions.Fixed; using SlidingWindowCache.Tests.Infrastructure.DataSources; -using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Integration.Tests; diff --git a/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs b/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs index 4ba3bc2..6499bc9 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RangeSemanticsContractTests.cs @@ -1,9 +1,9 @@ using Intervals.NET.Domain.Default.Numeric; using Intervals.NET.Domain.Extensions.Fixed; using SlidingWindowCache.Tests.Infrastructure.DataSources; -using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Integration.Tests; diff --git a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs index 03a8e5c..089d431 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs @@ -1,9 +1,9 @@ using Intervals.NET; using Intervals.NET.Domain.Default.Numeric; -using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Integration.Tests; diff --git a/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs index 0dcecfd..c71b131 100644 --- a/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/UserPathExceptionHandlingTests.cs @@ -1,8 +1,8 @@ using Intervals.NET.Domain.Default.Numeric; -using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Integration.Tests; diff --git a/tests/SlidingWindowCache.Invariants.Tests/README.md b/tests/SlidingWindowCache.Invariants.Tests/README.md index 28638b0..dc60a92 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/README.md +++ b/tests/SlidingWindowCache.Invariants.Tests/README.md @@ -68,8 +68,8 @@ Converted tests: ## Implementation Details -### 1. Instrumentation Infrastructure -- **Location**: `src/SlidingWindowCache/Infrastructure/Instrumentation/` +### 1. Instrumentation (Public) +- **Location**: `src/SlidingWindowCache/Public/Instrumentation/` - **Files**: - `ICacheDiagnostics.cs` - Public interface for cache event tracking - `EventCounterCacheDiagnostics.cs` - Thread-safe counter implementation @@ -199,7 +199,7 @@ not actual cache mutations. Actual mutations only occur in Rebalance Execution v **UserRequestHandler.cs**: - **REMOVED**: All `_state.Cache.Rematerialize()` calls (User Path is now read-only) -- **REMOVED**: `_state.LastRequested` writes (only Rebalance Execution writes) +- **REMOVED**: `_state.IsInitialized` writes (only Rebalance Execution writes) - **ADDED**: Cold start detection using cache data enumeration - **ADDED**: Materialization of assembled data to array (for user + intent) - **ADDED**: Creation of `RangeData` for intent with delivered data @@ -215,12 +215,12 @@ not actual cache mutations. Actual mutations only occur in Rebalance Execution v **RebalanceExecutor.cs**: - **ADDED**: Accept `requestedRange` and `deliveredData` parameters - **CHANGED**: Uses delivered data from intent as base (not current cache) -- **ADDED**: Writes to `_state.LastRequested` (sole writer) +- **ADDED**: Writes to `_state.IsInitialized` (sole writer) - **ADDED**: Writes to `_state.NoRebalanceRange` (already was sole writer) -- **RESPONSIBILITY**: Sole writer of all cache state (Cache, LastRequested, NoRebalanceRange) +- **RESPONSIBILITY**: Sole writer of all cache state (Cache, IsInitialized, NoRebalanceRange) **CacheState.cs**: -- **CHANGED**: `LastRequested` and `NoRebalanceRange` setters to `internal` +- **CHANGED**: `IsInitialized` and `NoRebalanceRange` setters to `internal` - **PURPOSE**: Enforce single-writer pattern at compile time **Storage Classes**: diff --git a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs index 2044631..1f563e4 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs @@ -1,10 +1,10 @@ using Intervals.NET.Domain.Default.Numeric; using Intervals.NET.Domain.Extensions.Fixed; using Intervals.NET.Extensions; -using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Tests.Infrastructure.Helpers; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; +using SlidingWindowCache.Public.Instrumentation; namespace SlidingWindowCache.Invariants.Tests; diff --git a/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs b/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs index 7d9de07..4cb961e 100644 --- a/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs +++ b/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs @@ -2,10 +2,10 @@ using Intervals.NET.Domain.Default.Numeric; using Intervals.NET.Domain.Extensions.Fixed; using Moq; -using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Dto; +using SlidingWindowCache.Public.Instrumentation; using SlidingWindowCache.Tests.Infrastructure.DataSources; namespace SlidingWindowCache.Tests.Infrastructure.Helpers; diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Instrumentation/NoOpDiagnosticsTests.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Instrumentation/NoOpDiagnosticsTests.cs deleted file mode 100644 index 0732d1f..0000000 --- a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Instrumentation/NoOpDiagnosticsTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -using SlidingWindowCache.Infrastructure.Instrumentation; - -namespace SlidingWindowCache.Unit.Tests.Infrastructure.Instrumentation; - -/// -/// Unit tests for NoOpDiagnostics to ensure it never throws exceptions. -/// This is critical because diagnostic failures should never break cache functionality. -/// -public class NoOpDiagnosticsTests -{ - [Fact] - public void AllMethods_WhenCalled_DoNotThrowExceptions() - { - // ARRANGE - var diagnostics = new NoOpDiagnostics(); - var testException = new InvalidOperationException("Test exception"); - - // ACT & ASSERT - Call all methods and verify none throw exceptions - var exception = Record.Exception(() => - { - diagnostics.CacheExpanded(); - diagnostics.CacheReplaced(); - diagnostics.DataSourceFetchMissingSegments(); - diagnostics.DataSourceFetchSingleRange(); - diagnostics.RebalanceExecutionCancelled(); - diagnostics.RebalanceExecutionCompleted(); - diagnostics.RebalanceExecutionStarted(); - diagnostics.RebalanceIntentCancelled(); - diagnostics.RebalanceIntentPublished(); - diagnostics.RebalanceSkippedCurrentNoRebalanceRange(); - diagnostics.RebalanceSkippedPendingNoRebalanceRange(); - diagnostics.RebalanceSkippedSameRange(); - diagnostics.RebalanceExecutionFailed(testException); - diagnostics.UserRequestFullCacheHit(); - diagnostics.UserRequestFullCacheMiss(); - diagnostics.UserRequestPartialCacheHit(); - diagnostics.UserRequestServed(); - }); - - Assert.Null(exception); - } -} From 788732ba5dcb02ce07a2064072ee629265cf9a6c Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Fri, 27 Feb 2026 03:48:14 +0100 Subject: [PATCH 05/21] refactor: revise ICacheDiagnostics interface to focus on key events --- .../Public/Instrumentation/ICacheDiagnostics.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/SlidingWindowCache/Public/Instrumentation/ICacheDiagnostics.cs b/src/SlidingWindowCache/Public/Instrumentation/ICacheDiagnostics.cs index 702f018..e3fed90 100644 --- a/src/SlidingWindowCache/Public/Instrumentation/ICacheDiagnostics.cs +++ b/src/SlidingWindowCache/Public/Instrumentation/ICacheDiagnostics.cs @@ -5,7 +5,6 @@ /// Mirrors the public API of CacheInstrumentationCounters to enable dependency injection. /// Used for testing and verification of system invariants. /// -/// TODO revise exposed methods, probably some reconsideration is needed. Better to expose less but major events, than too many fine-grained ones that may be noisy and hard to maintain. Focus on key events that validate critical invariants and system behavior. public interface ICacheDiagnostics { // ============================================================================ From 4c5fc8aa7735d01c1d31bb95b3328ca71d7d28b1 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Fri, 27 Feb 2026 04:03:17 +0100 Subject: [PATCH 06/21] refactor: improve user request handling logic for cache scenarios and enhance documentation --- .../Core/UserPath/UserRequestHandler.cs | 189 +++++++----------- 1 file changed, 73 insertions(+), 116 deletions(-) diff --git a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs index 0b9178a..86a6f0e 100644 --- a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs +++ b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs @@ -94,10 +94,10 @@ ICacheDiagnostics cacheDiagnostics /// /// 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 + /// Determine which of the four scenarios applies (cold start, full hit, partial hit, full miss) /// Fetch missing data from IDataSource as needed /// Compute actual available range (intersection of requested and available) - /// Materialize assembled data to array + /// Materialise assembled data into a buffer /// Publish rebalance intent with delivered data (fire-and-forget) /// Return RangeResult immediately /// @@ -109,7 +109,7 @@ ICacheDiagnostics cacheDiagnostics /// ✅ May READ from cache /// ✅ May READ from IDataSource /// ❌ NEVER writes to Cache (no Rematerialize calls) - /// ❌ NEVER writes to IsInitialized + /// ❌ NEVER writes to IsInitialized /// ❌ NEVER writes to NoRebalanceRange /// /// @@ -132,125 +132,77 @@ public async ValueTask> HandleRequestAsync( "Cannot handle request on a disposed handler."); } - // Check if cache is cold (never used) - use ToRangeData to detect empty cache var cacheStorage = _state.Storage; - var isColdStart = !_state.IsInitialized; + var fullyInCache = _state.IsInitialized && cacheStorage.Range.Contains(requestedRange); + var hasOverlap = _state.IsInitialized && !fullyInCache && cacheStorage.Range.Overlaps(requestedRange); - RangeData? assembledData = null; - var exceptionOccurred = false; + RangeData? assembledData; + Range? actualRange; + ReadOnlyMemory resultData; - try + if (!fullyInCache && !hasOverlap) { - Range? actualRange; - ReadOnlyMemory resultData; + // Scenario 1 (Cold Start) & Scenario 4 (Full Cache Miss / Non-intersecting Jump): + // Cache is uninitialised or RequestedRange does not overlap CurrentCacheRange. + // Fetch ONLY the requested range from IDataSource. + (assembledData, actualRange, resultData) = + await FetchSingleRangeAsync(requestedRange, cancellationToken).ConfigureAwait(false); + _cacheDiagnostics.UserRequestFullCacheMiss(); + } + else if (fullyInCache) + { + // Scenario 2: Full Cache Hit + // All requested data is available in cache - read directly (no IDataSource call). + assembledData = cacheStorage.ToRangeData(); + actualRange = requestedRange; // Fully in cache, so actual == requested + resultData = cacheStorage.Read(requestedRange); + _cacheDiagnostics.UserRequestFullCacheHit(); + } + else + { + // Scenario 3: Partial Cache Hit + // RequestedRange intersects CurrentCacheRange - read from cache and fetch missing parts. + // NOTE: storage.Read cannot be used here because we need a contiguous range that may + // require concatenating multiple segments (cached + fetched). + assembledData = await _cacheExtensionService.ExtendCacheAsync( + cacheStorage.ToRangeData(), + requestedRange, + cancellationToken + ).ConfigureAwait(false); + + _cacheDiagnostics.UserRequestPartialCacheHit(); + + // 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); - if (isColdStart) + if (actualRange.HasValue) { - // Scenario 1: Cold Start - // Cache has never been populated - fetch data ONLY for requested range - (assembledData, actualRange, resultData) = - await FetchSingleRangeAsync(requestedRange, cancellationToken).ConfigureAwait(false); - _cacheDiagnostics.UserRequestFullCacheMiss(); + // Slice to the actual available range (may be smaller than requestedRange). + resultData = MaterialiseData(assembledData[actualRange.Value]); } else { - var fullyInCache = cacheStorage.Range.Contains(requestedRange); - - if (fullyInCache) - { - // Scenario 2: Full Cache Hit - // All requested data is available in cache - read from cache (no IDataSource call) - assembledData = cacheStorage.ToRangeData(); - - _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); - } - else - { - var hasOverlap = cacheStorage.Range.Overlaps(requestedRange); - - if (hasOverlap) - { - // Scenario 3: Partial Cache Hit - // RequestedRange intersects CurrentCacheRange - read from cache and fetch missing parts - // ExtendCacheAsync will compute missing ranges and fetch only those parts - // NOTE: The usage of storage.Read doesn't make sense here because we need to assemble a contiguous range that may require concatenating multiple segments (cached + fetched) - assembledData = await _cacheExtensionService.ExtendCacheAsync( - cacheStorage.ToRangeData(), - requestedRange, - cancellationToken - ).ConfigureAwait(false); - - _cacheDiagnostics.UserRequestPartialCacheHit(); - - // 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 - { - // Scenario 4: Full Cache Miss (Non-intersecting Jump) - // RequestedRange does NOT intersect CurrentCacheRange - // Fetch ONLY the requested range from IDataSource - (assembledData, actualRange, resultData) = - await FetchSingleRangeAsync(requestedRange, cancellationToken).ConfigureAwait(false); - _cacheDiagnostics.UserRequestFullCacheMiss(); - } - } + // No actual intersection after extension (defensive fallback). + assembledData = null; + resultData = ReadOnlyMemory.Empty; } - - // Return RangeResult with actual available range and data - return new RangeResult(actualRange, resultData); } - catch + + // 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 (see Invariant C.24e). + if (assembledData is not null) { - // 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; + _intentController.PublishIntent(new Intent(requestedRange, assembledData)); } - finally - { - var shouldPublishIntent = assembledData is not null; - - if (!exceptionOccurred) - { - // 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); - } + // UserRequestServed fires for ALL successful completions, including boundary misses + // where assembledData == null (full vacuum / out-of-physical-bounds). + _cacheDiagnostics.UserRequestServed(); - // UserRequestServed fires for ALL non-exception completions, including boundary misses - // where assembledData == null (full vacuum / out-of-physical-bounds). - _cacheDiagnostics.UserRequestServed(); - } - } + return new RangeResult(actualRange, resultData); } /// @@ -289,8 +241,8 @@ internal async ValueTask DisposeAsync() /// The range to fetch. /// A cancellation token to cancel the operation. /// - /// A tuple of (assembledData, actualRange, resultData). assembledData is null and - /// actualRange is null when the data source reports no data is available for the range + /// A named tuple of (AssembledData, ActualRange, ResultData). AssembledData is null and + /// ActualRange is null when the data source reports no data is available for the range /// (physical boundary miss). /// /// @@ -301,23 +253,28 @@ internal async ValueTask DisposeAsync() /// handles the null-Range contract of . /// /// - private async ValueTask<(RangeData? assembledData, Range? actualRange, ReadOnlyMemory resultData)> + private async ValueTask<(RangeData? AssembledData, Range? ActualRange, ReadOnlyMemory ResultData)> FetchSingleRangeAsync(Range requestedRange, CancellationToken cancellationToken) { _cacheDiagnostics.DataSourceFetchSingleRange(); var fetchedChunk = await _dataSource.FetchAsync(requestedRange, cancellationToken) .ConfigureAwait(false); - // Handle boundary: chunk.Range may be null or truncated + // Handle boundary: chunk.Range may be null when the requested range lies entirely + // outside the physical bounds of the data source. if (!fetchedChunk.Range.HasValue) { - // No data available for requested range (physical boundary miss) - return (null, null, ReadOnlyMemory.Empty); + return (AssembledData: null, ActualRange: null, ResultData: ReadOnlyMemory.Empty); } var assembledData = fetchedChunk.Data.ToRangeData(fetchedChunk.Range.Value, _state.Domain); - var actualRange = fetchedChunk.Range.Value; - var resultData = new ReadOnlyMemory(assembledData.Data.ToArray()); - return (assembledData, actualRange, resultData); + return (AssembledData: assembledData, ActualRange: fetchedChunk.Range.Value, ResultData: MaterialiseData(assembledData)); } + + /// + /// Materialises the data of a into a + /// buffer. + /// + private static ReadOnlyMemory MaterialiseData(RangeData data) + => new(data.Data.ToArray()); } \ No newline at end of file From 295a31ee5fe72893f677560979f626b484c2f95a Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Fri, 27 Feb 2026 21:01:44 +0100 Subject: [PATCH 07/21] refactor: improve code structure and remove redundant elements; refactor: enhance clarity in comments and documentation; chore: exclude benchmark project from code coverage --- .../SlidingWindowCache.Benchmarks.csproj | 4 ++ .../Core/Planning/NoRebalanceRangePlanner.cs | 2 +- ...cy.cs => NoRebalanceSatisfactionPolicy.cs} | 0 .../Decision/RebalanceDecisionEngine.cs | 2 - .../Rebalance/Execution/RebalanceExecutor.cs | 3 +- .../Core/Rebalance/Intent/IntentController.cs | 3 +- .../Core/State/CacheState.cs | 2 - .../Storage/SnapshotReadStorage.cs | 10 ++++- .../EventCounterCacheDiagnostics.cs | 6 --- .../Instrumentation/ICacheDiagnostics.cs | 15 ++----- .../Public/Instrumentation/NoOpDiagnostics.cs | 5 --- .../RebalanceExceptionHandlingTests.cs | 5 --- .../WindowCacheInvariantTests.cs | 4 ++ .../Helpers/TestHelpers.cs | 39 +++++-------------- 14 files changed, 33 insertions(+), 67 deletions(-) rename src/SlidingWindowCache/Core/Rebalance/Decision/{ThresholdRebalancePolicy.cs => NoRebalanceSatisfactionPolicy.cs} (100%) diff --git a/benchmarks/SlidingWindowCache.Benchmarks/SlidingWindowCache.Benchmarks.csproj b/benchmarks/SlidingWindowCache.Benchmarks/SlidingWindowCache.Benchmarks.csproj index 336f07e..77bb229 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/SlidingWindowCache.Benchmarks.csproj +++ b/benchmarks/SlidingWindowCache.Benchmarks/SlidingWindowCache.Benchmarks.csproj @@ -8,6 +8,10 @@ Exe + + true + + diff --git a/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs b/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs index d13f71d..3e7c3ea 100644 --- a/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs +++ b/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs @@ -51,7 +51,7 @@ public NoRebalanceRangePlanner(WindowCacheOptions options, TDomain domain) /// - Left threshold shrinks from the left boundary inward /// - Right threshold shrinks from the right boundary inward /// This creates a "stability zone" where requests don't trigger rebalancing. - /// Returns null when individual thresholds are >= 1.0, which would completely eliminate the no-rebalance range. + /// Returns null when the sum of left and right thresholds is >= 1.0, which would completely eliminate the no-rebalance range. /// Note: WindowCacheOptions constructor ensures leftThreshold + rightThreshold does not exceed 1.0. /// public Range? Plan(Range cacheRange) diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/NoRebalanceSatisfactionPolicy.cs similarity index 100% rename from src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs rename to src/SlidingWindowCache/Core/Rebalance/Decision/NoRebalanceSatisfactionPolicy.cs diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs index 925bc67..b05f48e 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs @@ -1,8 +1,6 @@ using Intervals.NET; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Planning; -using SlidingWindowCache.Core.Rebalance.Execution; -using SlidingWindowCache.Core.State; namespace SlidingWindowCache.Core.Rebalance.Decision; diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs index f20b0dd..5845325 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs @@ -1,5 +1,4 @@ using Intervals.NET; -using Intervals.NET.Data; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Intent; using SlidingWindowCache.Core.State; @@ -105,7 +104,7 @@ public async Task ExecuteAsync( cancellationToken.ThrowIfCancellationRequested(); // Phase 3: Apply cache state mutations (single writer — all fields updated atomically) - _state.UpdateCacheState(normalizedData, intent.RequestedRange, desiredNoRebalanceRange); + _state.UpdateCacheState(normalizedData, desiredNoRebalanceRange); _cacheDiagnostics.RebalanceExecutionCompleted(); } diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index 52a8da6..c1b72f4 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -1,5 +1,4 @@ -using Intervals.NET; -using Intervals.NET.Domain.Abstractions; +using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.Rebalance.Execution; using SlidingWindowCache.Core.State; diff --git a/src/SlidingWindowCache/Core/State/CacheState.cs b/src/SlidingWindowCache/Core/State/CacheState.cs index 4d99e49..58f351b 100644 --- a/src/SlidingWindowCache/Core/State/CacheState.cs +++ b/src/SlidingWindowCache/Core/State/CacheState.cs @@ -79,7 +79,6 @@ public CacheState(ICacheStorage cacheStorage, TDomain do /// This is the ONLY method that may write to the mutable fields on this class. /// /// The normalized range data to write into storage. - /// The original range requested by the user; used to populate the storage and mark the cache as initialized. /// The pre-computed no-rebalance range for the new state. /// /// Single-Writer Contract: @@ -91,7 +90,6 @@ public CacheState(ICacheStorage cacheStorage, TDomain do /// internal void UpdateCacheState( Intervals.NET.Data.RangeData normalizedData, - Range requestedRange, Range? noRebalanceRange) { Storage.Rematerialize(normalizedData); diff --git a/src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs b/src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs index a541296..c315c6f 100644 --- a/src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs +++ b/src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs @@ -2,7 +2,6 @@ using Intervals.NET.Data; using Intervals.NET.Data.Extensions; using Intervals.NET.Domain.Abstractions; -using Intervals.NET.Extensions; using SlidingWindowCache.Infrastructure.Extensions; namespace SlidingWindowCache.Infrastructure.Storage; @@ -48,6 +47,15 @@ public void Rematerialize(RangeData rangeData) { // Always allocate a new array, even if the size is unchanged // This is the trade-off of the Snapshot mode + // + // Write ordering is intentional and critical for thread safety: + // 1. Range is written first (plain store, no fence) + // 2. _storage is written second as a volatile store (release fence) + // + // The volatile store on _storage acts as a release fence for ALL preceding stores, + // including Range. The user thread's volatile read of _storage (in Read()) acts as + // an acquire fence, guaranteeing it observes the Range value written before the + // volatile store. This is correct and safe under .NET's memory model. Range = rangeData.Range; _storage = rangeData.Data.ToArray(); } diff --git a/src/SlidingWindowCache/Public/Instrumentation/EventCounterCacheDiagnostics.cs b/src/SlidingWindowCache/Public/Instrumentation/EventCounterCacheDiagnostics.cs index c02e7ca..d8f6333 100644 --- a/src/SlidingWindowCache/Public/Instrumentation/EventCounterCacheDiagnostics.cs +++ b/src/SlidingWindowCache/Public/Instrumentation/EventCounterCacheDiagnostics.cs @@ -11,7 +11,6 @@ public sealed class EventCounterCacheDiagnostics : ICacheDiagnostics private int _cacheExpanded; private int _cacheReplaced; private int _rebalanceIntentPublished; - private int _rebalanceIntentCancelled; private int _rebalanceExecutionStarted; private int _rebalanceExecutionCompleted; private int _rebalanceExecutionCancelled; @@ -37,7 +36,6 @@ public sealed class EventCounterCacheDiagnostics : ICacheDiagnostics public int DataSourceFetchMissingSegments => _dataSourceFetchMissingSegments; public int DataSegmentUnavailable => _dataSegmentUnavailable; public int RebalanceIntentPublished => _rebalanceIntentPublished; - public int RebalanceIntentCancelled => _rebalanceIntentCancelled; public int RebalanceExecutionStarted => _rebalanceExecutionStarted; public int RebalanceExecutionCompleted => _rebalanceExecutionCompleted; public int RebalanceExecutionCancelled => _rebalanceExecutionCancelled; @@ -73,9 +71,6 @@ void ICacheDiagnostics.DataSegmentUnavailable() => /// void ICacheDiagnostics.RebalanceExecutionStarted() => Interlocked.Increment(ref _rebalanceExecutionStarted); - /// - void ICacheDiagnostics.RebalanceIntentCancelled() => Interlocked.Increment(ref _rebalanceIntentCancelled); - /// void ICacheDiagnostics.RebalanceIntentPublished() => Interlocked.Increment(ref _rebalanceIntentPublished); @@ -130,7 +125,6 @@ public void Reset() Volatile.Write(ref _cacheExpanded, 0); Volatile.Write(ref _cacheReplaced, 0); Volatile.Write(ref _rebalanceIntentPublished, 0); - Volatile.Write(ref _rebalanceIntentCancelled, 0); Volatile.Write(ref _rebalanceExecutionStarted, 0); Volatile.Write(ref _rebalanceExecutionCompleted, 0); Volatile.Write(ref _rebalanceExecutionCancelled, 0); diff --git a/src/SlidingWindowCache/Public/Instrumentation/ICacheDiagnostics.cs b/src/SlidingWindowCache/Public/Instrumentation/ICacheDiagnostics.cs index e3fed90..438be2a 100644 --- a/src/SlidingWindowCache/Public/Instrumentation/ICacheDiagnostics.cs +++ b/src/SlidingWindowCache/Public/Instrumentation/ICacheDiagnostics.cs @@ -131,15 +131,6 @@ public interface ICacheDiagnostics /// void RebalanceIntentPublished(); - /// - /// Records cancellation of a rebalance intent before or during execution. - /// Called when a new user request arrives and cancels the previous intent's CancellationToken, or when intent becomes obsolete during debounce delay. - /// Indicates single-flight execution pattern and priority enforcement (User Path cancels Rebalance). - /// Location: RebalanceScheduler (three scenarios: cancellation during debounce, cancellation before decision, cancellation during execution) - /// Related: Invariant A.0 (User Path priority), Invariant A.0a (User Request must cancel ongoing rebalance), Invariant C.20 (Obsolete intent must not start) - /// - void RebalanceIntentCancelled(); - // ============================================================================ // REBALANCE EXECUTION LIFECYCLE COUNTERS // ============================================================================ @@ -148,7 +139,7 @@ public interface ICacheDiagnostics /// Records the start of rebalance execution after decision engine approves execution. /// Called when DecisionEngine determines rebalance is necessary (RequestedRange outside NoRebalanceRange and DesiredCacheRange != CurrentCacheRange). /// Indicates transition from Decision Path to Execution Path (Decision Scenario D3). - /// Location: RebalanceScheduler.ExecutePipelineAsync (after decision approval, before executor invocation) + /// Location: TaskBasedRebalanceExecutionController.ExecuteRequestAsync / ChannelBasedRebalanceExecutionController.ProcessExecutionRequestsAsync (before executor invocation) /// Related: Invariant 28 (Rebalance triggered only if confirmed necessary) /// void RebalanceExecutionStarted(); @@ -166,7 +157,7 @@ public interface ICacheDiagnostics /// Records cancellation of rebalance execution due to a new user request or intent supersession. /// Called when intentToken is cancelled during rebalance execution (after execution started but before completion). /// Indicates User Path priority enforcement and single-flight execution (yielding to new requests). - /// Location: RebalanceScheduler.ExecutePipelineAsync (catch OperationCanceledException during execution) + /// Location: TaskBasedRebalanceExecutionController.ExecuteRequestAsync / ChannelBasedRebalanceExecutionController.ProcessExecutionRequestsAsync (catch OperationCanceledException during execution) /// Related: Invariant 34a (Rebalance Execution must yield to User Path immediately) /// void RebalanceExecutionCancelled(); @@ -293,7 +284,7 @@ public interface ICacheDiagnostics /// } /// /// - /// Location: RebalanceScheduler.ExecutePipelineAsync (catch block around ExecuteAsync) + /// Location: TaskBasedRebalanceExecutionController.ExecuteRequestAsync / ChannelBasedRebalanceExecutionController.ProcessExecutionRequestsAsync (catch block around ExecuteAsync) /// /// void RebalanceExecutionFailed(Exception ex); diff --git a/src/SlidingWindowCache/Public/Instrumentation/NoOpDiagnostics.cs b/src/SlidingWindowCache/Public/Instrumentation/NoOpDiagnostics.cs index b49145f..c6d2fca 100644 --- a/src/SlidingWindowCache/Public/Instrumentation/NoOpDiagnostics.cs +++ b/src/SlidingWindowCache/Public/Instrumentation/NoOpDiagnostics.cs @@ -45,11 +45,6 @@ public void RebalanceExecutionStarted() { } - /// - public void RebalanceIntentCancelled() - { - } - /// public void RebalanceIntentPublished() { diff --git a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs index 089d431..e54bb1d 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs @@ -1,4 +1,3 @@ -using Intervals.NET; using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Tests.Infrastructure.DataSources; using SlidingWindowCache.Public; @@ -260,10 +259,6 @@ public void RebalanceIntentPublished() { } - public void RebalanceIntentCancelled() - { - } - public void RebalanceExecutionStarted() { } diff --git a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs index 1f563e4..20843eb 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs @@ -318,6 +318,8 @@ public async Task Invariant_A3_8_UserPathNeverMutatesCache( string storageName, UserCacheReadMode readMode) { // ARRANGE + _ = scenario; + _ = storageName; var options = TestHelpers.CreateDefaultOptions( debounceDelay: TimeSpan.FromMilliseconds(50), readMode: readMode); @@ -1100,6 +1102,7 @@ public async Task Invariant_F35_G46_RebalanceCancellationBehavior(string executi public async Task Invariant_F36a_RebalanceNormalizesCache(string storageName, UserCacheReadMode readMode) { // ARRANGE + _ = storageName; var options = TestHelpers.CreateDefaultOptions( leftCacheSize: 1.0, rightCacheSize: 1.0, @@ -1134,6 +1137,7 @@ public async Task Invariant_F36a_RebalanceNormalizesCache(string storageName, Us public async Task Invariant_F40_F41_F42_PostExecutionGuarantees(string storageName, UserCacheReadMode readMode) { // ARRANGE + _ = storageName; var options = TestHelpers.CreateDefaultOptions( leftCacheSize: 1.0, rightCacheSize: 1.0, diff --git a/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs b/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs index 4cb961e..15bbbb7 100644 --- a/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs +++ b/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs @@ -309,7 +309,7 @@ public static void AssertIntentPublished(EventCounterCacheDiagnostics cacheDiagn } /// - /// Asserts that rebalance was cancelled (at either intent or execution stage). + /// Asserts that rebalance execution was cancelled. /// /// /// @@ -319,38 +319,20 @@ public static void AssertIntentPublished(EventCounterCacheDiagnostics cacheDiagn /// tracked in the lifecycle. /// /// - /// Due to timing, cancellation can occur at two distinct lifecycle points: - /// - /// - /// - /// Intent-level cancellation: When a new request arrives while the previous - /// rebalance is still in debounce delay (before execution starts). This increments - /// . - /// - /// - /// Execution-level cancellation: When a new request arrives after the debounce - /// delay completed and execution has started. This increments - /// . - /// - /// - /// - /// This method checks the total cancellations across both stages, making assertions - /// stable regardless of timing variations. Most tests care that cancellation occurred, not the - /// specific lifecycle stage where it happened. + /// Cancellation occurs when a new request arrives after the debounce delay completed and execution + /// has started. This increments . /// /// /// - /// The diagnostics instance to check for cancellation counts. The method will sum both intent and execution cancellations to determine if the expected number of cancellations occurred. + /// The diagnostics instance to check for cancellation counts. /// - /// Minimum number of total cancellations expected (default: 1). + /// Minimum number of execution cancellations expected (default: 1). public static void AssertRebalancePathCancelled(EventCounterCacheDiagnostics cacheDiagnostics, int minExpected = 1) { - var totalCancelled = cacheDiagnostics.RebalanceIntentCancelled + - cacheDiagnostics.RebalanceExecutionCancelled; + var totalCancelled = cacheDiagnostics.RebalanceExecutionCancelled; Assert.True(totalCancelled >= minExpected, - $"At least {minExpected} cancellation(s) expected (intent or execution), but actual count was {totalCancelled} " + - $"(IntentCancelled: {cacheDiagnostics.RebalanceIntentCancelled}, " + - $"ExecutionCancelled: {cacheDiagnostics.RebalanceExecutionCancelled})"); + $"At least {minExpected} cancellation(s) expected, but actual count was {totalCancelled} " + + $"(ExecutionCancelled: {cacheDiagnostics.RebalanceExecutionCancelled})"); } /// @@ -502,14 +484,13 @@ public static void AssertRebalancePipelineIntegrity(EventCounterCacheDiagnostics var skippedStage1 = cacheDiagnostics.RebalanceSkippedCurrentNoRebalanceRange; var skippedStage2 = cacheDiagnostics.RebalanceSkippedPendingNoRebalanceRange; var skippedStage3 = cacheDiagnostics.RebalanceSkippedSameRange; - var intentCancelled = cacheDiagnostics.RebalanceIntentCancelled; // Decision phase: All intents must be accounted for - var totalDecisionOutcomes = scheduled + skippedStage1 + skippedStage2 + skippedStage3 + intentCancelled; + var totalDecisionOutcomes = scheduled + skippedStage1 + skippedStage2 + skippedStage3; Assert.True(totalDecisionOutcomes <= intentPublished, $"Decision outcomes ({totalDecisionOutcomes}) cannot exceed intents published ({intentPublished}). " + $"Breakdown: Scheduled={scheduled}, SkippedStage1={skippedStage1}, SkippedStage2={skippedStage2}, " + - $"SkippedStage3={skippedStage3}, IntentCancelled={intentCancelled}"); + $"SkippedStage3={skippedStage3}"); // Execution phase: Verify lifecycle integrity AssertRebalanceLifecycleIntegrity(cacheDiagnostics); From 0b74fca5d4052aef61b9aba2bd52563fd1a075d3 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Fri, 27 Feb 2026 22:35:42 +0100 Subject: [PATCH 08/21] refactor: update method signatures and improve data handling in various classes; feat: introduce new diagnostics tests for NoOpDiagnostics; fix: correct range handling in tests and documentation; chore: remove redundant test data source and simplify test infrastructure; style: enhance code readability and consistency across files --- .../Infrastructure/SlowDataSource.cs | 4 +- .../Infrastructure/SynchronousDataSource.cs | 4 +- docs/actors-and-responsibilities.md | 28 +- docs/architecture-model.md | 2 +- docs/component-map.md | 95 ++-- docs/invariants.md | 6 +- docs/scenario-model.md | 8 +- .../WasmCompilationValidator.cs | 8 +- .../Core/Planning/ProportionalRangePlanner.cs | 4 +- .../Rebalance/Decision/RebalanceDecision.cs | 4 +- .../Rebalance/Decision/RebalanceReason.cs | 5 + .../IntervalsNetDomainExtensions.cs | 5 + .../Configuration/WindowCacheOptions.cs | 24 + .../Public/Dto/RangeChunk.cs | 6 +- src/SlidingWindowCache/Public/IDataSource.cs | 4 +- .../EventCounterCacheDiagnostics.cs | 44 +- .../Public/Instrumentation/NoOpDiagnostics.cs | 8 + src/SlidingWindowCache/Public/WindowCache.cs | 32 +- .../DataSourceRangePropagationTests.cs | 2 +- .../ExecutionStrategySelectionTests.cs | 73 +-- .../WindowCacheInvariantTests.cs | 21 +- .../DataSources/FaultyDataSource.cs | 8 +- .../DataSources/SimpleTestDataSource.cs | 82 +++ .../Helpers/TestHelpers.cs | 7 +- .../Extensions/IntegerVariableStepDomain.cs | 2 +- .../IntervalsNetDomainExtensionsTests.cs | 4 +- .../Storage/CopyOnReadStorageTests.cs | 463 +--------------- .../Storage/SnapshotReadStorageTests.cs | 428 +-------------- .../CacheStorageTestsBase.cs | 503 ++++++++++++++++++ .../Configuration/WindowCacheOptionsTests.cs | 24 + .../Instrumentation/NoOpDiagnosticsTests.cs | 41 ++ .../Public/WindowCacheDisposalTests.cs | 62 +-- .../SlidingWindowCache.Unit.Tests.csproj | 1 + 33 files changed, 875 insertions(+), 1137 deletions(-) create mode 100644 tests/SlidingWindowCache.Tests.Infrastructure/DataSources/SimpleTestDataSource.cs create mode 100644 tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/TestInfrastructure/CacheStorageTestsBase.cs create mode 100644 tests/SlidingWindowCache.Unit.Tests/Public/Instrumentation/NoOpDiagnosticsTests.cs diff --git a/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SlowDataSource.cs b/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SlowDataSource.cs index d71f0b5..c99ed4d 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SlowDataSource.cs +++ b/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SlowDataSource.cs @@ -37,7 +37,7 @@ public async Task> FetchAsync(Range range, Cancellatio await Task.Delay(_latency, cancellationToken).ConfigureAwait(false); // Generate data after delay completes - return new RangeChunk(range, GenerateDataForRange(range)); + return new RangeChunk(range, GenerateDataForRange(range).ToList()); } /// @@ -57,7 +57,7 @@ public async Task>> FetchAsync( chunks.Add(new RangeChunk( range, - GenerateDataForRange(range) + GenerateDataForRange(range).ToList() )); } diff --git a/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SynchronousDataSource.cs b/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SynchronousDataSource.cs index 55caef1..2585c07 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SynchronousDataSource.cs +++ b/benchmarks/SlidingWindowCache.Benchmarks/Infrastructure/SynchronousDataSource.cs @@ -25,7 +25,7 @@ public SynchronousDataSource(IntegerFixedStepDomain domain) /// Data generation: Returns the integer value at each position in the range. /// public Task> FetchAsync(Range range, CancellationToken cancellationToken) => - Task.FromResult(new RangeChunk(range, GenerateDataForRange(range))); + Task.FromResult(new RangeChunk(range, GenerateDataForRange(range).ToList())); /// /// Fetches data for multiple ranges with zero latency. @@ -37,7 +37,7 @@ public Task>> FetchAsync( // Synchronous generation for all chunks var chunks = ranges.Select(range => new RangeChunk( range, - GenerateDataForRange(range) + GenerateDataForRange(range).ToList() )); return Task.FromResult(chunks); diff --git a/docs/actors-and-responsibilities.md b/docs/actors-and-responsibilities.md index d41acb1..6a8d464 100644 --- a/docs/actors-and-responsibilities.md +++ b/docs/actors-and-responsibilities.md @@ -102,7 +102,7 @@ IntentController OWNS the DecisionEngine instance. **Responsibility Type:** ensures correctness of rebalance necessity decisions through analytical validation, enabling smart eventual consistency -**Note:** Not a top-level actor — internal tool of IntentManager/Executor pipeline, but THE authority for necessity determination and work avoidance. +**Note:** Not a top-level actor — internal tool of IntentController/Executor pipeline, but THE authority for necessity determination and work avoidance. --- @@ -134,7 +134,7 @@ Pure functions, lightweight structs (value types), CPU-only, side-effect free - 30. Independent of current cache contents [ProportionalRangePlanner] - 31. Canonical target cache state [ProportionalRangePlanner] - 32. Sliding window geometry defined by configuration [Both components] -- 33. NoRebalanceRange derived from current cache range + config [ThresholdRebalancePolicy] +- 33. NoRebalanceRange derived from current cache range + config [NoRebalanceSatisfactionPolicy + NoRebalanceRangePlanner] - 35. Threshold sum constraint (leftThreshold + rightThreshold ≤ 1.0) [WindowCacheOptions validation] **Responsibility Type:** sets rules and constraints @@ -234,24 +234,24 @@ Executor is **mechanically simple** with no analytical logic: - Performs only: fetch missing data, merge with delivered data, trim to desired range, write atomically **Responsible for invariants:** -- 4. Rebalance is asynchronous relative to User Path -- 34. MUST support cancellation at all stages -- 34a. MUST yield to User Path requests immediately upon cancellation -- 34b. Partially executed or cancelled execution MUST NOT leave cache inconsistent -- 35. Only path responsible for cache normalization -- 35a. Mutates cache ONLY for normalization, using delivered data from intent: +- A.4. Rebalance is asynchronous relative to User Path +- F.35. MUST support cancellation at all stages +- F.35a. MUST yield to User Path requests immediately upon cancellation +- F.35b. Partially executed or cancelled execution MUST NOT leave cache inconsistent +- F.36. Only path responsible for cache normalization (single-writer architecture) +- F.36a. Mutates cache ONLY for normalization, using delivered data from intent: - Uses delivered data from intent as authoritative base (not current cache) - Expanding to DesiredCacheRange by fetching only truly missing ranges - Trimming excess data outside DesiredCacheRange - Writing to Cache.Rematerialize() - Writing to IsInitialized (= true) - Recomputing NoRebalanceRange -- 36. May replace / expand / shrink cache to achieve normalization -- 37. Requests data only for missing subranges (not covered by delivered data) -- 38. Does not overwrite intersecting data -- 39. Upon completion: CacheData corresponds to DesiredCacheRange -- 40. Upon completion: CurrentCacheRange == DesiredCacheRange -- 41. Upon completion: NoRebalanceRange recomputed +- F.37. May replace / expand / shrink cache to achieve normalization +- F.38. Requests data only for missing subranges (not covered by delivered data) +- F.39. Does not overwrite intersecting data +- F.40. Upon completion: CacheData corresponds to DesiredCacheRange +- F.41. Upon completion: CurrentCacheRange == DesiredCacheRange +- F.42. Upon completion: NoRebalanceRange recomputed **Responsibility Type:** executes rebalance and normalizes cache (cancellable, never concurrent with User Path, assumes validated necessity) diff --git a/docs/architecture-model.md b/docs/architecture-model.md index dceed05..54dabf6 100644 --- a/docs/architecture-model.md +++ b/docs/architecture-model.md @@ -451,7 +451,7 @@ WindowCache.DisposeAsync() All public operations check disposal state using lock-free reads: ```csharp -public ValueTask> GetDataAsync(...) +public ValueTask> GetDataAsync(...) { // Check disposal state (lock-free) if (Volatile.Read(ref _disposeState) != 0) diff --git a/docs/component-map.md b/docs/component-map.md index ddecf52..1d76ce8 100644 --- a/docs/component-map.md +++ b/docs/component-map.md @@ -39,14 +39,14 @@ This document provides a comprehensive catalog of all components in the Sliding **By Type**: - 🟦 **Classes (Reference Types)**: 12 -- 🟩 **Structs (Value Types)**: 3 +- 🟩 **Structs (Value Types)**: 4 - 🟧 **Interfaces**: 3 - 🟪 **Enums**: 1 -- 🟨 **Records**: 2 +- 🟨 **Records**: 3 **By Mutability**: - **Immutable**: 12 components -- **Mutable**: 5 components (CacheState, IntentManager._currentIntentCts, Storage implementations) +- **Mutable**: 5 components (CacheState, IntentController._pendingIntent, Storage implementations) **By Execution Context**: - **User Thread**: 2 (UserRequestHandler, IntentController.PublishIntent) @@ -101,7 +101,7 @@ This document provides a comprehensive catalog of all components in the Sliding │ ├── implements → 🟦 TaskBasedRebalanceExecutionController (default) │ └── implements → 🟦 ChannelBasedRebalanceExecutionController (optional) ├── 🟦 RebalanceDecisionEngine - │ ├── owns → 🟩 ThresholdRebalancePolicy + │ ├── owns → 🟩 NoRebalanceSatisfactionPolicy │ └── owns → 🟩 ProportionalRangePlanner ├── 🟦 RebalanceExecutor └── 🟦 CacheDataExtensionService @@ -135,7 +135,7 @@ The system uses a **multi-stage rebalance decision pipeline**, not a cancellatio **Pipeline Stages** (all must pass for execution): 1. **Stage 1: Current Cache NoRebalanceRange Validation** - - Component: `ThresholdRebalancePolicy.ShouldRebalance()` + - Component: `NoRebalanceSatisfactionPolicy.ShouldRebalance()` - Check: Is RequestedRange contained in NoRebalanceRange(CurrentCacheRange)? - Purpose: Fast-path rejection if current cache provides sufficient buffer - Result: Skip if contained (no I/O needed) @@ -147,12 +147,21 @@ The system uses a **multi-stage rebalance decision pipeline**, not a cancellatio - Result: Skip if pending rebalance covers request - Note: May be implemented via cancellation timing optimization -3. **Stage 3: DesiredCacheRange vs CurrentCacheRange Equality** +3. **Stage 3: DesiredCacheRange Computation** + - Component: `ProportionalRangePlanner.Plan()` + `NoRebalanceRangePlanner.Plan()` + - Computes DesiredCacheRange and DesiredNoRebalanceRange from RequestedRange + config + - Purpose: Determine the target cache geometry + +4. **Stage 4: DesiredCacheRange vs CurrentCacheRange Equality** - Component: `RebalanceDecisionEngine.Evaluate` (pre-scheduling analytical check) - Check: Does computed DesiredCacheRange == CurrentCacheRange? - Purpose: Avoid no-op mutations - Result: Skip scheduling if cache already in optimal configuration +5. **Stage 5: Schedule Execution** + - All previous stages passed — return execute decision with desired ranges + - Result: IntentController cancels previous pending execution and enqueues new one + **Execution Rule**: Rebalance executes ONLY if ALL stages confirm necessity. ### Component Responsibilities in Decision Model @@ -163,7 +172,7 @@ The system uses a **multi-stage rebalance decision pipeline**, not a cancellatio | **IntentController** | Manages intent lifecycle; runs background processing loop | No decision authority | | **IRebalanceExecutionController** | Debounce + execution serialization | No decision authority | | **RebalanceDecisionEngine** | **SOLE AUTHORITY** for necessity determination | **Yes - THE authority** | -| **ThresholdRebalancePolicy** | Stage 1 validation (NoRebalanceRange check) | Analytical input | +| **NoRebalanceSatisfactionPolicy** | Stage 1 & 2 validation (NoRebalanceRange check) | Analytical input | | **ProportionalRangePlanner** | Computes desired cache geometry | Analytical input | | **RebalanceExecutor** | Mechanical execution; assumes validated necessity | No decision authority | @@ -174,7 +183,7 @@ The system prioritizes **decision correctness and work avoidance** over aggressi **Work Avoidance Mechanisms:** - Stage 1: Avoid rebalance if current cache sufficient (NoRebalanceRange containment) - Stage 2: Avoid redundant rebalance if pending execution covers request (anti-thrashing) -- Stage 3: Avoid no-op mutations if cache already optimal (Desired==Current) +- Stage 4: Avoid no-op mutations if cache already optimal (Desired==Current) **Smart Eventual Consistency:** @@ -227,7 +236,7 @@ This section bridges architectural invariants (documented in [invariants.md](inv - **Cooperative Cancellation**: Multiple checkpoints in execution pipeline check for cancellation **Source References**: -- `src/SlidingWindowCache/Core/Intent/IntentManager.cs` - Cancellation token lifecycle management +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Cancellation token lifecycle management - `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` - Multi-stage validation gates cancellation - `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` - Cancellation checkpoints (ThrowIfCancellationRequested) @@ -237,12 +246,12 @@ This section bridges architectural invariants (documented in [invariants.md](inv **Enforcement Mechanism**: - **Latest-Wins Semantics**: Interlocked.Exchange replaces previous intent atomically -- **Intent Singularity**: Single-writer architecture for intent state (IntentManager) +- **Intent Singularity**: Single-writer architecture for intent state (IntentController) - **Early Exit Validation**: Cancellation checked after debounce delay before execution starts **Source References**: -- `src/SlidingWindowCache/Core/Intent/IntentManager.cs` - Atomic intent replacement via Interlocked.Exchange -- `src/SlidingWindowCache/Core/Intent/IntentController.cs` - Intent processing loop with early exit on cancellation +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Atomic intent replacement via Interlocked.Exchange +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Intent processing loop with early exit on cancellation ### UserRequestHandler Responsibilities @@ -254,7 +263,7 @@ This section bridges architectural invariants (documented in [invariants.md](inv **Source References**: - `src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs` - Exclusive intent publisher, minimal work implementation -- `src/SlidingWindowCache/Core/Intent/IntentController.cs` - Intent publication interface (internal visibility) +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Intent publication interface (internal visibility) ### Async Execution Model @@ -266,7 +275,7 @@ This section bridges architectural invariants (documented in [invariants.md](inv - **Thread Context Separation**: User thread vs ThreadPool thread isolation **Source References**: -- `src/SlidingWindowCache/Core/Intent/IntentController.cs` - ProcessIntentsAsync loop runs on background thread +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - ProcessIntentsAsync loop runs on background thread - `src/SlidingWindowCache/Infrastructure/Execution/TaskBasedRebalanceExecutionController.cs` - Task.Run scheduling - `src/SlidingWindowCache/Infrastructure/Execution/ChannelBasedRebalanceExecutionController.cs` - Channel-based background execution @@ -307,7 +316,7 @@ This section bridges architectural invariants (documented in [invariants.md](inv **Source References**: - `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` - Cancellation validation before cache mutation -- `src/SlidingWindowCache/Core/Intent/IntentManager.cs` - Token lifecycle management +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Token lifecycle management ### Intent Singularity @@ -319,7 +328,7 @@ This section bridges architectural invariants (documented in [invariants.md](inv - **No Queue Buildup**: At most one pending intent at any time **Source References**: -- `src/SlidingWindowCache/Core/Intent/IntentManager.cs` - Interlocked.Exchange for atomic intent replacement +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Interlocked.Exchange for atomic intent replacement ### Cancellation Protocol @@ -344,7 +353,7 @@ This section bridges architectural invariants (documented in [invariants.md](inv - **Decision Engine Authority**: All stages must pass for execution to proceed **Source References**: -- `src/SlidingWindowCache/Core/Intent/IntentController.cs` - Cancellation check in ProcessIntentsAsync after debounce +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Cancellation check in ProcessIntentsAsync after debounce - `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` - Multi-stage early exit logic ### Serial Execution Guarantee @@ -357,8 +366,8 @@ This section bridges architectural invariants (documented in [invariants.md](inv - **Sequential Processing**: Intent processing loop ensures serial execution **Source References**: -- `src/SlidingWindowCache/Core/Intent/IntentController.cs` - Sequential intent processing loop -- `src/SlidingWindowCache/Core/Intent/IntentManager.cs` - Cancellation of previous execution before scheduling new +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Sequential intent processing loop +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Cancellation of previous execution before scheduling new ### Intent Data Contract @@ -370,7 +379,7 @@ This section bridges architectural invariants (documented in [invariants.md](inv - **Type Safety**: Compiler enforces data presence **Source References**: -- `src/SlidingWindowCache/Core/Intent/IntentController.cs` - PublishIntent(requestedRange, deliveredData) signature +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - PublishIntent(requestedRange, deliveredData) signature - `src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs` - Single data materialization shared between paths ### Pure Decision Logic @@ -385,7 +394,7 @@ This section bridges architectural invariants (documented in [invariants.md](inv **Source References**: - `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` - Pure evaluation logic -- `src/SlidingWindowCache/Core/Planning/ThresholdRebalancePolicy.cs` - Stateless struct +- `src/SlidingWindowCache/Core/Planning/NoRebalanceSatisfactionPolicy.cs` - Stateless struct - `src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs` - Stateless struct ### Decision-Execution Separation @@ -517,7 +526,7 @@ This section bridges architectural invariants (documented in [invariants.md](inv - **Documentation**: XML comments verify ordering at each publication site **Source References**: -- `src/SlidingWindowCache/Core/Intent/IntentController.cs` - Increment before semaphore.Release +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Increment before semaphore.Release - `src/SlidingWindowCache/Infrastructure/Execution/` - Increment before channel.Writer.WriteAsync or Task.Run ### Activity Counter Cleanup @@ -530,7 +539,7 @@ This section bridges architectural invariants (documented in [invariants.md](inv - **Catch Blocks**: Manual decrement in catch blocks for pre-execution failures **Source References**: -- `src/SlidingWindowCache/Core/Intent/IntentController.cs` - Finally block in ProcessIntentsAsync loop +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Finally block in ProcessIntentsAsync loop - `src/SlidingWindowCache/Infrastructure/Execution/` - Finally blocks in execution controllers --- @@ -572,7 +581,7 @@ public record WindowCacheOptions **Used by**: - WindowCache (constructor) -- ThresholdRebalancePolicy (threshold configuration) +- NoRebalanceSatisfactionPolicy (threshold configuration) - ProportionalRangePlanner (size configuration) --- @@ -1169,7 +1178,7 @@ internal sealed class RebalanceDecisionEngine **Role**: Pure Decision Logic - **SOLE AUTHORITY for Rebalance Necessity Determination** **Dependencies** (all readonly, value types): -- ThresholdRebalancePolicy (threshold validation logic) +- NoRebalanceSatisfactionPolicy (threshold validation logic) - ProportionalRangePlanner (cache range planning) - NoRebalanceRangePlanner (no-rebalance range planning) @@ -1227,12 +1236,12 @@ internal sealed class RebalanceDecisionEngine --- -#### 🟩 ThresholdRebalancePolicy +#### 🟩 NoRebalanceSatisfactionPolicy ```csharp -internal readonly struct ThresholdRebalancePolicy +internal readonly struct NoRebalanceSatisfactionPolicy ``` -**File**: `src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs` +**File**: `src/SlidingWindowCache/Core/Rebalance/Decision/NoRebalanceSatisfactionPolicy.cs` **Type**: Struct (readonly value type) @@ -1240,7 +1249,6 @@ internal readonly struct ThresholdRebalancePolicy **Key Methods**: - **ShouldRebalance**: Determines if requested range is outside no-rebalance range -- **GetNoRebalanceRange**: Computes no-rebalance range by shrinking cache range using threshold ratios **Characteristics**: - ✅ **Value type** (struct, passed by value) @@ -1248,14 +1256,13 @@ internal readonly struct ThresholdRebalancePolicy - ✅ **Configuration-driven** (uses WindowCacheOptions) - ✅ **Stateless** (readonly fields) -**Ownership**: Value type, copied into RebalanceDecisionEngine and RebalanceExecutor +**Ownership**: Value type, copied into RebalanceDecisionEngine **Execution Context**: Background Thread (invoked by RebalanceDecisionEngine within intent processing loop - see IntentController.ProcessIntentsAsync) **Responsibilities**: -- Compute NoRebalanceRange (shrinks cache by threshold ratios) -- Check if requested range falls outside no-rebalance zone -- Answers: **"When to rebalance"** +- Check if requested range falls outside no-rebalance zone (Stages 1 & 2) +- Answers: **"When to rebalance"** (decision evaluation only; planning delegated to `NoRebalanceRangePlanner`) **Invariants Enforced**: - 26: No rebalance if inside NoRebalanceRange @@ -1701,7 +1708,7 @@ Creates and wires all internal components in dependency order: │ ├─ 🟦 IntentController ────────────────────┼───┼───┼───┐ │ │ │ └─ 🟧 IRebalanceExecutionController ───┼───┼───┼───┼───┐ │ │ ├─ 🟦 RebalanceDecisionEngine ─────────────┼───┼───┼───┼───┼───┐ │ -│ │ ├─ 🟩 ThresholdRebalancePolicy │ │ │ │ │ │ │ +│ │ ├─ 🟩 NoRebalanceSatisfactionPolicy │ │ │ │ │ │ │ │ │ └─ 🟩 ProportionalRangePlanner │ │ │ │ │ │ │ │ └─ 🟦 RebalanceExecutor ────────────────────┼───┼───┼───┼───┼───┤ │ │ │ │ │ │ │ │ │ @@ -1759,7 +1766,7 @@ Creates and wires all internal components in dependency order: │ 🟦 CLASS (sealed) │ │ │ │ │ │ Fields (value types): │ │ -│ ├─ 🟩 ThresholdRebalancePolicy _policy │ │ +│ ├─ 🟩 NoRebalanceSatisfactionPolicy _policy │ │ │ ├─ 🟩 ProportionalRangePlanner _planner │ │ │ └─ 🟩 NoRebalanceRangePlanner _noRebalancePlanner │ │ │ │ │ @@ -2019,7 +2026,7 @@ The Sliding Window Cache follows a **single consumer model** as documented in `d | **RebalanceDecisionEngine** | 🔄 **Background** | Invoked in intent processing loop, CPU-only logic | | **ProportionalRangePlanner** | 🔄 **Background** | Invoked by DecisionEngine in intent processing loop | | **NoRebalanceRangePlanner** | 🔄 **Background** | Invoked by DecisionEngine in intent processing loop | -| **ThresholdRebalancePolicy** | 🔄 **Background** | Invoked by DecisionEngine in intent processing loop | +| **NoRebalanceSatisfactionPolicy** | 🔄 **Background** | Invoked by DecisionEngine in intent processing loop | | **IRebalanceExecutionController.PublishExecutionRequest()** | 🔄 **Background** | Invoked by intent loop (task-based: sync, channel-based: async await) | | **TaskBasedRebalanceExecutionController.ChainExecutionAsync()** | 🔄 **Background** | Task chain execution (sequential) | | **ChannelBasedRebalanceExecutionController.ProcessExecutionRequestsAsync()** | 🔄 **Background** | Channel loop execution | @@ -2083,7 +2090,7 @@ The Sliding Window Cache follows a **single consumer model** as documented in `d │ .Evaluate() │ Stage 2: Pending NoRebalanceRange chk│ │ ├─ Stage 3 ────────────────→ │ • ProportionalRangePlanner.Plan() │ │ │ │ • NoRebalanceRangePlanner.Plan() │ -│ ├─ ThresholdRebalancePolicy │ Stage 4: Equality check │ +│ ├─ NoRebalanceSatisfactionPolicy│ Stage 4: Equality check │ │ └─ Return Decision │ Stage 5: Return decision │ │ ↓ │ │ │ If Skip: continue loop │ • Diagnostics event │ @@ -2266,8 +2273,9 @@ var sharedCache = new WindowCache(...); | Component | Mutability | Ownership | Lifetime | |--------------------------|------------|------------------------|--------------------| -| ThresholdRebalancePolicy | Readonly | Copied into components | Component lifetime | +| NoRebalanceSatisfactionPolicy | Readonly | Copied into components | Component lifetime | | ProportionalRangePlanner | Readonly | Copied into components | Component lifetime | +| NoRebalanceRangePlanner | Readonly | Copied into components | Component lifetime | | RebalanceDecision | Readonly | Local variable | Method scope | ### Other Types @@ -2276,6 +2284,7 @@ var sharedCache = new WindowCache(...); |--------------------|--------------|------------------------|------------| | WindowCacheOptions | 🟨 Record | Configuration | Immutable | | RangeChunk | 🟨 Record | Data transfer | Immutable | +| Intent | 🟨 Record | Intent data container | Immutable | | UserCacheReadMode | 🟪 Enum | Configuration option | Immutable | | ICacheStorage | 🟧 Interface | Storage abstraction | - | | IDataSource | 🟧 Interface | External data contract | - | @@ -2294,7 +2303,7 @@ var sharedCache = new WindowCache(...); **Background Thread (Intent Processing Loop)**: - IntentController.ProcessIntentsAsync - Intent processing loop, decision orchestration - RebalanceDecisionEngine - Pure decision logic (CPU-only, deterministic) -- ThresholdRebalancePolicy - Threshold validation (value type, inline) +- NoRebalanceSatisfactionPolicy - Threshold validation (value type, inline) - ProportionalRangePlanner - Cache geometry planning (value type, inline) **Background ThreadPool (Execution)**: @@ -2318,7 +2327,7 @@ var sharedCache = new WindowCache(...); **Decision Making**: - RebalanceDecisionEngine (orchestrator) -- ThresholdRebalancePolicy (thresholds) +- NoRebalanceSatisfactionPolicy (thresholds) - ProportionalRangePlanner (geometry) **Mutation**: @@ -2349,7 +2358,7 @@ Components follow actor-like patterns with clear responsibilities and message pa **ICacheStorage** with two implementations (SnapshotReadStorage, CopyOnReadStorage) allows runtime selection of storage strategy. ### 6. Value Object Pattern -**ThresholdRebalancePolicy**, **ProportionalRangePlanner**, **RebalanceDecision** are immutable value types with pure behavior. +**NoRebalanceSatisfactionPolicy**, **ProportionalRangePlanner**, **RebalanceDecision** are immutable value types with pure behavior. ### 7. Shared Mutable State (Controlled) **CacheState** is intentionally shared mutable state, coordinated via CancellationToken (not locks). @@ -2377,9 +2386,9 @@ Entire architecture assumes one logical consumer, avoiding traditional concurren The Sliding Window Cache is composed of **19 components** working together to provide fast, cache-aware data access with automatic rebalancing: - **10 classes** (reference types) provide the runtime behavior -- **3 structs** (value types) provide pure, stateless logic +- **4 structs** (value types) provide pure, stateless logic - **2 interfaces** define contracts for extensibility -- **2 records** provide immutable configuration and data transfer +- **3 records** provide immutable configuration and data transfer - **1 enum** defines storage strategy options The architecture follows a **single consumer model** with **no traditional synchronization primitives**, relying instead on **CancellationToken** for coordination between the fast User Path and the async Rebalance Path. diff --git a/docs/invariants.md b/docs/invariants.md index 0abd40f..82a1998 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -455,7 +455,7 @@ The system uses a **multi-stage rebalance decision pipeline**, not a cancellatio #### Multi-Stage Decision Pipeline -The Rebalance Decision Engine validates rebalance necessity through three sequential stages: +The Rebalance Decision Engine validates rebalance necessity through five stages: **Stage 1 — Current Cache NoRebalanceRange Validation** - **Purpose**: Fast-path check against current cache state @@ -750,7 +750,7 @@ I/O operations (data fetching via IDataSource) are divided by responsibility: **Implementation:** See [component-map.md - I/O Isolation](#implementation) for enforcement mechanism details. - 🔵 **[Architectural — Covered by same test as G.43]** -**G.46** 🟢 **[Behavioral — Tests: `Invariant_G46_UserCancellationDuringFetch`, `Invariant_G46_RebalanceCancellation`]** Cancellation **must be supported** for all scenarios: +**G.46** 🟢 **[Behavioral — Tests: `Invariant_G46_UserCancellationDuringFetch`, `Invariant_F35_G46_RebalanceCancellationBehavior`]** Cancellation **must be supported** for all scenarios: 1. **User-facing cancellation**: User-provided CancellationToken propagates through User Path to IDataSource.FetchAsync() 2. **Background rebalance cancellation**: System supports cancellation of pending/ongoing rebalance execution - *Observable via*: @@ -758,7 +758,7 @@ I/O operations (data fetching via IDataSource) are divided by responsibility: - Rebalance cancellation: System stability and lifecycle integrity under concurrent requests - *Test verifies*: - `Invariant_G46_UserCancellationDuringFetch`: Cancelling during IDataSource fetch throws OperationCanceledException - - `Invariant_G46_RebalanceCancellation`: Background rebalance supports cancellation mechanism (high-level guarantee) + - `Invariant_F35_G46_RebalanceCancellationBehavior`: Background rebalance supports cancellation mechanism (high-level guarantee) - *Important*: System does NOT guarantee cancellation on new requests. Cancellation MAY occur depending on Decision Engine scheduling validation. Focus is on system stability and cache consistency, not deterministic cancellation behavior. - *Related*: F.35 (detailed rebalance execution cancellation mechanics), A.0a (User Path priority via validation-driven cancellation) diff --git a/docs/scenario-model.md b/docs/scenario-model.md index 6194387..a237007 100644 --- a/docs/scenario-model.md +++ b/docs/scenario-model.md @@ -143,9 +143,9 @@ The User Path does not expand the cache beyond RequestedRange. 2. Cache computes intersection with CurrentCacheRange 3. Missing part is synchronously requested from IDataSource 4. Cache: - - merges cached and newly fetched data (cache expansion) + - merges cached and newly fetched data **locally** (in-memory assembly, not stored to cache) - does **not** trim excess data - - updates CurrentCacheRange to cover both old and new data + - does **not** update CurrentCacheRange (User Path is READ-ONLY with respect to cache state) 5. Rebalance is triggered asynchronously 6. RequestedRange data is returned to the user @@ -400,7 +400,7 @@ The Sliding Window Cache follows these rules: ### Expected Behavior -1. **U₂ cancels any pending rebalance work before performing its own cache mutations** +1. **The new intent from U₂ supersedes R₁: IntentController's decision pipeline cancels any pending rebalance work when validation confirms new execution is necessary** 2. User Path for U₂ executes normally and immediately 3. A new rebalance trigger R₂ is issued 4. R₁ is cancelled or marked obsolete @@ -421,7 +421,7 @@ No rebalance work is executed based on outdated user intent. User Path always ha ### Expected Behavior -1. **U₂ cancels ongoing rebalance execution R₁ before performing its own cache mutations** +1. **The new intent from U₂ supersedes R₁: IntentController's decision pipeline cancels the ongoing rebalance when validation confirms new execution is necessary** 2. User Path for U₂ executes normally and immediately 3. R₂ becomes the latest rebalance intent 4. R₁ receives a cancellation signal diff --git a/src/SlidingWindowCache.WasmValidation/WasmCompilationValidator.cs b/src/SlidingWindowCache.WasmValidation/WasmCompilationValidator.cs index 32d6104..176530d 100644 --- a/src/SlidingWindowCache.WasmValidation/WasmCompilationValidator.cs +++ b/src/SlidingWindowCache.WasmValidation/WasmCompilationValidator.cs @@ -18,7 +18,7 @@ public Task> FetchAsync(Range range, CancellationToken // 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); + var data = Enumerable.Range(start, end - start + 1).ToArray(); return Task.FromResult(new RangeChunk(range, data)); } @@ -31,9 +31,9 @@ CancellationToken cancellationToken { var start = r.Start.Value; var end = r.End.Value; - return new RangeChunk(r, Enumerable.Range(start, end - start + 1)); - }); - return Task.FromResult(chunks); + return new RangeChunk(r, Enumerable.Range(start, end - start + 1).ToArray()); + }).ToList(); + return Task.FromResult>>(chunks); } } diff --git a/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs b/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs index 9d4376e..f996e68 100644 --- a/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs +++ b/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs @@ -109,8 +109,8 @@ public Range Plan(Range requested) return requested.Expand( domain: _domain, - left: (long)left, - right: (long)right + left: (long)Math.Round(left), + right: (long)Math.Round(right) ); } } \ No newline at end of file diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecision.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecision.cs index 3e4bc65..9547828 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecision.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecision.cs @@ -30,12 +30,12 @@ internal readonly struct RebalanceDecision public RebalanceReason Reason { get; } private RebalanceDecision( - bool shouldSchedule, + bool isExecutionRequired, Range? desiredRange, Range? desiredNoRebalanceRange, RebalanceReason reason) { - IsExecutionRequired = shouldSchedule; + IsExecutionRequired = isExecutionRequired; DesiredRange = desiredRange; DesiredNoRebalanceRange = desiredNoRebalanceRange; Reason = reason; diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceReason.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceReason.cs index 8bcb80a..7d3decc 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceReason.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceReason.cs @@ -5,6 +5,11 @@ namespace SlidingWindowCache.Core.Rebalance.Decision; /// internal enum RebalanceReason { + /// + /// Default unspecified value. This value should never appear in practice. + /// + Unspecified = 0, + /// /// Request falls within the current cache's no-rebalance range (Stage 1 stability). /// diff --git a/src/SlidingWindowCache/Infrastructure/Extensions/IntervalsNetDomainExtensions.cs b/src/SlidingWindowCache/Infrastructure/Extensions/IntervalsNetDomainExtensions.cs index a571bb9..9e90ad2 100644 --- a/src/SlidingWindowCache/Infrastructure/Extensions/IntervalsNetDomainExtensions.cs +++ b/src/SlidingWindowCache/Infrastructure/Extensions/IntervalsNetDomainExtensions.cs @@ -40,6 +40,11 @@ internal static RangeValue Span(this Range range, where TRange : IComparable where TDomain : IRangeDomain => domain switch { + // FQN required: Intervals.NET exposes Span/Expand in separate Fixed and Variable namespaces + // (Intervals.NET.Domain.Extensions.Fixed and ...Variable). Both namespaces define a + // RangeDomainExtensions class with the same method names, so a using directive would cause + // an ambiguity error. Full qualification unambiguously selects the correct overload at + // compile time without polluting the file's namespace imports. IFixedStepDomain fixedDomain => Intervals.NET.Domain.Extensions.Fixed.RangeDomainExtensions.Span(range, fixedDomain), IVariableStepDomain variableDomain => Intervals.NET.Domain.Extensions.Variable.RangeDomainExtensions.Span(range, variableDomain), _ => throw new NotSupportedException( diff --git a/src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs b/src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs index 8c630bc..b917629 100644 --- a/src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs +++ b/src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs @@ -3,6 +3,18 @@ /// /// Options for configuring the behavior of the sliding window cache. /// +/// +/// Warning — record with-expressions bypass constructor validation: +/// +/// Because is a record type, C# allows creating mutated copies +/// via with-expressions (e.g., options with { LeftThreshold = 0.9, RightThreshold = 0.9 }). +/// with-expressions do NOT invoke the constructor, so all validation guards are bypassed. +/// +/// +/// Always construct using the primary constructor to ensure +/// all invariants (range sizes ≥ 0, threshold sum ≤ 1.0, queue capacity > 0) are enforced. +/// +/// public record WindowCacheOptions { /// @@ -64,6 +76,18 @@ public WindowCacheOptions( "RightThreshold must be greater than or equal to 0."); } + if (leftThreshold is > 1.0) + { + throw new ArgumentOutOfRangeException(nameof(leftThreshold), + "LeftThreshold must not exceed 1.0."); + } + + if (rightThreshold is > 1.0) + { + throw new ArgumentOutOfRangeException(nameof(rightThreshold), + "RightThreshold must not exceed 1.0."); + } + // Validate that thresholds don't overlap (sum must not exceed 1.0) if (leftThreshold.HasValue && rightThreshold.HasValue && (leftThreshold.Value + rightThreshold.Value) > 1.0) diff --git a/src/SlidingWindowCache/Public/Dto/RangeChunk.cs b/src/SlidingWindowCache/Public/Dto/RangeChunk.cs index a65d5d0..37e498e 100644 --- a/src/SlidingWindowCache/Public/Dto/RangeChunk.cs +++ b/src/SlidingWindowCache/Public/Dto/RangeChunk.cs @@ -14,7 +14,7 @@ namespace SlidingWindowCache.Public.Dto; /// /// /// The data elements for the range. -/// Empty enumerable when Range is null. +/// Empty list when Range is null. /// /// /// IDataSource Contract: @@ -25,8 +25,8 @@ namespace SlidingWindowCache.Public.Dto; /// /// // Database with records ID 100-500 /// // Request [50..150] → Return RangeChunk([100..150], 51 records) -/// // Request [600..700] → Return RangeChunk(null, empty enumerable) +/// // Request [600..700] → Return RangeChunk(null, empty list) /// /// -public sealed record RangeChunk(Range? Range, IEnumerable Data) +public sealed record RangeChunk(Range? Range, IReadOnlyList Data) where TRange : IComparable; \ No newline at end of file diff --git a/src/SlidingWindowCache/Public/IDataSource.cs b/src/SlidingWindowCache/Public/IDataSource.cs index 28ebb44..370ffca 100644 --- a/src/SlidingWindowCache/Public/IDataSource.cs +++ b/src/SlidingWindowCache/Public/IDataSource.cs @@ -78,7 +78,7 @@ public interface IDataSource where TRange : IComparable /// 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 + /// Ensure Data.Count equals Range.Span when Range is non-null /// /// Boundary Handling Examples: /// @@ -152,7 +152,7 @@ async Task>> FetchAsync( CancellationToken cancellationToken ) { - var tasks = ranges.Select(async range => await FetchAsync(range, cancellationToken)); + var tasks = ranges.Select(range => FetchAsync(range, cancellationToken)); return await Task.WhenAll(tasks); } } \ No newline at end of file diff --git a/src/SlidingWindowCache/Public/Instrumentation/EventCounterCacheDiagnostics.cs b/src/SlidingWindowCache/Public/Instrumentation/EventCounterCacheDiagnostics.cs index d8f6333..15e8010 100644 --- a/src/SlidingWindowCache/Public/Instrumentation/EventCounterCacheDiagnostics.cs +++ b/src/SlidingWindowCache/Public/Instrumentation/EventCounterCacheDiagnostics.cs @@ -26,24 +26,24 @@ public sealed class EventCounterCacheDiagnostics : ICacheDiagnostics private int _dataSegmentUnavailable; private int _rebalanceExecutionFailed; - public int UserRequestServed => _userRequestServed; - public int CacheExpanded => _cacheExpanded; - public int CacheReplaced => _cacheReplaced; - public int UserRequestFullCacheHit => _userRequestFullCacheHit; - public int UserRequestPartialCacheHit => _userRequestPartialCacheHit; - public int UserRequestFullCacheMiss => _userRequestFullCacheMiss; - public int DataSourceFetchSingleRange => _dataSourceFetchSingleRange; - public int DataSourceFetchMissingSegments => _dataSourceFetchMissingSegments; - public int DataSegmentUnavailable => _dataSegmentUnavailable; - public int RebalanceIntentPublished => _rebalanceIntentPublished; - public int RebalanceExecutionStarted => _rebalanceExecutionStarted; - public int RebalanceExecutionCompleted => _rebalanceExecutionCompleted; - public int RebalanceExecutionCancelled => _rebalanceExecutionCancelled; - public int RebalanceSkippedCurrentNoRebalanceRange => _rebalanceSkippedCurrentNoRebalanceRange; - public int RebalanceSkippedPendingNoRebalanceRange => _rebalanceSkippedPendingNoRebalanceRange; - public int RebalanceSkippedSameRange => _rebalanceSkippedSameRange; - public int RebalanceScheduled => _rebalanceScheduled; - public int RebalanceExecutionFailed => _rebalanceExecutionFailed; + public int UserRequestServed => Volatile.Read(ref _userRequestServed); + public int CacheExpanded => Volatile.Read(ref _cacheExpanded); + public int CacheReplaced => Volatile.Read(ref _cacheReplaced); + public int UserRequestFullCacheHit => Volatile.Read(ref _userRequestFullCacheHit); + public int UserRequestPartialCacheHit => Volatile.Read(ref _userRequestPartialCacheHit); + public int UserRequestFullCacheMiss => Volatile.Read(ref _userRequestFullCacheMiss); + public int DataSourceFetchSingleRange => Volatile.Read(ref _dataSourceFetchSingleRange); + public int DataSourceFetchMissingSegments => Volatile.Read(ref _dataSourceFetchMissingSegments); + public int DataSegmentUnavailable => Volatile.Read(ref _dataSegmentUnavailable); + public int RebalanceIntentPublished => Volatile.Read(ref _rebalanceIntentPublished); + public int RebalanceExecutionStarted => Volatile.Read(ref _rebalanceExecutionStarted); + public int RebalanceExecutionCompleted => Volatile.Read(ref _rebalanceExecutionCompleted); + public int RebalanceExecutionCancelled => Volatile.Read(ref _rebalanceExecutionCancelled); + public int RebalanceSkippedCurrentNoRebalanceRange => Volatile.Read(ref _rebalanceSkippedCurrentNoRebalanceRange); + public int RebalanceSkippedPendingNoRebalanceRange => Volatile.Read(ref _rebalanceSkippedPendingNoRebalanceRange); + public int RebalanceSkippedSameRange => Volatile.Read(ref _rebalanceSkippedSameRange); + public int RebalanceScheduled => Volatile.Read(ref _rebalanceScheduled); + public int RebalanceExecutionFailed => Volatile.Read(ref _rebalanceExecutionFailed); /// void ICacheDiagnostics.CacheExpanded() => Interlocked.Increment(ref _cacheExpanded); @@ -119,6 +119,14 @@ void ICacheDiagnostics.RebalanceExecutionFailed(Exception ex) /// /// Resets all counters to zero. Use this before each test to ensure clean state. /// + /// + /// Warning — not atomic: This method resets each counter individually using + /// . In a concurrent environment, another thread may increment a counter + /// between two consecutive resets, leaving the object in a partially-reset state. Only call this + /// method when you can guarantee that no other thread is mutating the counters (e.g., after + /// WaitForIdleAsync in tests). + /// + /// public void Reset() { Volatile.Write(ref _userRequestServed, 0); diff --git a/src/SlidingWindowCache/Public/Instrumentation/NoOpDiagnostics.cs b/src/SlidingWindowCache/Public/Instrumentation/NoOpDiagnostics.cs index c6d2fca..ac74b64 100644 --- a/src/SlidingWindowCache/Public/Instrumentation/NoOpDiagnostics.cs +++ b/src/SlidingWindowCache/Public/Instrumentation/NoOpDiagnostics.cs @@ -5,6 +5,11 @@ /// public sealed class NoOpDiagnostics : ICacheDiagnostics { + /// + /// A shared singleton instance. Use this to avoid unnecessary allocations. + /// + public static readonly NoOpDiagnostics Instance = new(); + /// public void CacheExpanded() { @@ -73,6 +78,9 @@ public void RebalanceScheduled() /// public void RebalanceExecutionFailed(Exception ex) { + // Intentional no-op: this implementation discards all diagnostics including failures. + // For production systems, use EventCounterCacheDiagnostics or a custom ICacheDiagnostics + // implementation that logs to your observability pipeline. } /// diff --git a/src/SlidingWindowCache/Public/WindowCache.cs b/src/SlidingWindowCache/Public/WindowCache.cs index f14db00..208b2a0 100644 --- a/src/SlidingWindowCache/Public/WindowCache.cs +++ b/src/SlidingWindowCache/Public/WindowCache.cs @@ -48,7 +48,7 @@ public sealed class WindowCache // TaskCompletionSource for coordinating concurrent DisposeAsync calls // Allows loser threads to await disposal completion without CPU burn // Published via Volatile.Write when winner thread starts disposal - private TaskCompletionSource? _disposalCompletionSource; + private TaskCompletionSource? _disposalCompletionSource; /// /// Initializes a new instance of the class. @@ -76,7 +76,7 @@ public WindowCache( ) { // Initialize diagnostics (use NoOpDiagnostics if null to avoid null checks in actors) - cacheDiagnostics ??= new NoOpDiagnostics(); + cacheDiagnostics ??= NoOpDiagnostics.Instance; var cacheStorage = CreateCacheStorage(domain, options); var state = new CacheState(cacheStorage, domain); @@ -139,17 +139,15 @@ AsyncActivityCounter activityCounter activityCounter ); } - else - { - // Bounded strategy: Channel-based serialization with backpressure support - return new ChannelBasedRebalanceExecutionController( - executor, - options.DebounceDelay, - cacheDiagnostics, - activityCounter, - options.RebalanceQueueCapacity.Value - ); - } + + // Bounded strategy: Channel-based serialization with backpressure support + return new ChannelBasedRebalanceExecutionController( + executor, + options.DebounceDelay, + cacheDiagnostics, + activityCounter, + options.RebalanceQueueCapacity.Value + ); } /// @@ -261,7 +259,7 @@ public Task WaitForIdleAsync(CancellationToken cancellationToken = default) /// Thread Safety: /// /// Uses lock-free synchronization via , , - /// and operations, consistent with the project's + /// and operations, consistent with the project's /// "Mostly Lock-Free Concurrency" architecture principle. /// /// Concurrent Disposal Coordination: @@ -300,7 +298,7 @@ public async ValueTask DisposeAsync() if (previousState == 0) { // Winner thread - create TCS and perform disposal - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Volatile.Write(ref _disposalCompletionSource, tcs); try @@ -310,7 +308,7 @@ public async ValueTask DisposeAsync() await _userRequestHandler.DisposeAsync().ConfigureAwait(false); // Signal successful completion - tcs.TrySetResult(true); + tcs.TrySetResult(); } catch (Exception ex) { @@ -328,7 +326,7 @@ public async ValueTask DisposeAsync() { // Loser thread - await disposal completion asynchronously // Brief spin-wait for TCS publication (should be very fast - CPU-only operation) - TaskCompletionSource? tcs; + TaskCompletionSource? tcs; var spinWait = new SpinWait(); while ((tcs = Volatile.Read(ref _disposalCompletionSource)) == null) diff --git a/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs b/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs index 9667576..addf416 100644 --- a/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/DataSourceRangePropagationTests.cs @@ -256,7 +256,7 @@ public async Task Rebalance_ColdStart_ExpandsSymmetrically() // Left expansion: 11 * 1 = 11, so [89, 100) _dataSource.AssertRangeRequested(Intervals.NET.Factories.Range.ClosedOpen(89, 100)); - // Right expansion: 11 * 2.0 = 22, so (110, 121] + // Right expansion: 11 * 1.0 = 11, so (110, 121] _dataSource.AssertRangeRequested(Intervals.NET.Factories.Range.OpenClosed(110, 121)); } diff --git a/tests/SlidingWindowCache.Integration.Tests/ExecutionStrategySelectionTests.cs b/tests/SlidingWindowCache.Integration.Tests/ExecutionStrategySelectionTests.cs index 692398a..c288245 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 SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; using SlidingWindowCache.Public.Dto; +using SlidingWindowCache.Tests.Infrastructure.DataSources; namespace SlidingWindowCache.Integration.Tests; @@ -12,59 +12,8 @@ namespace SlidingWindowCache.Integration.Tests; /// public class ExecutionStrategySelectionTests { - #region Test Data Source - - private class TestDataSource : IDataSource - { - public Task> FetchAsync( - Range range, - CancellationToken cancellationToken) - { - return Task.FromResult(new RangeChunk(range, GenerateDataForRange(range))); - } - - /// - /// Generates data respecting range boundary inclusivity. - /// Uses pattern matching to handle all 4 combinations of inclusive/exclusive boundaries. - /// - private static IEnumerable 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($"Item_{i}"); - break; - - case { IsStartInclusive: true, IsEndInclusive: false }: - // [start, end) - for (var i = start; i < end; i++) - data.Add($"Item_{i}"); - break; - - case { IsStartInclusive: false, IsEndInclusive: true }: - // (start, end] - for (var i = start + 1; i <= end; i++) - data.Add($"Item_{i}"); - break; - - default: - // (start, end) - for (var i = start + 1; i < end; i++) - data.Add($"Item_{i}"); - break; - } - - return data; - } - } - - #endregion + private static IDataSource CreateDataSource() => + new SimpleTestDataSource(i => $"Item_{i}"); #region Task-Based Strategy Tests (Unbounded - Default) @@ -72,7 +21,7 @@ private static IEnumerable GenerateDataForRange(Range range) public async Task WindowCache_WithNullCapacity_UsesTaskBasedStrategy() { // ARRANGE - var dataSource = new TestDataSource(); + var dataSource = CreateDataSource(); var domain = new IntegerFixedStepDomain(); var options = new WindowCacheOptions( leftCacheSize: 1.0, @@ -100,7 +49,7 @@ public async Task WindowCache_WithNullCapacity_UsesTaskBasedStrategy() public async Task WindowCache_WithDefaultParameters_UsesTaskBasedStrategy() { // ARRANGE - var dataSource = new TestDataSource(); + var dataSource = CreateDataSource(); var domain = new IntegerFixedStepDomain(); var options = new WindowCacheOptions( leftCacheSize: 1.0, @@ -128,7 +77,7 @@ public async Task WindowCache_WithDefaultParameters_UsesTaskBasedStrategy() public async Task TaskBasedStrategy_UnderLoad_MaintainsSerialExecution() { // ARRANGE - var dataSource = new TestDataSource(); + var dataSource = CreateDataSource(); var domain = new IntegerFixedStepDomain(); var options = new WindowCacheOptions( leftCacheSize: 0.5, @@ -176,7 +125,7 @@ public async Task TaskBasedStrategy_UnderLoad_MaintainsSerialExecution() public async Task WindowCache_WithBoundedCapacity_UsesChannelBasedStrategy() { // ARRANGE - var dataSource = new TestDataSource(); + var dataSource = CreateDataSource(); var domain = new IntegerFixedStepDomain(); var options = new WindowCacheOptions( leftCacheSize: 1.0, @@ -204,7 +153,7 @@ public async Task WindowCache_WithBoundedCapacity_UsesChannelBasedStrategy() public async Task ChannelBasedStrategy_UnderLoad_MaintainsSerialExecution() { // ARRANGE - var dataSource = new TestDataSource(); + var dataSource = CreateDataSource(); var domain = new IntegerFixedStepDomain(); var options = new WindowCacheOptions( leftCacheSize: 0.5, @@ -248,7 +197,7 @@ public async Task ChannelBasedStrategy_UnderLoad_MaintainsSerialExecution() public async Task ChannelBasedStrategy_WithCapacityOne_WorksCorrectly() { // ARRANGE - Minimum capacity (strictest backpressure) - var dataSource = new TestDataSource(); + var dataSource = CreateDataSource(); var domain = new IntegerFixedStepDomain(); var options = new WindowCacheOptions( leftCacheSize: 0.5, @@ -288,7 +237,7 @@ public async Task ChannelBasedStrategy_WithCapacityOne_WorksCorrectly() public async Task TaskBasedStrategy_DisposalCompletesGracefully() { // ARRANGE - var dataSource = new TestDataSource(); + var dataSource = CreateDataSource(); var domain = new IntegerFixedStepDomain(); var options = new WindowCacheOptions( leftCacheSize: 1.0, @@ -318,7 +267,7 @@ await Assert.ThrowsAsync(async () => public async Task ChannelBasedStrategy_DisposalCompletesGracefully() { // ARRANGE - var dataSource = new TestDataSource(); + var dataSource = CreateDataSource(); var domain = new IntegerFixedStepDomain(); var options = new WindowCacheOptions( leftCacheSize: 1.0, diff --git a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs index 20843eb..f786bcb 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs @@ -1148,15 +1148,14 @@ public async Task Invariant_F40_F41_F42_PostExecutionGuarantees(string storageNa // ACT: Request and wait for rebalance to complete await TestHelpers.ExecuteRequestAndWaitForRebalance(cache, TestHelpers.CreateRange(100, 110)); - if (_cacheDiagnostics.RebalanceExecutionCompleted > 0) - { - // Verify rebalance was scheduled - TestHelpers.AssertRebalanceScheduled(_cacheDiagnostics, 1); - - // 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.Data, TestHelpers.CreateRange(90, 120)); - } + // ASSERT: At least one rebalance must complete for the post-execution guarantees to be meaningful + Assert.True(_cacheDiagnostics.RebalanceExecutionCompleted > 0, + "At least one rebalance must complete so that F.40/F.41/F.42 post-execution guarantees can be verified."); + // Verify rebalance was scheduled + TestHelpers.AssertRebalanceScheduled(_cacheDiagnostics, 1); + // After rebalance, cache should serve data from normalized range [100-10, 110+10] = [90, 120] + var normalizedData = await cache.GetDataAsync(TestHelpers.CreateRange(90, 120), CancellationToken.None); + TestHelpers.AssertUserDataCorrect(normalizedData.Data, TestHelpers.CreateRange(90, 120)); } /// @@ -1195,8 +1194,8 @@ public async Task Invariant_F38_IncrementalFetchOptimization() // ASSERT: Only missing segments should be fetched (incremental optimization) // The system should NOT refetch the entire [105, 120] range or full desired range // Depending on timing, this may be a partial hit with missing segments fetch - Assert.True(fetchedRanges.Count >= 0, - "Cache expansion should use incremental fetch (0 if already expanded enough, or missing segments only)"); + Assert.True(fetchedRanges.Count > 0, + "Cache expansion should trigger at least one incremental fetch of missing segments"); // If fetches occurred, verify they don't include already-cached data if (fetchedRanges.Count > 0) diff --git a/tests/SlidingWindowCache.Tests.Infrastructure/DataSources/FaultyDataSource.cs b/tests/SlidingWindowCache.Tests.Infrastructure/DataSources/FaultyDataSource.cs index 20103eb..c7b4c3d 100644 --- a/tests/SlidingWindowCache.Tests.Infrastructure/DataSources/FaultyDataSource.cs +++ b/tests/SlidingWindowCache.Tests.Infrastructure/DataSources/FaultyDataSource.cs @@ -14,18 +14,18 @@ namespace SlidingWindowCache.Tests.Infrastructure.DataSources; public sealed class FaultyDataSource : IDataSource where TRange : IComparable { - private readonly Func, IEnumerable> _fetchSingleRange; + private readonly Func, IReadOnlyList> _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. + /// or return any to control the returned data. /// The in the result is always set to /// the requested range — this class does not support returning a null Range. /// - public FaultyDataSource(Func, IEnumerable> fetchSingleRange) + public FaultyDataSource(Func, IReadOnlyList> fetchSingleRange) { _fetchSingleRange = fetchSingleRange; } @@ -56,7 +56,7 @@ public Task>> FetchAsync( /// 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) + public static IReadOnlyList GenerateStringData(Range range) { var data = new List(); for (var i = range.Start.Value; i <= range.End.Value; i++) diff --git a/tests/SlidingWindowCache.Tests.Infrastructure/DataSources/SimpleTestDataSource.cs b/tests/SlidingWindowCache.Tests.Infrastructure/DataSources/SimpleTestDataSource.cs new file mode 100644 index 0000000..bde7927 --- /dev/null +++ b/tests/SlidingWindowCache.Tests.Infrastructure/DataSources/SimpleTestDataSource.cs @@ -0,0 +1,82 @@ +using Intervals.NET; +using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Dto; + +namespace SlidingWindowCache.Tests.Infrastructure.DataSources; + +/// +/// A minimal generic test data source that generates data for any requested range +/// using a caller-provided data generation function. +/// +/// +/// This class exists to eliminate the near-identical private TestDataSource +/// inner classes that appear in multiple test files. Use this instead of per-file +/// private copies whenever the data-generation logic is range-boundary-driven and +/// does not need specific spy/fault injection behavior. +/// +/// The type of data produced by this source. +public sealed class SimpleTestDataSource : IDataSource +{ + private readonly Func _valueFactory; + private readonly bool _simulateAsyncDelay; + + /// + /// Creates a new instance. + /// + /// + /// Maps an integer position within the requested range to the data value at that position. + /// Called once per element (each integer within the inclusive bounds of the range). + /// + /// + /// When , adds a 1 ms to simulate real async I/O. + /// Defaults to . + /// + public SimpleTestDataSource(Func valueFactory, bool simulateAsyncDelay = false) + { + _valueFactory = valueFactory; + _simulateAsyncDelay = simulateAsyncDelay; + } + + /// + public async Task> FetchAsync( + Range requestedRange, + CancellationToken cancellationToken) + { + if (_simulateAsyncDelay) + await Task.Delay(1, cancellationToken); + + return new RangeChunk(requestedRange, GenerateData(requestedRange)); + } + + private List GenerateData(Range range) + { + var data = new List(); + var start = (int)range.Start; + var end = (int)range.End; + + switch (range) + { + case { IsStartInclusive: true, IsEndInclusive: true }: + for (var i = start; i <= end; i++) + data.Add(_valueFactory(i)); + break; + + case { IsStartInclusive: true, IsEndInclusive: false }: + for (var i = start; i < end; i++) + data.Add(_valueFactory(i)); + break; + + case { IsStartInclusive: false, IsEndInclusive: true }: + for (var i = start + 1; i <= end; i++) + data.Add(_valueFactory(i)); + break; + + default: + for (var i = start + 1; i < end; i++) + data.Add(_valueFactory(i)); + break; + } + + return data; + } +} diff --git a/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs b/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs index 15bbbb7..608cb7c 100644 --- a/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs +++ b/tests/SlidingWindowCache.Tests.Infrastructure/Helpers/TestHelpers.cs @@ -66,8 +66,8 @@ public static Range CalculateExpectedDesiredRange( { // Mimic ProportionalRangePlanner.Plan() logic var size = requestedRange.Span(domain); - var left = (long)(size.Value * options.LeftCacheSize); - var right = (long)(size.Value * options.RightCacheSize); + var left = (long)Math.Round(size.Value * options.LeftCacheSize); + var right = (long)Math.Round(size.Value * options.RightCacheSize); return requestedRange.Expand(domain, left, right); } @@ -343,7 +343,8 @@ public static void AssertRebalanceLifecycleIntegrity(EventCounterCacheDiagnostic var started = cacheDiagnostics.RebalanceExecutionStarted; var completed = cacheDiagnostics.RebalanceExecutionCompleted; var executionsCancelled = cacheDiagnostics.RebalanceExecutionCancelled; - Assert.Equal(started, completed + executionsCancelled); + var failed = cacheDiagnostics.RebalanceExecutionFailed; + Assert.Equal(started, completed + executionsCancelled + failed); } /// diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Extensions/IntegerVariableStepDomain.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Extensions/IntegerVariableStepDomain.cs index 79b5205..d029045 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Extensions/IntegerVariableStepDomain.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Extensions/IntegerVariableStepDomain.cs @@ -6,7 +6,7 @@ namespace SlidingWindowCache.Unit.Tests.Infrastructure.Extensions; /// Test implementation of IVariableStepDomain for integer values with custom step sizes. /// Used for testing domain-agnostic extension methods with variable-step domains. /// -internal class IntegerVariableStepDomain : IVariableStepDomain +public class IntegerVariableStepDomain : IVariableStepDomain { private readonly int[] _steps; diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Extensions/IntervalsNetDomainExtensionsTests.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Extensions/IntervalsNetDomainExtensionsTests.cs index 3a28125..6459e29 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Extensions/IntervalsNetDomainExtensionsTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Extensions/IntervalsNetDomainExtensionsTests.cs @@ -281,7 +281,7 @@ public void ExpandByRatio_WithFixedStepDomain_NegativeRatio_Shrinks() // The range should be smaller Assert.True(shrunk.Start.Value >= 10); Assert.True(shrunk.End.Value <= 30); - Assert.True(shrunk.Start.Value > 10 || shrunk.End.Value < 30); // At least one side changed + Assert.True(shrunk.Start.Value > 10 && shrunk.End.Value < 30); // Both sides must have changed } [Fact] @@ -341,7 +341,7 @@ public void ExpandByRatio_WithVariableStepDomain_ExpandsCorrectly() // ARRANGE var steps = new[] { 1, 2, 5, 10, 15, 20, 25, 30, 40, 50, 100, 200 }; var domain = new IntegerVariableStepDomain(steps); - var range = Intervals.NET.Factories.Range.Closed(10, 30); // Span = 4 steps (10, 15, 20, 25, 30) + var range = Intervals.NET.Factories.Range.Closed(10, 30); // Span = 5 steps (10, 15, 20, 25, 30) // ACT - Expand by 50% on each side (2 steps on each side) var expanded = range.ExpandByRatio(domain, leftRatio: 0.5, rightRatio: 0.5); diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/CopyOnReadStorageTests.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/CopyOnReadStorageTests.cs index ff761ff..b4b4581 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/CopyOnReadStorageTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/CopyOnReadStorageTests.cs @@ -2,6 +2,7 @@ using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Infrastructure.Storage; using SlidingWindowCache.Unit.Tests.Infrastructure.Extensions; +using SlidingWindowCache.Unit.Tests.Infrastructure.Storage.TestInfrastructure; using static SlidingWindowCache.Unit.Tests.Infrastructure.Storage.TestInfrastructure.StorageTestHelpers; namespace SlidingWindowCache.Unit.Tests.Infrastructure.Storage; @@ -9,148 +10,17 @@ namespace SlidingWindowCache.Unit.Tests.Infrastructure.Storage; /// /// Unit tests for CopyOnReadStorage that verify the ICacheStorage interface contract, /// data correctness (Invariant B.11), dual-buffer staging pattern, and error handling. +/// Shared tests are inherited from . /// -public class CopyOnReadStorageTests +public class CopyOnReadStorageTests : CacheStorageTestsBase { - #region Interface Contract Tests + protected override object CreateStorageObject(IntegerFixedStepDomain domain) => + new CopyOnReadStorage(domain); - [Fact] - public void Range_InitiallyEmpty() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - - // ACT & ASSERT - // Default Range behavior - storage starts uninitialized - // Range is a value type, so it's always non-null - _ = storage.Range; - } - - [Fact] - public void Range_UpdatesAfterRematerialize() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - var rangeData = CreateRangeData(10, 20, domain); - - // ACT - storage.Rematerialize(rangeData); - - // ASSERT - Assert.Equal(10, storage.Range.Start.Value); - Assert.Equal(20, storage.Range.End.Value); - } - - #endregion - - #region Rematerialize Tests - - [Fact] - public void Rematerialize_StoresDataCorrectly() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - var rangeData = CreateRangeData(5, 15, domain); - - // ACT - storage.Rematerialize(rangeData); - var result = storage.Read(CreateRange(5, 15)); - - // ASSERT - VerifyDataMatchesRange(result, 5, 15); - } - - [Fact] - public void Rematerialize_UpdatesRange() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - var rangeData = CreateRangeData(100, 200, domain); - - // ACT - storage.Rematerialize(rangeData); + protected override object CreateVariableStepStorageObject(IntegerVariableStepDomain domain) => + new CopyOnReadStorage(domain); - // ASSERT - Assert.Equal(100, storage.Range.Start.Value); - Assert.Equal(200, storage.Range.End.Value); - } - - [Fact] - public void Rematerialize_MultipleCalls_ReplacesData() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - - // First rematerialization - var firstData = CreateRangeData(0, 10, domain); - storage.Rematerialize(firstData); - - // ACT - Second rematerialization with different range - var secondData = CreateRangeData(20, 30, domain); - storage.Rematerialize(secondData); - var result = storage.Read(CreateRange(20, 30)); - - // ASSERT - Assert.Equal(20, storage.Range.Start.Value); - Assert.Equal(30, storage.Range.End.Value); - VerifyDataMatchesRange(result, 20, 30); - } - - [Fact] - public void Rematerialize_WithSameSize_ReplacesData() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - - storage.Rematerialize(CreateRangeData(0, 10, domain)); - - // ACT - Same size, different values - storage.Rematerialize(CreateRangeData(100, 110, domain)); - var result = storage.Read(CreateRange(100, 110)); - - // ASSERT - VerifyDataMatchesRange(result, 100, 110); - } - - [Fact] - public void Rematerialize_WithLargerSize_ReplacesData() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - - storage.Rematerialize(CreateRangeData(0, 5, domain)); - - // ACT - Larger size - storage.Rematerialize(CreateRangeData(0, 20, domain)); - var result = storage.Read(CreateRange(0, 20)); - - // ASSERT - VerifyDataMatchesRange(result, 0, 20); - } - - [Fact] - public void Rematerialize_WithSmallerSize_ReplacesData() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - - storage.Rematerialize(CreateRangeData(0, 20, domain)); - - // ACT - Smaller size - storage.Rematerialize(CreateRangeData(0, 5, domain)); - var result = storage.Read(CreateRange(0, 5)); - - // ASSERT - VerifyDataMatchesRange(result, 0, 5); - } + #region Rematerialize Tests (CopyOnRead-specific) [Fact] public void Rematerialize_SequentialCalls_MaintainsCorrectness() @@ -173,282 +43,6 @@ public void Rematerialize_SequentialCalls_MaintainsCorrectness() #endregion - #region Read Tests - - [Fact] - public void Read_FullRange_ReturnsAllData() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - storage.Rematerialize(CreateRangeData(0, 10, domain)); - - // ACT - var result = storage.Read(CreateRange(0, 10)); - - // ASSERT - VerifyDataMatchesRange(result, 0, 10); - } - - [Fact] - public void Read_PartialRange_AtStart_ReturnsCorrectSubset() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - storage.Rematerialize(CreateRangeData(0, 20, domain)); - - // ACT - var result = storage.Read(CreateRange(0, 5)); - - // ASSERT - VerifyDataMatchesRange(result, 0, 5); - } - - [Fact] - public void Read_PartialRange_InMiddle_ReturnsCorrectSubset() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - storage.Rematerialize(CreateRangeData(0, 20, domain)); - - // ACT - var result = storage.Read(CreateRange(5, 15)); - - // ASSERT - VerifyDataMatchesRange(result, 5, 15); - } - - [Fact] - public void Read_PartialRange_AtEnd_ReturnsCorrectSubset() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - storage.Rematerialize(CreateRangeData(0, 20, domain)); - - // ACT - var result = storage.Read(CreateRange(15, 20)); - - // ASSERT - VerifyDataMatchesRange(result, 15, 20); - } - - [Fact] - public void Read_SingleElement_ReturnsOneValue() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - storage.Rematerialize(CreateRangeData(0, 10, domain)); - - // ACT - var result = storage.Read(CreateRange(5, 5)); - - // ASSERT - Assert.Equal(1, result.Length); - Assert.Equal(5, result.Span[0]); - } - - [Fact] - public void Read_AtExactBoundaries_ReturnsCorrectData() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - storage.Rematerialize(CreateRangeData(10, 20, domain)); - - // ACT - var resultStart = storage.Read(CreateRange(10, 10)); - var resultEnd = storage.Read(CreateRange(20, 20)); - - // ASSERT - Assert.Equal(1, resultStart.Length); - Assert.Equal(10, resultStart.Span[0]); - Assert.Equal(1, resultEnd.Length); - Assert.Equal(20, resultEnd.Span[0]); - } - - [Fact] - public void Read_AfterMultipleRematerializations_ReturnsCurrentData() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - - storage.Rematerialize(CreateRangeData(0, 10, domain)); - storage.Rematerialize(CreateRangeData(50, 60, domain)); - storage.Rematerialize(CreateRangeData(100, 110, domain)); - - // ACT - var result = storage.Read(CreateRange(100, 110)); - - // ASSERT - VerifyDataMatchesRange(result, 100, 110); - } - - [Fact] - public void Read_OutOfBounds_ThrowsArgumentOutOfRangeException() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - storage.Rematerialize(CreateRangeData(10, 20, domain)); - - // ACT & ASSERT - Read beyond stored range - Assert.Throws(() => - storage.Read(CreateRange(25, 30))); - } - - [Fact] - public void Read_PartiallyOutOfBounds_ThrowsArgumentOutOfRangeException() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - storage.Rematerialize(CreateRangeData(10, 20, domain)); - - // ACT & ASSERT - Read overlapping but extending beyond range - Assert.Throws(() => - storage.Read(CreateRange(15, 25))); - } - - [Fact] - public void Read_BeforeStoredRange_ThrowsArgumentOutOfRangeException() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - storage.Rematerialize(CreateRangeData(10, 20, domain)); - - // ACT & ASSERT - Read before stored range - Assert.Throws(() => - storage.Read(CreateRange(0, 5))); - } - - #endregion - - #region ToRangeData Tests - - [Fact] - public void ToRangeData_AfterRematerialize_RoundTripsCorrectly() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - var originalData = CreateRangeData(10, 30, domain); - storage.Rematerialize(originalData); - - // ACT - var roundTripped = storage.ToRangeData(); - - // ASSERT - AssertRangeDataRoundTrip(originalData, roundTripped); - } - - [Fact] - public void ToRangeData_MaintainsSequentialOrder() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - var originalData = CreateRangeData(5, 15, domain); - storage.Rematerialize(originalData); - - // ACT - var rangeData = storage.ToRangeData(); - var dataArray = rangeData.Data.ToArray(); - - // ASSERT - for (var i = 0; i < dataArray.Length; i++) - { - Assert.Equal(5 + i, dataArray[i]); - } - } - - [Fact] - public void ToRangeData_AfterMultipleRematerializations_ReflectsCurrentState() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - - storage.Rematerialize(CreateRangeData(0, 10, domain)); - storage.Rematerialize(CreateRangeData(20, 30, domain)); - var finalData = CreateRangeData(100, 120, domain); - storage.Rematerialize(finalData); - - // ACT - var result = storage.ToRangeData(); - - // ASSERT - AssertRangeDataRoundTrip(finalData, result); - } - - #endregion - - #region Invariant B.11 Tests (Data/Range Consistency) - - [Fact] - public void InvariantB11_DataLengthMatchesRangeSize_AfterRematerialize() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - var rangeData = CreateRangeData(0, 50, domain); - - // ACT - storage.Rematerialize(rangeData); - var data = storage.Read(storage.Range); - - // ASSERT - Data length must equal range size (Invariant B.11) - var expectedLength = 51; // [0, 50] inclusive = 51 elements - Assert.Equal(expectedLength, data.Length); - } - - [Fact] - public void InvariantB11_DataLengthMatchesRangeSize_AfterMultipleRematerializations() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - - // ACT & ASSERT - Verify consistency after each rematerialization - storage.Rematerialize(CreateRangeData(0, 10, domain)); - Assert.Equal(11, storage.Read(storage.Range).Length); - - storage.Rematerialize(CreateRangeData(0, 100, domain)); - Assert.Equal(101, storage.Read(storage.Range).Length); - - storage.Rematerialize(CreateRangeData(50, 55, domain)); - Assert.Equal(6, storage.Read(storage.Range).Length); - } - - [Fact] - public void InvariantB11_PartialReads_ConsistentWithStoredRange() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - storage.Rematerialize(CreateRangeData(10, 30, domain)); - - // ACT & ASSERT - All partial reads must be consistent with range - var read1 = storage.Read(CreateRange(10, 15)); - Assert.Equal(6, read1.Length); - VerifyDataMatchesRange(read1, 10, 15); - - var read2 = storage.Read(CreateRange(20, 25)); - Assert.Equal(6, read2.Length); - VerifyDataMatchesRange(read2, 20, 25); - - var read3 = storage.Read(CreateRange(25, 30)); - Assert.Equal(6, read3.Length); - VerifyDataMatchesRange(read3, 25, 30); - } - - #endregion - #region Dual-Buffer Staging Pattern Tests [Fact] @@ -623,45 +217,4 @@ public async Task ThreadSafety_ConcurrentRematerializeWithDerivedData_NeverCorru } #endregion - - #region Domain-Agnostic Tests - - [Fact] - public void DomainAgnostic_WorksWithFixedStepDomain() - { - // ARRANGE - var domain = new IntegerFixedStepDomain(); - var storage = new CopyOnReadStorage(domain); - var rangeData = CreateRangeData(0, 100, domain); - - // ACT - storage.Rematerialize(rangeData); - var result = storage.Read(CreateRange(25, 75)); - - // ASSERT - VerifyDataMatchesRange(result, 25, 75); - } - - [Fact] - public void DomainAgnostic_WorksWithVariableStepDomain() - { - // ARRANGE - var steps = new[] { 1, 2, 5, 10, 20, 50, 100 }; - var domain = new IntegerVariableStepDomain(steps); - var storage = new CopyOnReadStorage(domain); - - var range = CreateRange(2, 50); - var data = new[] { 2, 5, 10, 20, 50 }; - var rangeData = data.ToRangeData(range, domain); - - // ACT - storage.Rematerialize(rangeData); - var result = storage.Read(CreateRange(2, 50)); - - // ASSERT - Assert.Equal(5, result.Length); - Assert.Equal([2, 5, 10, 20, 50], result.ToArray()); - } - - #endregion } diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/SnapshotReadStorageTests.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/SnapshotReadStorageTests.cs index 7d70560..2b16262 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/SnapshotReadStorageTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/SnapshotReadStorageTests.cs @@ -1,434 +1,20 @@ -using Intervals.NET.Data.Extensions; using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Infrastructure.Storage; using SlidingWindowCache.Unit.Tests.Infrastructure.Extensions; -using static SlidingWindowCache.Unit.Tests.Infrastructure.Storage.TestInfrastructure.StorageTestHelpers; +using SlidingWindowCache.Unit.Tests.Infrastructure.Storage.TestInfrastructure; namespace SlidingWindowCache.Unit.Tests.Infrastructure.Storage; /// /// Unit tests for SnapshotReadStorage that verify the ICacheStorage interface contract, /// data correctness (Invariant B.11), and error handling. +/// Shared tests are inherited from . /// -public class SnapshotReadStorageTests +public class SnapshotReadStorageTests : CacheStorageTestsBase { - #region Interface Contract Tests + protected override object CreateStorageObject(IntegerFixedStepDomain domain) => + new SnapshotReadStorage(domain); - [Fact] - public void Range_InitiallyEmpty() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - - // ACT & ASSERT - // Default Range behavior - storage starts uninitialized - // Range is a value type, so it's always non-null - _ = storage.Range; - } - - [Fact] - public void Range_UpdatesAfterRematerialize() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - var rangeData = CreateRangeData(10, 20, domain); - - // ACT - storage.Rematerialize(rangeData); - - // ASSERT - Assert.Equal(10, storage.Range.Start.Value); - Assert.Equal(20, storage.Range.End.Value); - } - - #endregion - - #region Rematerialize Tests - - [Fact] - public void Rematerialize_StoresDataCorrectly() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - var rangeData = CreateRangeData(5, 15, domain); - - // ACT - storage.Rematerialize(rangeData); - var result = storage.Read(CreateRange(5, 15)); - - // ASSERT - VerifyDataMatchesRange(result, 5, 15); - } - - [Fact] - public void Rematerialize_UpdatesRange() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - var rangeData = CreateRangeData(100, 200, domain); - - // ACT - storage.Rematerialize(rangeData); - - // ASSERT - Assert.Equal(100, storage.Range.Start.Value); - Assert.Equal(200, storage.Range.End.Value); - } - - [Fact] - public void Rematerialize_MultipleCalls_ReplacesData() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - - // First rematerialization - var firstData = CreateRangeData(0, 10, domain); - storage.Rematerialize(firstData); - - // ACT - Second rematerialization with different range - var secondData = CreateRangeData(20, 30, domain); - storage.Rematerialize(secondData); - var result = storage.Read(CreateRange(20, 30)); - - // ASSERT - Assert.Equal(20, storage.Range.Start.Value); - Assert.Equal(30, storage.Range.End.Value); - VerifyDataMatchesRange(result, 20, 30); - } - - [Fact] - public void Rematerialize_WithSameSize_ReplacesData() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - - storage.Rematerialize(CreateRangeData(0, 10, domain)); - - // ACT - Same size, different values - storage.Rematerialize(CreateRangeData(100, 110, domain)); - var result = storage.Read(CreateRange(100, 110)); - - // ASSERT - VerifyDataMatchesRange(result, 100, 110); - } - - [Fact] - public void Rematerialize_WithLargerSize_ReplacesData() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - - storage.Rematerialize(CreateRangeData(0, 5, domain)); - - // ACT - Larger size - storage.Rematerialize(CreateRangeData(0, 20, domain)); - var result = storage.Read(CreateRange(0, 20)); - - // ASSERT - VerifyDataMatchesRange(result, 0, 20); - } - - [Fact] - public void Rematerialize_WithSmallerSize_ReplacesData() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - - storage.Rematerialize(CreateRangeData(0, 20, domain)); - - // ACT - Smaller size - storage.Rematerialize(CreateRangeData(0, 5, domain)); - var result = storage.Read(CreateRange(0, 5)); - - // ASSERT - VerifyDataMatchesRange(result, 0, 5); - } - - #endregion - - #region Read Tests - - [Fact] - public void Read_FullRange_ReturnsAllData() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - storage.Rematerialize(CreateRangeData(0, 10, domain)); - - // ACT - var result = storage.Read(CreateRange(0, 10)); - - // ASSERT - VerifyDataMatchesRange(result, 0, 10); - } - - [Fact] - public void Read_PartialRange_AtStart_ReturnsCorrectSubset() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - storage.Rematerialize(CreateRangeData(0, 20, domain)); - - // ACT - var result = storage.Read(CreateRange(0, 5)); - - // ASSERT - VerifyDataMatchesRange(result, 0, 5); - } - - [Fact] - public void Read_PartialRange_InMiddle_ReturnsCorrectSubset() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - storage.Rematerialize(CreateRangeData(0, 20, domain)); - - // ACT - var result = storage.Read(CreateRange(5, 15)); - - // ASSERT - VerifyDataMatchesRange(result, 5, 15); - } - - [Fact] - public void Read_PartialRange_AtEnd_ReturnsCorrectSubset() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - storage.Rematerialize(CreateRangeData(0, 20, domain)); - - // ACT - var result = storage.Read(CreateRange(15, 20)); - - // ASSERT - VerifyDataMatchesRange(result, 15, 20); - } - - [Fact] - public void Read_SingleElement_ReturnsOneValue() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - storage.Rematerialize(CreateRangeData(0, 10, domain)); - - // ACT - var result = storage.Read(CreateRange(5, 5)); - - // ASSERT - Assert.Equal(1, result.Length); - Assert.Equal(5, result.Span[0]); - } - - [Fact] - public void Read_AtExactBoundaries_ReturnsCorrectData() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - storage.Rematerialize(CreateRangeData(10, 20, domain)); - - // ACT - var resultStart = storage.Read(CreateRange(10, 10)); - var resultEnd = storage.Read(CreateRange(20, 20)); - - // ASSERT - Assert.Equal(1, resultStart.Length); - Assert.Equal(10, resultStart.Span[0]); - Assert.Equal(1, resultEnd.Length); - Assert.Equal(20, resultEnd.Span[0]); - } - - [Fact] - public void Read_AfterMultipleRematerializations_ReturnsCurrentData() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - - storage.Rematerialize(CreateRangeData(0, 10, domain)); - storage.Rematerialize(CreateRangeData(50, 60, domain)); - storage.Rematerialize(CreateRangeData(100, 110, domain)); - - // ACT - var result = storage.Read(CreateRange(100, 110)); - - // ASSERT - VerifyDataMatchesRange(result, 100, 110); - } - - #endregion - - #region ToRangeData Tests - - [Fact] - public void ToRangeData_AfterRematerialize_RoundTripsCorrectly() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - var originalData = CreateRangeData(10, 30, domain); - storage.Rematerialize(originalData); - - // ACT - var roundTripped = storage.ToRangeData(); - - // ASSERT - AssertRangeDataRoundTrip(originalData, roundTripped); - } - - [Fact] - public void ToRangeData_MaintainsSequentialOrder() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - var originalData = CreateRangeData(5, 15, domain); - storage.Rematerialize(originalData); - - // ACT - var rangeData = storage.ToRangeData(); - var dataArray = rangeData.Data.ToArray(); - - // ASSERT - for (var i = 0; i < dataArray.Length; i++) - { - Assert.Equal(5 + i, dataArray[i]); - } - } - - [Fact] - public void ToRangeData_AfterMultipleRematerializations_ReflectsCurrentState() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - - storage.Rematerialize(CreateRangeData(0, 10, domain)); - storage.Rematerialize(CreateRangeData(20, 30, domain)); - var finalData = CreateRangeData(100, 120, domain); - storage.Rematerialize(finalData); - - // ACT - var result = storage.ToRangeData(); - - // ASSERT - AssertRangeDataRoundTrip(finalData, result); - } - - #endregion - - #region Invariant B.11 Tests (Data/Range Consistency) - - [Fact] - public void InvariantB11_DataLengthMatchesRangeSize_AfterRematerialize() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - var rangeData = CreateRangeData(0, 50, domain); - - // ACT - storage.Rematerialize(rangeData); - var data = storage.Read(storage.Range); - - // ASSERT - Data length must equal range size (Invariant B.11) - var expectedLength = 51; // [0, 50] inclusive = 51 elements - Assert.Equal(expectedLength, data.Length); - } - - [Fact] - public void InvariantB11_DataLengthMatchesRangeSize_AfterMultipleRematerializations() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - - // ACT & ASSERT - Verify consistency after each rematerialization - storage.Rematerialize(CreateRangeData(0, 10, domain)); - Assert.Equal(11, storage.Read(storage.Range).Length); - - storage.Rematerialize(CreateRangeData(0, 100, domain)); - Assert.Equal(101, storage.Read(storage.Range).Length); - - storage.Rematerialize(CreateRangeData(50, 55, domain)); - Assert.Equal(6, storage.Read(storage.Range).Length); - } - - [Fact] - public void InvariantB11_PartialReads_ConsistentWithStoredRange() - { - // ARRANGE - var domain = CreateFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - storage.Rematerialize(CreateRangeData(10, 30, domain)); - - // ACT & ASSERT - All partial reads must be consistent with range - var read1 = storage.Read(CreateRange(10, 15)); - Assert.Equal(6, read1.Length); - VerifyDataMatchesRange(read1, 10, 15); - - var read2 = storage.Read(CreateRange(20, 25)); - Assert.Equal(6, read2.Length); - VerifyDataMatchesRange(read2, 20, 25); - - var read3 = storage.Read(CreateRange(25, 30)); - Assert.Equal(6, read3.Length); - VerifyDataMatchesRange(read3, 25, 30); - } - - #endregion - - #region Domain-Agnostic Tests - - [Fact] - public void DomainAgnostic_WorksWithFixedStepDomain() - { - // ARRANGE - var domain = new IntegerFixedStepDomain(); - var storage = new SnapshotReadStorage(domain); - var rangeData = CreateRangeData(0, 100, domain); - - // ACT - storage.Rematerialize(rangeData); - var result = storage.Read(CreateRange(25, 75)); - - // ASSERT - VerifyDataMatchesRange(result, 25, 75); - } - - [Fact] - public void DomainAgnostic_WorksWithVariableStepDomain() - { - // ARRANGE - var steps = new[] { 1, 2, 5, 10, 20, 50, 100 }; - var domain = new IntegerVariableStepDomain(steps); - var storage = new SnapshotReadStorage(domain); - - var range = CreateRange(2, 50); - var data = new[] { 2, 5, 10, 20, 50 }; - var rangeData = data.ToRangeData(range, domain); - - // ACT - storage.Rematerialize(rangeData); - var result = storage.Read(CreateRange(2, 50)); - - // ASSERT - Assert.Equal(5, result.Length); - Assert.Equal([2, 5, 10, 20, 50], result.ToArray()); - } - - #endregion + protected override object CreateVariableStepStorageObject(IntegerVariableStepDomain domain) => + new SnapshotReadStorage(domain); } diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/TestInfrastructure/CacheStorageTestsBase.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/TestInfrastructure/CacheStorageTestsBase.cs new file mode 100644 index 0000000..a96c12a --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Storage/TestInfrastructure/CacheStorageTestsBase.cs @@ -0,0 +1,503 @@ +using Intervals.NET.Data.Extensions; +using Intervals.NET.Domain.Default.Numeric; +using SlidingWindowCache.Infrastructure.Storage; +using SlidingWindowCache.Unit.Tests.Infrastructure.Extensions; +using static SlidingWindowCache.Unit.Tests.Infrastructure.Storage.TestInfrastructure.StorageTestHelpers; + +namespace SlidingWindowCache.Unit.Tests.Infrastructure.Storage.TestInfrastructure; + +/// +/// Abstract base class providing shared test coverage for all +/// implementations, enforcing the ICacheStorage interface contract, data correctness (Invariant B.11), +/// and error handling. +/// +/// +/// Subclasses provide the concrete storage instance via and +/// . Implementation-specific tests (e.g., dual-buffer +/// staging for CopyOnReadStorage) live in the subclass. +/// The factory methods return object and are cast internally to keep the public abstract +/// class compatible with the internal type. +/// +public abstract class CacheStorageTestsBase +{ + /// + /// Factory method that subclasses override to provide the storage implementation under test + /// using a fixed-step domain. Must return an instance. + /// + protected abstract object CreateStorageObject(IntegerFixedStepDomain domain); + + /// + /// Factory method that subclasses override to provide the storage implementation under test + /// using a variable-step domain. Must return an instance. + /// + protected abstract object CreateVariableStepStorageObject(IntegerVariableStepDomain domain); + + private ICacheStorage CreateStorage(IntegerFixedStepDomain domain) => + (ICacheStorage)CreateStorageObject(domain); + + private ICacheStorage CreateVariableStepStorage(IntegerVariableStepDomain domain) => + (ICacheStorage)CreateVariableStepStorageObject(domain); + + #region Interface Contract Tests + + [Fact] + public void Range_InitiallyEmpty() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + + // ACT & ASSERT + // Default Range behavior - storage starts uninitialized + // Range is a value type, so it's always non-null + _ = storage.Range; + } + + [Fact] + public void Range_UpdatesAfterRematerialize() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + var rangeData = CreateRangeData(10, 20, domain); + + // ACT + storage.Rematerialize(rangeData); + + // ASSERT + Assert.Equal(10, storage.Range.Start.Value); + Assert.Equal(20, storage.Range.End.Value); + } + + #endregion + + #region Rematerialize Tests + + [Fact] + public void Rematerialize_StoresDataCorrectly() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + var rangeData = CreateRangeData(5, 15, domain); + + // ACT + storage.Rematerialize(rangeData); + var result = storage.Read(CreateRange(5, 15)); + + // ASSERT + VerifyDataMatchesRange(result, 5, 15); + } + + [Fact] + public void Rematerialize_UpdatesRange() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + var rangeData = CreateRangeData(100, 200, domain); + + // ACT + storage.Rematerialize(rangeData); + + // ASSERT + Assert.Equal(100, storage.Range.Start.Value); + Assert.Equal(200, storage.Range.End.Value); + } + + [Fact] + public void Rematerialize_MultipleCalls_ReplacesData() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + + // First rematerialization + var firstData = CreateRangeData(0, 10, domain); + storage.Rematerialize(firstData); + + // ACT - Second rematerialization with different range + var secondData = CreateRangeData(20, 30, domain); + storage.Rematerialize(secondData); + var result = storage.Read(CreateRange(20, 30)); + + // ASSERT + Assert.Equal(20, storage.Range.Start.Value); + Assert.Equal(30, storage.Range.End.Value); + VerifyDataMatchesRange(result, 20, 30); + } + + [Fact] + public void Rematerialize_WithSameSize_ReplacesData() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + + storage.Rematerialize(CreateRangeData(0, 10, domain)); + + // ACT - Same size, different values + storage.Rematerialize(CreateRangeData(100, 110, domain)); + var result = storage.Read(CreateRange(100, 110)); + + // ASSERT + VerifyDataMatchesRange(result, 100, 110); + } + + [Fact] + public void Rematerialize_WithLargerSize_ReplacesData() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + + storage.Rematerialize(CreateRangeData(0, 5, domain)); + + // ACT - Larger size + storage.Rematerialize(CreateRangeData(0, 20, domain)); + var result = storage.Read(CreateRange(0, 20)); + + // ASSERT + VerifyDataMatchesRange(result, 0, 20); + } + + [Fact] + public void Rematerialize_WithSmallerSize_ReplacesData() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + + storage.Rematerialize(CreateRangeData(0, 20, domain)); + + // ACT - Smaller size + storage.Rematerialize(CreateRangeData(0, 5, domain)); + var result = storage.Read(CreateRange(0, 5)); + + // ASSERT + VerifyDataMatchesRange(result, 0, 5); + } + + #endregion + + #region Read Tests + + [Fact] + public void Read_FullRange_ReturnsAllData() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + storage.Rematerialize(CreateRangeData(0, 10, domain)); + + // ACT + var result = storage.Read(CreateRange(0, 10)); + + // ASSERT + VerifyDataMatchesRange(result, 0, 10); + } + + [Fact] + public void Read_PartialRange_AtStart_ReturnsCorrectSubset() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + storage.Rematerialize(CreateRangeData(0, 20, domain)); + + // ACT + var result = storage.Read(CreateRange(0, 5)); + + // ASSERT + VerifyDataMatchesRange(result, 0, 5); + } + + [Fact] + public void Read_PartialRange_InMiddle_ReturnsCorrectSubset() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + storage.Rematerialize(CreateRangeData(0, 20, domain)); + + // ACT + var result = storage.Read(CreateRange(5, 15)); + + // ASSERT + VerifyDataMatchesRange(result, 5, 15); + } + + [Fact] + public void Read_PartialRange_AtEnd_ReturnsCorrectSubset() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + storage.Rematerialize(CreateRangeData(0, 20, domain)); + + // ACT + var result = storage.Read(CreateRange(15, 20)); + + // ASSERT + VerifyDataMatchesRange(result, 15, 20); + } + + [Fact] + public void Read_SingleElement_ReturnsOneValue() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + storage.Rematerialize(CreateRangeData(0, 10, domain)); + + // ACT + var result = storage.Read(CreateRange(5, 5)); + + // ASSERT + Assert.Equal(1, result.Length); + Assert.Equal(5, result.Span[0]); + } + + [Fact] + public void Read_AtExactBoundaries_ReturnsCorrectData() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + storage.Rematerialize(CreateRangeData(10, 20, domain)); + + // ACT + var resultStart = storage.Read(CreateRange(10, 10)); + var resultEnd = storage.Read(CreateRange(20, 20)); + + // ASSERT + Assert.Equal(1, resultStart.Length); + Assert.Equal(10, resultStart.Span[0]); + Assert.Equal(1, resultEnd.Length); + Assert.Equal(20, resultEnd.Span[0]); + } + + [Fact] + public void Read_AfterMultipleRematerializations_ReturnsCurrentData() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + + storage.Rematerialize(CreateRangeData(0, 10, domain)); + storage.Rematerialize(CreateRangeData(50, 60, domain)); + storage.Rematerialize(CreateRangeData(100, 110, domain)); + + // ACT + var result = storage.Read(CreateRange(100, 110)); + + // ASSERT + VerifyDataMatchesRange(result, 100, 110); + } + + #endregion + + #region Error Handling Tests + + [Fact] + public void Read_OutOfBounds_ThrowsArgumentOutOfRangeException() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + storage.Rematerialize(CreateRangeData(10, 20, domain)); + + // ACT & ASSERT - Read beyond stored range + Assert.Throws(() => + storage.Read(CreateRange(25, 30))); + } + + [Fact] + public void Read_PartiallyOutOfBounds_ThrowsArgumentOutOfRangeException() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + storage.Rematerialize(CreateRangeData(10, 20, domain)); + + // ACT & ASSERT - Read overlapping but extending beyond range + Assert.Throws(() => + storage.Read(CreateRange(15, 25))); + } + + [Fact] + public void Read_BeforeStoredRange_ThrowsArgumentOutOfRangeException() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + storage.Rematerialize(CreateRangeData(10, 20, domain)); + + // ACT & ASSERT - Read before stored range + Assert.Throws(() => + storage.Read(CreateRange(0, 5))); + } + + #endregion + + #region ToRangeData Tests + + [Fact] + public void ToRangeData_AfterRematerialize_RoundTripsCorrectly() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + var originalData = CreateRangeData(10, 30, domain); + storage.Rematerialize(originalData); + + // ACT + var roundTripped = storage.ToRangeData(); + + // ASSERT + AssertRangeDataRoundTrip(originalData, roundTripped); + } + + [Fact] + public void ToRangeData_MaintainsSequentialOrder() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + var originalData = CreateRangeData(5, 15, domain); + storage.Rematerialize(originalData); + + // ACT + var rangeData = storage.ToRangeData(); + var dataArray = rangeData.Data.ToArray(); + + // ASSERT + for (var i = 0; i < dataArray.Length; i++) + { + Assert.Equal(5 + i, dataArray[i]); + } + } + + [Fact] + public void ToRangeData_AfterMultipleRematerializations_ReflectsCurrentState() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + + storage.Rematerialize(CreateRangeData(0, 10, domain)); + storage.Rematerialize(CreateRangeData(20, 30, domain)); + var finalData = CreateRangeData(100, 120, domain); + storage.Rematerialize(finalData); + + // ACT + var result = storage.ToRangeData(); + + // ASSERT + AssertRangeDataRoundTrip(finalData, result); + } + + #endregion + + #region Invariant B.11 Tests (Data/Range Consistency) + + [Fact] + public void InvariantB11_DataLengthMatchesRangeSize_AfterRematerialize() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + var rangeData = CreateRangeData(0, 50, domain); + + // ACT + storage.Rematerialize(rangeData); + var data = storage.Read(storage.Range); + + // ASSERT - Data length must equal range size (Invariant B.11) + var expectedLength = 51; // [0, 50] inclusive = 51 elements + Assert.Equal(expectedLength, data.Length); + } + + [Fact] + public void InvariantB11_DataLengthMatchesRangeSize_AfterMultipleRematerializations() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + + // ACT & ASSERT - Verify consistency after each rematerialization + storage.Rematerialize(CreateRangeData(0, 10, domain)); + Assert.Equal(11, storage.Read(storage.Range).Length); + + storage.Rematerialize(CreateRangeData(0, 100, domain)); + Assert.Equal(101, storage.Read(storage.Range).Length); + + storage.Rematerialize(CreateRangeData(50, 55, domain)); + Assert.Equal(6, storage.Read(storage.Range).Length); + } + + [Fact] + public void InvariantB11_PartialReads_ConsistentWithStoredRange() + { + // ARRANGE + var domain = CreateFixedStepDomain(); + var storage = CreateStorage(domain); + storage.Rematerialize(CreateRangeData(10, 30, domain)); + + // ACT & ASSERT - All partial reads must be consistent with range + var read1 = storage.Read(CreateRange(10, 15)); + Assert.Equal(6, read1.Length); + VerifyDataMatchesRange(read1, 10, 15); + + var read2 = storage.Read(CreateRange(20, 25)); + Assert.Equal(6, read2.Length); + VerifyDataMatchesRange(read2, 20, 25); + + var read3 = storage.Read(CreateRange(25, 30)); + Assert.Equal(6, read3.Length); + VerifyDataMatchesRange(read3, 25, 30); + } + + #endregion + + #region Domain-Agnostic Tests + + [Fact] + public void DomainAgnostic_WorksWithFixedStepDomain() + { + // ARRANGE + var domain = new IntegerFixedStepDomain(); + var storage = CreateStorage(domain); + var rangeData = CreateRangeData(0, 100, domain); + + // ACT + storage.Rematerialize(rangeData); + var result = storage.Read(CreateRange(25, 75)); + + // ASSERT + VerifyDataMatchesRange(result, 25, 75); + } + + [Fact] + public void DomainAgnostic_WorksWithVariableStepDomain() + { + // ARRANGE + var steps = new[] { 1, 2, 5, 10, 20, 50, 100 }; + var domain = new IntegerVariableStepDomain(steps); + var storage = CreateVariableStepStorage(domain); + + var range = CreateRange(2, 50); + var data = new[] { 2, 5, 10, 20, 50 }; + var rangeData = data.ToRangeData(range, domain); + + // ACT + storage.Rematerialize(rangeData); + var result = storage.Read(CreateRange(2, 50)); + + // ASSERT + Assert.Equal(5, result.Length); + Assert.Equal([2, 5, 10, 20, 50], result.ToArray()); + } + + #endregion +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsTests.cs index 84fcffa..da2ea59 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsTests.cs @@ -590,6 +590,30 @@ public void RecordEquality_WithDifferentThresholds_AreNotEqual() Assert.NotEqual(options1, options2); } + [Fact] + public void RecordEquality_WithDifferentRebalanceQueueCapacity_AreNotEqual() + { + // ARRANGE + var options1 = new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot, + rebalanceQueueCapacity: null + ); + + var options2 = new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot, + rebalanceQueueCapacity: 5 + ); + + // ACT & ASSERT + Assert.NotEqual(options1, options2); + Assert.False(options1 == options2); + Assert.True(options1 != options2); + } + [Fact] public void RecordEquality_WithDifferentDebounceDelay_AreNotEqual() { diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/Instrumentation/NoOpDiagnosticsTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/Instrumentation/NoOpDiagnosticsTests.cs new file mode 100644 index 0000000..aa410c7 --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Public/Instrumentation/NoOpDiagnosticsTests.cs @@ -0,0 +1,41 @@ +using SlidingWindowCache.Public.Instrumentation; + +namespace SlidingWindowCache.Unit.Tests.Public.Instrumentation; + +/// +/// Unit tests for NoOpDiagnostics to ensure it never throws exceptions. +/// This is critical because diagnostic failures should never break cache functionality. +/// +public class NoOpDiagnosticsTests +{ + [Fact] + public void AllMethods_WhenCalled_DoNotThrowExceptions() + { + // ARRANGE + var diagnostics = new NoOpDiagnostics(); + var testException = new InvalidOperationException("Test exception"); + + // ACT & ASSERT - Call all methods and verify none throw exceptions + var exception = Record.Exception(() => + { + diagnostics.CacheExpanded(); + diagnostics.CacheReplaced(); + diagnostics.DataSourceFetchMissingSegments(); + diagnostics.DataSourceFetchSingleRange(); + diagnostics.RebalanceExecutionCancelled(); + diagnostics.RebalanceExecutionCompleted(); + diagnostics.RebalanceExecutionStarted(); + diagnostics.RebalanceIntentPublished(); + diagnostics.RebalanceSkippedCurrentNoRebalanceRange(); + diagnostics.RebalanceSkippedPendingNoRebalanceRange(); + diagnostics.RebalanceSkippedSameRange(); + diagnostics.RebalanceExecutionFailed(testException); + diagnostics.UserRequestFullCacheHit(); + diagnostics.UserRequestFullCacheMiss(); + diagnostics.UserRequestPartialCacheHit(); + diagnostics.UserRequestServed(); + }); + + Assert.Null(exception); + } +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs index 89a6839..18e8184 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs @@ -1,8 +1,7 @@ -using Intervals.NET; using Intervals.NET.Domain.Default.Numeric; using SlidingWindowCache.Public; using SlidingWindowCache.Public.Configuration; -using SlidingWindowCache.Public.Dto; +using SlidingWindowCache.Tests.Infrastructure.DataSources; namespace SlidingWindowCache.Unit.Tests.Public; @@ -14,66 +13,9 @@ public class WindowCacheDisposalTests { #region Test Infrastructure - /// - /// Simple test data source that returns sequential integers for any requested range. - /// Properly respects range inclusivity (IsStartInclusive/IsEndInclusive). - /// - private sealed class TestDataSource : IDataSource - { - public async Task> FetchAsync( - Range requestedRange, - CancellationToken cancellationToken) - { - // Simulate async I/O - await Task.Delay(1, cancellationToken); - - return new RangeChunk(requestedRange, GenerateDataForRange(requestedRange)); - } - - /// - /// Generates data respecting range boundary inclusivity. - /// Uses pattern matching to handle all 4 combinations of inclusive/exclusive boundaries. - /// - 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; - } - } - private static WindowCache CreateCache() { - var dataSource = new TestDataSource(); + var dataSource = new SimpleTestDataSource(i => i, simulateAsyncDelay: true); var domain = new IntegerFixedStepDomain(); var options = new WindowCacheOptions( leftCacheSize: 1.0, diff --git a/tests/SlidingWindowCache.Unit.Tests/SlidingWindowCache.Unit.Tests.csproj b/tests/SlidingWindowCache.Unit.Tests/SlidingWindowCache.Unit.Tests.csproj index 3e123f5..4a3d19b 100644 --- a/tests/SlidingWindowCache.Unit.Tests/SlidingWindowCache.Unit.Tests.csproj +++ b/tests/SlidingWindowCache.Unit.Tests/SlidingWindowCache.Unit.Tests.csproj @@ -26,6 +26,7 @@ + From bf90291162cfc931e4ba625b389f0977315dbf4c Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Fri, 27 Feb 2026 23:59:36 +0100 Subject: [PATCH 09/21] docs: README and documentation files have been updated for clarity and accuracy; style: formatting improvements have been made to tables and lists; refactor: redundant references in documentation have been removed --- README.md | 12 +- docs/actors-and-responsibilities.md | 32 +-- docs/component-map.md | 316 ++++++++++++++-------------- docs/glossary.md | 18 +- docs/invariants.md | 62 +++--- docs/storage-strategies.md | 28 +-- 6 files changed, 231 insertions(+), 237 deletions(-) diff --git a/README.md b/README.md index 0217892..d7e413e 100644 --- a/README.md +++ b/README.md @@ -690,10 +690,10 @@ The `rebalanceQueueCapacity` configuration parameter controls how the cache seri ### Strategy Overview -| Configuration | Implementation | Queue Behavior | Best For | -|---------------|----------------|----------------|----------| -| `null` (default) | Task-based | Unbounded accumulation via task chaining | **99% of use cases** - typical workloads with moderate burst patterns | -| `>= 1` (e.g., `10`) | Channel-based | Bounded queue with backpressure | Extreme high-frequency scenarios (1000+ rapid requests with I/O latency) | +| Configuration | Implementation | Queue Behavior | Best For | +|---------------------|----------------|------------------------------------------|--------------------------------------------------------------------------| +| `null` (default) | Task-based | Unbounded accumulation via task chaining | **99% of use cases** - typical workloads with moderate burst patterns | +| `>= 1` (e.g., `10`) | Channel-based | Bounded queue with backpressure | Extreme high-frequency scenarios (1000+ rapid requests with I/O latency) | ### Unbounded Execution (Default - Recommended) @@ -867,8 +867,8 @@ see [Diagnostics Guide](docs/diagnostics.md).** **Goal**: Get up and running with working code and common patterns. -1. **[README - Quick Start](#-quick-start)** - Basic usage examples (you're already here!) -2. **[README - Configuration Guide](#configuration)** - Understand the 5 key parameters +1. **[README - Quick Start](#-usage-example)** - Basic usage examples (you're already here!) +2. **[README - Configuration Guide](#-configuration)** - Understand the 5 key parameters 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 diff --git a/docs/actors-and-responsibilities.md b/docs/actors-and-responsibilities.md index 6a8d464..4b26b8b 100644 --- a/docs/actors-and-responsibilities.md +++ b/docs/actors-and-responsibilities.md @@ -290,19 +290,19 @@ Ensures atomicity and internal consistency of cache state, coordinates cancellat This table maps **actors** to the scenarios they participate in and clarifies **read/write responsibilities**. -| Scenario | User Path | Decision Engine | Geometry Policy | IntentController | Rebalance Executor | Cache State Manager | Notes | -|-----------------------------------------|-------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------|----------------------------|------------------------------------------|----------------------------------------------------------------------------------------|--------------------------------------------------------|--------------------------------------------| -| **U1 – Cold Cache** | Requests data from IDataSource, returns data to user, publishes rebalance intent | – | Computes DesiredCacheRange | Receives intent | Executes rebalance asynchronously (writes IsInitialized, CurrentCacheRange, CacheData) | Validates atomic update of CacheData/CurrentCacheRange | User served directly | -| **U2 – Full Cache Hit (Exact)** | Reads from cache, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes if rebalance required | Monitors consistency | Minimal I/O | -| **U3 – Full Cache Hit (Shifted)** | Reads subrange from cache, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes if rebalance required | Monitors consistency | Cache hit but shifted range | -| **U4 – Partial Cache Hit** | Reads intersection, requests missing from IDataSource, merges locally, returns data to user, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes merge and normalization | Ensures atomic merge & consistency | Temporary excess data allowed | -| **U5 – Full Cache Miss (Jump)** | Requests full range from IDataSource, returns data to user, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes full normalization | Ensures atomic replacement | No cached data usable | -| **D1 – NoRebalanceRange Block** | – | Checks NoRebalanceRange, decides no execution | – | Receives intent (blocked) | – | – | Fast path skip | -| **D2 – Desired == Current** | – | Computes DesiredCacheRange, decides no execution | Computes DesiredCacheRange | Receives intent (no-op) | – | – | No mutation required | -| **D3 – Rebalance Required** | – | Computes DesiredCacheRange, confirms execution | Computes DesiredCacheRange | Issues rebalance intent | Executes rebalance | Ensures consistency | Rebalance triggered asynchronously | -| **R1 – Build from Scratch** | – | – | Defines DesiredCacheRange | Receives intent | Requests full range, replaces cache | Atomic replacement | Cache initialized from empty | -| **R2 – Expand Cache (Partial Overlap)** | – | – | Defines DesiredCacheRange | Receives intent | Requests missing subranges, merges with existing cache | Atomic merge, consistency | Cache partially reused | -| **R3 – Shrink / Normalize** | – | – | Defines DesiredCacheRange | Receives intent | Trims cache to DesiredCacheRange | Atomic trim, consistency | Cache normalized to target | -| **C1 – Rebalance Trigger Pending** | Executes normally | – | – | Debounces old intent, allows only latest | Cancels obsolete | Ensures atomicity | Fast user response guaranteed | -| **C2 – Rebalance Executing** | Executes normally | – | – | Marks latest intent | Cancels or discards obsolete execution | Ensures atomicity | Latest execution wins | -| **C3 – Spike / Multiple Requests** | Executes normally | – | – | Debounces & coordinates intents | Executes only latest rebalance | Ensures atomicity | Single-flight execution enforced | \ No newline at end of file +| Scenario | User Path | Decision Engine | Geometry Policy | IntentController | Rebalance Executor | Cache State Manager | Notes | +|-----------------------------------------|-------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------|----------------------------|------------------------------------------|----------------------------------------------------------------------------------------|--------------------------------------------------------|------------------------------------| +| **U1 – Cold Cache** | Requests data from IDataSource, returns data to user, publishes rebalance intent | – | Computes DesiredCacheRange | Receives intent | Executes rebalance asynchronously (writes IsInitialized, CurrentCacheRange, CacheData) | Validates atomic update of CacheData/CurrentCacheRange | User served directly | +| **U2 – Full Cache Hit (Exact)** | Reads from cache, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes if rebalance required | Monitors consistency | Minimal I/O | +| **U3 – Full Cache Hit (Shifted)** | Reads subrange from cache, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes if rebalance required | Monitors consistency | Cache hit but shifted range | +| **U4 – Partial Cache Hit** | Reads intersection, requests missing from IDataSource, merges locally, returns data to user, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes merge and normalization | Ensures atomic merge & consistency | Temporary excess data allowed | +| **U5 – Full Cache Miss (Jump)** | Requests full range from IDataSource, returns data to user, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes full normalization | Ensures atomic replacement | No cached data usable | +| **D1 – NoRebalanceRange Block** | – | Checks NoRebalanceRange, decides no execution | – | Receives intent (blocked) | – | – | Fast path skip | +| **D2 – Desired == Current** | – | Computes DesiredCacheRange, decides no execution | Computes DesiredCacheRange | Receives intent (no-op) | – | – | No mutation required | +| **D3 – Rebalance Required** | – | Computes DesiredCacheRange, confirms execution | Computes DesiredCacheRange | Issues rebalance intent | Executes rebalance | Ensures consistency | Rebalance triggered asynchronously | +| **R1 – Build from Scratch** | – | – | Defines DesiredCacheRange | Receives intent | Requests full range, replaces cache | Atomic replacement | Cache initialized from empty | +| **R2 – Expand Cache (Partial Overlap)** | – | – | Defines DesiredCacheRange | Receives intent | Requests missing subranges, merges with existing cache | Atomic merge, consistency | Cache partially reused | +| **R3 – Shrink / Normalize** | – | – | Defines DesiredCacheRange | Receives intent | Trims cache to DesiredCacheRange | Atomic trim, consistency | Cache normalized to target | +| **C1 – Rebalance Trigger Pending** | Executes normally | – | – | Debounces old intent, allows only latest | Cancels obsolete | Ensures atomicity | Fast user response guaranteed | +| **C2 – Rebalance Executing** | Executes normally | – | – | Marks latest intent | Cancels or discards obsolete execution | Ensures atomicity | Latest execution wins | +| **C3 – Spike / Multiple Requests** | Executes normally | – | – | Debounces & coordinates intents | Executes only latest rebalance | Ensures atomicity | Single-flight execution enforced | \ No newline at end of file diff --git a/docs/component-map.md b/docs/component-map.md index 1d76ce8..9695f57 100644 --- a/docs/component-map.md +++ b/docs/component-map.md @@ -166,15 +166,15 @@ The system uses a **multi-stage rebalance decision pipeline**, not a cancellatio ### Component Responsibilities in Decision Model -| Component | Role | Decision Authority | -|-----------|------|-------------------| -| **UserRequestHandler** | Read-only; publishes intents with delivered data | No decision authority | -| **IntentController** | Manages intent lifecycle; runs background processing loop | No decision authority | -| **IRebalanceExecutionController** | Debounce + execution serialization | No decision authority | -| **RebalanceDecisionEngine** | **SOLE AUTHORITY** for necessity determination | **Yes - THE authority** | -| **NoRebalanceSatisfactionPolicy** | Stage 1 & 2 validation (NoRebalanceRange check) | Analytical input | -| **ProportionalRangePlanner** | Computes desired cache geometry | Analytical input | -| **RebalanceExecutor** | Mechanical execution; assumes validated necessity | No decision authority | +| Component | Role | Decision Authority | +|-----------------------------------|-----------------------------------------------------------|-------------------------| +| **UserRequestHandler** | Read-only; publishes intents with delivered data | No decision authority | +| **IntentController** | Manages intent lifecycle; runs background processing loop | No decision authority | +| **IRebalanceExecutionController** | Debounce + execution serialization | No decision authority | +| **RebalanceDecisionEngine** | **SOLE AUTHORITY** for necessity determination | **Yes - THE authority** | +| **NoRebalanceSatisfactionPolicy** | Stage 1 & 2 validation (NoRebalanceRange check) | Analytical input | +| **ProportionalRangePlanner** | Computes desired cache geometry | Analytical input | +| **RebalanceExecutor** | Mechanical execution; assumes validated necessity | No decision authority | ### System Stability Principle @@ -1212,7 +1212,7 @@ internal sealed class RebalanceDecisionEngine - ◇ `_planner.Plan()` - Stage 3: Compute DesiredCacheRange - ◇ `_noRebalancePlanner.Plan()` - Stage 3: Compute DesiredNoRebalanceRange -**Returns**: `RebalanceDecision` (struct with `ShouldSchedule`, `DesiredRange`, `DesiredNoRebalanceRange`, `Reason`) +**Returns**: `RebalanceDecision` (struct with `IsExecutionRequired`, `DesiredRange`, `DesiredNoRebalanceRange`, `Reason`) **Ownership**: Owned by IntentController, invoked exclusively in `IntentController.ProcessIntentsAsync` @@ -1698,198 +1698,192 @@ Creates and wires all internal components in dependency order: │ GetDataAsync(range, ct) ▼ ┌─────────────────────────────────────────────────────────────────────┐ -│ WindowCache [Public Facade] │ +│ WindowCache [Public Facade] │ │ 🟦 CLASS (sealed, public) │ -│ │ +│ │ │ Constructor creates and wires: │ -│ ├─ 🟦 CacheState ──────────────────────────┐ (shared mutable) │ -│ ├─ 🟦 UserRequestHandler ──────────────────┼───┐ │ -│ ├─ 🟦 CacheDataExtensionService ───────────┼───┼───┐ │ -│ ├─ 🟦 IntentController ────────────────────┼───┼───┼───┐ │ -│ │ └─ 🟧 IRebalanceExecutionController ───┼───┼───┼───┼───┐ │ -│ ├─ 🟦 RebalanceDecisionEngine ─────────────┼───┼───┼───┼───┼───┐ │ -│ │ ├─ 🟩 NoRebalanceSatisfactionPolicy │ │ │ │ │ │ │ -│ │ └─ 🟩 ProportionalRangePlanner │ │ │ │ │ │ │ -│ └─ 🟦 RebalanceExecutor ────────────────────┼───┼───┼───┼───┼───┤ │ -│ │ │ │ │ │ │ │ -│ GetDataAsync() → delegates to UserRequestHandler │ -└────────────────────────────────────────────────┼───┼───┼───┼───┼───┼─┘ - │ │ │ │ │ │ - ═════════════════════════════════════════╪═══╪═══╪═══╪═══╪═══╪═ - USER THREAD │ │ │ │ │ │ - ═════════════════════════════════════════╪═══╪═══╪═══╪═══╪═══╪═ - │ │ │ │ │ │ -┌────────────────────────────────────────────────▼───┼───┼───┼───┼───┤ -│ UserRequestHandler [Fast Path Actor — READ-ONLY] │ │ │ │ │ -│ 🟦 CLASS (sealed) │ │ │ │ │ -│ │ │ │ │ │ -│ HandleRequestAsync(range, ct): │ │ │ │ │ -│ 1. Check cold start / cache coverage ────────────┼───┤ │ │ │ -│ 2. Fetch missing via _cacheExtensionService ─────┼───┼───┤ │ │ -│ or _dataSource (cold start / full miss) │ │ │ │ │ -│ 3. Publish intent with assembled data ────────────┼───┼───┼───┼───┤ -│ 4. Return ReadOnlyMemory to user │ │ │ │ │ -│ │ │ │ │ │ -│ ❌ NEVER writes to CacheState │ │ │ │ │ -│ ❌ NEVER calls Cache.Rematerialize() │ │ │ │ │ -│ ❌ NEVER writes IsInitialized or NoRebalanceRange │ │ │ │ │ -└─────────────────────────────────────────────────────┼───┼───┼───┼───┘ - │ │ │ │ - ══════════════════════════════════════════════╪═══╪═══╪═══╪═══ - BACKGROUND / THREADPOOL │ │ │ │ - ══════════════════════════════════════════════╪═══╪═══╪═══╪═══ - │ │ │ │ -┌─────────────────────────────────────────────────────▼───┼───┼───┼───┐ -│ IntentController [Intent Lifecycle + Background Loop] │ │ │ │ -│ 🟦 CLASS (sealed) │ │ │ │ -│ │ │ │ │ -│ Fields: │ │ │ │ -│ ├─ IRebalanceExecutionController _executionController ─▼───┼───┤ │ -│ └─ Intent? _pendingIntent (Interlocked.Exchange) │ │ │ -│ │ │ │ -│ PublishIntent(intent) [User Thread]: │ │ │ -│ 1. Interlocked.Exchange(_pendingIntent, intent) │ │ │ -│ 2. _activityCounter.IncrementActivity() │ │ │ -│ 3. _intentSignal.Release() → wakes ProcessIntentsAsync │ │ │ -│ │ │ │ -│ ProcessIntentsAsync() [Background Loop]: │ │ │ -│ 1. await _intentSignal.WaitAsync() │ │ │ -│ 2. intent = Interlocked.Exchange(_pendingIntent, null) │ │ │ -│ 3. decision = _decisionEngine.Evaluate(intent, ...) ───────┼───┤ │ -│ 4. if (!decision.ShouldSchedule) → skip │ │ │ -│ 5. lastRequest?.Cancel() │ │ │ -│ 6. await _executionController.PublishExecutionRequest() ───┼───┤ │ -└──────────────────────────────────────────────────────────────┼───┼───┘ +│ ├─ 🟦 CacheState ──────────────────────────┐ (shared mutable) │ +│ ├─ 🟦 UserRequestHandler ──────────────────┼───┐ │ +│ ├─ 🟦 CacheDataExtensionService ───────────┼───┼───┐ │ +│ ├─ 🟦 IntentController ────────────────────┼───┼───┼───┐ │ +│ │ └─ 🟧 IRebalanceExecutionController ───┼───┼───┼───┼───┐ │ +│ ├─ 🟦 RebalanceDecisionEngine ─────────────┼───┼───┼───┼───┼───┐ │ +│ │ ├─ 🟩 NoRebalanceSatisfactionPolicy │ │ │ │ │ │ │ +│ │ └─ 🟩 ProportionalRangePlanner │ │ │ │ │ │ │ +│ └─ 🟦 RebalanceExecutor ───────────────────┼───┼───┼───┼───┼───┤ │ +│ │ │ │ │ │ │ │ +│ GetDataAsync() → delegates to UserRequestHandler│ │ │ │ │ │ +└──────────────────────────────────────────────┼───┼───┼───┼───┼───┼──┘ + │ │ │ │ │ │ + ══════════════════════════════════════════════╪═══╪═══╪═══╪═══╪═══╪═ + USER THREAD │ │ │ │ │ │ + ══════════════════════════════════════════════╪═══╪═══╪═══╪═══╪═══╪═ + │ │ │ │ │ │ +┌──────────────────────────────────────────────▼───┼───┼───┼───┼───┐ +│ UserRequestHandler [Fast Path Actor — READ-ONLY]│ │ │ │ │ +│ 🟦 CLASS (sealed) │ │ │ │ │ +│ │ │ │ │ │ +│ HandleRequestAsync(range, ct): │ │ │ │ │ +│ 1. Check cold start / cache coverage ──────────┼───┤ │ │ │ +│ 2. Fetch missing via _cacheExtensionService ───┼───┼───┤ │ │ +│ or _dataSource (cold start / full miss) │ │ │ │ │ +│ 3. Publish intent with assembled data ─────────┼───┼───┼───┼───┤ +│ 4. Return ReadOnlyMemory to user │ │ │ │ │ +│ │ │ │ │ │ +│ ❌ NEVER writes to CacheState │ │ │ │ │ +│ ❌ NEVER calls Cache.Rematerialize() │ │ │ │ │ +│ ❌ NEVER writes IsInitialized/NoRebalanceRange │ │ │ │ │ +└──────────────────────────────────────────────────┼───┼───┼───┼───┘ + │ │ │ │ +═══════════════════════════════════════════════════╪═══╪═══╪═══╪═══ +BACKGROUND / THREADPOOL │ │ │ │ +═══════════════════════════════════════════════════╪═══╪═══╪═══╪═══ + │ │ │ │ +┌──────────────────────────────────────────────────▼───┼───┼───┼───┐ +│ IntentController [Lifecycle + Background Loop] │ │ │ │ │ +│ 🟦 CLASS (sealed) │ │ │ │ │ +│ │ │ │ │ │ +│ Fields: │ │ │ │ │ +│ ├─ RebalanceDecisionEngine _decisionEngine │ │ +│ ├─ CacheState _state │ │ +│ ├─ IRebalanceExecutionController _executionController ─────▼───┤ +│ └─ Intent? _pendingIntent (Interlocked.Exchange) │ │ +│ │ │ +│ PublishIntent(intent) [User Thread]: │ │ +│ 1. Interlocked.Exchange(_pendingIntent, intent) │ │ +│ 2. _activityCounter.IncrementActivity() │ │ +│ 3. _intentSignal.Release() → wakes ProcessIntentsAsync │ │ +│ │ │ +│ ProcessIntentsAsync() [Background Loop]: │ │ +│ 1. await _intentSignal.WaitAsync() │ │ +│ 2. intent = Interlocked.Exchange(_pendingIntent, null) │ │ +│ 3. decision = _decisionEngine.Evaluate(intent, ...) ───────┼───┤ +│ 4. if (!decision.IsExecutionRequired) → skip │ │ +│ 5. lastRequest?.Cancel() │ │ +│ 6. await _executionController.PublishExecutionRequest() ───┼───┤ +└──────────────────────────────────────────────────────────────┼───┘ │ │ -┌──────────────────────────────────────────────────────────────▼───┼───┐ -│ RebalanceDecisionEngine [Pure Decision Logic] │ │ +┌──────────────────────────────────────────────────────────────▼────┼───┐ +│ RebalanceDecisionEngine [Pure Decision Logic] │ │ │ 🟦 CLASS (sealed) │ │ │ │ │ -│ Fields (value types): │ │ +│ Fields (value types): │ │ │ ├─ 🟩 NoRebalanceSatisfactionPolicy _policy │ │ -│ ├─ 🟩 ProportionalRangePlanner _planner │ │ -│ └─ 🟩 NoRebalanceRangePlanner _noRebalancePlanner │ │ +│ ├─ 🟩 ProportionalRangePlanner _planner │ │ +│ └─ 🟩 NoRebalanceRangePlanner _noRebalancePlanner │ │ │ │ │ -│ Evaluate(requested, cacheState, lastRequest): │ │ -│ 1. Stage 1: _policy.ShouldRebalance(noRebalanceRange) → skip │ │ -│ 2. Stage 2: _policy.ShouldRebalance(pendingNRR) → skip │ │ -│ 3. Stage 3: desiredRange = _planner.Plan(requested) │ │ -│ 4. Stage 4: desiredRange == currentRange → skip │ │ -│ 5. Stage 5: return Schedule(desiredRange, desiredNRR) │ │ +│ Evaluate(requested, cacheState, lastRequest): │ │ +│ 1. Stage 1: _policy.ShouldRebalance(noRebalanceRange) → skip │ │ +│ 2. Stage 2: _policy.ShouldRebalance(pendingNRR) → skip │ │ +│ 3. Stage 3: desiredRange = _planner.Plan(requested) │ │ +│ 4. Stage 4: desiredRange == currentRange → skip │ │ +│ 5. Stage 5: return Schedule(desiredRange, desiredNRR) │ │ │ │ │ -│ Returns: 🟩 RebalanceDecision │ │ -│ (ShouldSchedule, DesiredRange, DesiredNoRebalanceRange, Reason)│ │ +│ Returns: 🟩 RebalanceDecision │ │ +│ (IsExecutionRequired, DesiredRange, │ │ +│ DesiredNoRebalanceRange, Reason) │ │ └───────────────────────────────────────────────────────────────────┼───┘ │ -┌───────────────────────────────────────────────────────────────────▼──┐ -│ IRebalanceExecutionController [Execution Serialization] │ +┌───────────────────────────────────────────────────────────────────▼───┐ +│ IRebalanceExecutionController [Execution Serialization] │ │ 🟧 INTERFACE │ │ │ │ Implementations: │ -│ ├─ 🟦 TaskBasedRebalanceExecutionController (default) │ -│ │ • Lock-free task chaining (Volatile.Write for single-writer) │ -│ │ • Debounce via Task.Delay before executing │ -│ │ • PublishExecutionRequest returns ValueTask.CompletedTask │ -│ └─ 🟦 ChannelBasedRebalanceExecutionController │ -│ • Bounded Channel with backpressure │ -│ • Single reader loop processes requests sequentially │ +│ ├─ 🟦 TaskBasedRebalanceExecutionController (default) │ +│ │ • Lock-free task chaining (Volatile.Write for single-writer) │ +│ │ • Debounce via Task.Delay before executing │ +│ │ • PublishExecutionRequest returns ValueTask.CompletedTask │ +│ └─ 🟦 ChannelBasedRebalanceExecutionController │ +│ • Bounded Channel with backpressure │ +│ • Single reader loop processes requests sequentially │ │ │ -│ ChainExecutionAsync / channel read loop: │ -│ 1. await Task.Delay(debounceDelay, ct) (cancellable) │ -│ 2. await _executor.ExecuteAsync(desiredRange, ct) ─────────────┐ │ -└──────────────────────────────────────────────────────────────────┼──┘ +│ ChainExecutionAsync / channel read loop: │ +│ 1. await Task.Delay(debounceDelay, ct) (cancellable) │ +│ 2. await _executor.ExecuteAsync(desiredRange, ct) ─────────────┐ │ +└──────────────────────────────────────────────────────────────────┼────┘ │ -┌──────────────────────────────────────────────────────────────────▼──┐ -│ RebalanceExecutor [Mutating Actor — SOLE WRITER] │ +┌──────────────────────────────────────────────────────────────────▼────┐ +│ RebalanceExecutor [Mutating Actor — SOLE WRITER] │ │ 🟦 CLASS (sealed) │ │ │ -│ ExecuteAsync(intent, desiredRange, desiredNRR, ct): │ -│ 1. await _executionSemaphore.WaitAsync(ct) (serialize) │ -│ 2. baseRangeData = intent.AvailableRangeData │ -│ 3. ct.ThrowIfCancellationRequested() │ -│ 4. extended = await _cacheExtensionService.ExtendCacheAsync() ──┐ │ -│ 5. ct.ThrowIfCancellationRequested() │ │ -│ 6. rebalanced = extended[desiredRange] (trim) │ │ -│ 7. ct.ThrowIfCancellationRequested() │ │ -│ 8. UpdateCacheState(rebalanced, requestedRange, desiredNRR) │ │ -│ └─ _state.Cache.Rematerialize(rebalanced) ────────────────┐ │ │ -│ └─ _state.NoRebalanceRange = desiredNRR ──────────────────┼───┤ │ -│ └─ _state.IsInitialized = true ───────────────────┼───┤ │ -│ finally: _executionSemaphore.Release() │ │ │ +│ ExecuteAsync(intent, desiredRange, desiredNRR, ct): │ +│ 1. baseRangeData = intent.AssembledRangeData │ +│ 2. ct.ThrowIfCancellationRequested() │ +│ 3. extended = await _cacheExtensionService.ExtendCacheAsync() ────┐ │ +│ 4. ct.ThrowIfCancellationRequested() │ │ +│ 5. normalizedData = extended[desiredRange] (trim) │ │ +│ 6. ct.ThrowIfCancellationRequested() │ │ +│ 7. _state.UpdateCacheState(normalizedData, desiredNRR) │ │ +│ └─ _state.Storage.Rematerialize(normalizedData) ───────────┐ │ │ +│ └─ _state.NoRebalanceRange = desiredNRR ───────────────────┼───┤ │ +│ └─ _state.IsInitialized = true ────────────────────────────┼───┤ │ └─────────────────────────────────────────────────────────────────┼───┼─┘ │ │ -┌─────────────────────────────────────────────────────────────────▼───┼──┐ -│ CacheState [Shared Mutable State] │ │ +┌─────────────────────────────────────────────────────────────────▼───┼───┐ +│ CacheState [Shared Mutable State] │ │ │ 🟦 CLASS (sealed) ⚠️ SHARED │ │ │ │ │ │ Properties: │ │ -│ ├─ ICacheStorage Cache ◄─ RebalanceExecutor (SOLE WRITER) ─────────┤ │ -│ ├─ bool IsInitialized ◄─ RebalanceExecutor │ │ -│ ├─ Range? NoRebalanceRange ◄─ RebalanceExecutor │ │ +│ ├─ ICacheStorage Storage ◄─ RebalanceExecutor (SOLE WRITER) ───────┤ │ +│ ├─ bool IsInitialized ◄─ RebalanceExecutor │ │ +│ ├─ Range? NoRebalanceRange ◄─ RebalanceExecutor │ │ │ └─ TDomain Domain (readonly) │ │ │ │ │ │ Read by: │ │ -│ ├─ UserRequestHandler (Cache.Range, Cache.Read, Cache.ToRangeData, IsInitialized) -│ ├─ RebalanceExecutor (Cache.Range, Cache.ToRangeData) │ │ -│ └─ RebalanceDecisionEngine (NoRebalanceRange, Cache.Range) │ │ +│ ├─ UserRequestHandler (Storage.Range, Storage.Read, │ │ +│ │ Storage.ToRangeData, IsInitialized) │ │ +│ ├─ RebalanceDecisionEngine (NoRebalanceRange, Storage.Range) │ │ +│ └─ IntentController (Storage.Range, NoRebalanceRange) │ │ └──────────────────────────────────────────────────────────────────────┼──┘ │ -┌──────────────────────────────────────────────────────────────────────▼──┐ -│ ICacheStorage │ +┌──────────────────────────────────────────────────────────────────────▼───┐ +│ ICacheStorage │ │ 🟧 INTERFACE │ │ │ │ Implementations: │ -│ ├─ 🟦 SnapshotReadStorage (TData[] array) │ -│ │ • Read: zero allocation (memory view) │ -│ │ • Write: expensive (allocates new array) │ +│ ├─ 🟦 SnapshotReadStorage (TData[] array) │ +│ │ • Read: zero allocation (memory view) │ +│ │ • Write: expensive (allocates new array) │ │ │ │ -│ └─ 🟦 CopyOnReadStorage (List) │ -│ • Read: allocates (copies to new array) │ -│ • Write: cheap (list operations) │ +│ └─ 🟦 CopyOnReadStorage (List) │ +│ • Read: allocates (copies to new array) │ +│ • Write: cheap (list operations) │ │ │ │ Methods: │ -│ ├─ void Rematerialize(RangeData) ⊲ WRITE │ -│ ├─ ReadOnlyMemory Read(Range) ⊳ READ │ -│ └─ RangeData ToRangeData() ⊳ READ │ +│ ├─ void Rematerialize(RangeData) ⊲ WRITE │ +│ ├─ ReadOnlyMemory Read(Range) ⊳ READ │ +│ └─ RangeData ToRangeData() ⊳ READ │ └──────────────────────────────────────────────────────────────────────────┘ │ -┌──────────────────────────────────────────────────────────────────────────▼──┐ -│ CacheDataExtensionService [Data Fetcher] │ +┌──────────────────────────────────────────────────────────────────────────▼───┐ +│ CacheDataExtensionService [Data Fetcher] │ │ 🟦 CLASS (sealed) │ │ │ -│ ExtendCacheAsync(current, requested, ct): │ -│ 1. missingRanges = CalculateMissingRanges() │ -│ 2. fetched = await _dataSource.FetchAsync(missingRanges, ct) ◄────────┐ │ -│ 3. return UnionAll(current, fetched) (merge, no trim) │ │ -│ │ │ -│ Shared by: │ │ -│ ├─ UserRequestHandler (extend to cover requested range — no mutation) │ │ -│ └─ RebalanceExecutor (extend to desired range — feeds mutation) │ │ +│ ExtendCacheAsync(current, requested, ct): │ +│ 1. missingRanges = CalculateMissingRanges() │ +│ 2. fetched = await _dataSource.FetchAsync(missingRanges, ct) ◄──────────┐ │ +│ 3. return UnionAll(current, fetched) (merge, no trim) │ │ +│ │ │ +│ Shared by: │ │ +│ ├─ UserRequestHandler (extend to cover requested range — no mutation) │ │ +│ └─ RebalanceExecutor (extend to desired range — feeds mutation) │ │ └───────────────────────────────────────────────────────────────────────────┼──┘ │ -┌───────────────────────────────────────────────────────────────────────────▼──┐ -│ IDataSource [External Data Source] │ +┌────────────────────────────────────────────────────────────────────────────▼──┐ +│ IDataSource [External Data Source] │ │ 🟧 INTERFACE (user-implemented) │ │ │ │ Methods: │ -│ ├─ FetchAsync(Range, CT) → Task> │ -│ └─ FetchAsync(IEnumerable, CT) → Task> │ +│ ├─ FetchAsync(Range, CT) → Task> │ +│ └─ FetchAsync(IEnumerable, CT) → Task> │ │ │ │ Characteristics: │ -│ ├─ User-provided implementation │ -│ ├─ May perform I/O (network, disk, database) │ -│ ├─ Read-only (fetches data) │ -│ └─ Should respect CancellationToken │ +│ ├─ User-provided implementation │ +│ ├─ May perform I/O (network, disk, database) │ +│ ├─ Read-only (fetches data) │ +│ └─ Should respect CancellationToken │ └───────────────────────────────────────────────────────────────────────────────┘ ``` -│ │ -│ Characteristics: │ -│ ├─ User-provided implementation │ -│ ├─ May perform I/O (network, disk, database) │ -│ ├─ Read-only (fetches data) │ -│ └─ Should respect CancellationToken │ -└───────────────────────────────────────────────────────────────────────────┘ -``` --- @@ -2271,12 +2265,12 @@ var sharedCache = new WindowCache(...); ### Value Types (Structs) -| Component | Mutability | Ownership | Lifetime | -|--------------------------|------------|------------------------|--------------------| +| Component | Mutability | Ownership | Lifetime | +|-------------------------------|------------|------------------------|--------------------| | NoRebalanceSatisfactionPolicy | Readonly | Copied into components | Component lifetime | -| ProportionalRangePlanner | Readonly | Copied into components | Component lifetime | -| NoRebalanceRangePlanner | Readonly | Copied into components | Component lifetime | -| RebalanceDecision | Readonly | Local variable | Method scope | +| ProportionalRangePlanner | Readonly | Copied into components | Component lifetime | +| NoRebalanceRangePlanner | Readonly | Copied into components | Component lifetime | +| RebalanceDecision | Readonly | Local variable | Method scope | ### Other Types diff --git a/docs/glossary.md b/docs/glossary.md index 9edc1f6..ccfc586 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -42,7 +42,7 @@ Mathematical domain for range operations. Must implement `IRangeDomain`. **Desired Cache Range**: Target computed by `ProportionalRangePlanner`. See [Component Map](component-map.md#desired-range-computation). **Available Range**: Intersection of Requested ∩ Current (immediately returnable). **Missing Range**: Requested \ Current (must fetch). -**NoRebalanceRange**: Stability zone. Requests within skip rebalancing. See [Architecture Model](architecture-model.md#burst-resistance). +**NoRebalanceRange**: Stability zone. Requests within skip rebalancing. See [Architecture Model](architecture-model.md#smart-eventual-consistency-model). --- @@ -52,13 +52,13 @@ Mathematical domain for range operations. Must implement `IRangeDomain`. Only ONE component (`RebalanceExecutor`) mutates shared state (Cache, IsInitialized, NoRebalanceRange). All others read-only. Eliminates write-write conflicts. See [Architecture Model](architecture-model.md#single-writer-architecture) | [Component Map - Implementation](component-map.md#single-writer-architecture). ### Decision-Driven Execution -Multi-stage validation pipeline separating decisions from execution. `RebalanceDecisionEngine` is sole authority for rebalance necessity. Execution proceeds only if all stages pass. Prevents thrashing. See [Architecture Model](architecture-model.md#decision-driven-execution) | [Invariants D.29](invariants.md#d-rebalance-decision-path-invariants). +Multi-stage validation pipeline separating decisions from execution. `RebalanceDecisionEngine` is sole authority for rebalance necessity. Execution proceeds only if all stages pass. Prevents thrashing. See [Architecture Model](architecture-model.md#rebalance-validation-vs-cancellation) | [Invariants D.29](invariants.md#d-rebalance-decision-path-invariants). ### Smart Eventual Consistency Cache converges to optimal state without blocking user requests. May temporarily serve from non-optimal range, rebalancing in background. See [Architecture Model - Consistency](architecture-model.md#smart-eventual-consistency-model). ### Burst Resistance -Handles rapid request sequences without thrashing. Achieved via "latest intent wins" and NoRebalanceRange stability zones. See [Architecture Model](architecture-model.md#burst-resistance). +Handles rapid request sequences without thrashing. Achieved via "latest intent wins" and NoRebalanceRange stability zones. See [Architecture Model](architecture-model.md#smart-eventual-consistency-model). --- @@ -71,13 +71,13 @@ Public API facade. Exposes `GetDataAsync()`. Handles user requests on user thread. Assembles data, publishes intents. Never mutates cache ([Invariants A.7-A.8](invariants.md#a-user-path--fast-user-access-invariants)). ### IntentController -Manages rebalance intent lifecycle. Evaluates `RebalanceDecisionEngine`, coordinates execution. Single-threaded background loop. See [Component Map](component-map.md#intentcontroller). +Manages rebalance intent lifecycle. Evaluates `RebalanceDecisionEngine`, coordinates execution. Single-threaded background loop. See [Component Map](component-map.md#5-rebalance-system---intent-management). ### RebalanceDecisionEngine Sole authority for rebalance necessity. 5-stage validation pipeline. Pure, deterministic, side-effect free. See [Invariants D.25-D.29](invariants.md#d-rebalance-decision-path-invariants). ### RebalanceExecutionController -Serializes/debounces executions. Implementations: `TaskBasedRebalanceExecutionController` (default), `ChannelBasedRebalanceExecutionController`. See [Component Map](component-map.md#rebalanceexecutioncontroller). +Serializes/debounces executions. Implementations: `TaskBasedRebalanceExecutionController` (default), `ChannelBasedRebalanceExecutionController`. See [Component Map](component-map.md#7-rebalance-system---execution). ### RebalanceExecutor Performs cache mutations. Fetches, merges, trims, updates state. Only mutator ([Invariant F.36](invariants.md#f-rebalance-execution-invariants)). @@ -93,16 +93,16 @@ Lock-free activity counter. Awaitable idle state. Tracks operations, signals "wa ## Operations & Processes ### Intent -Signal containing requested range + delivered data. Published by `UserRequestHandler` for rebalance evaluation. Signals, not commands (may be skipped). "Latest wins" - newer replaces older atomically. See [Invariants C.17-C.24](invariants.md#c-intent--rebalance-lifecycle-invariants). +Signal containing requested range + delivered data. Published by `UserRequestHandler` for rebalance evaluation. Signals, not commands (may be skipped). "Latest wins" - newer replaces older atomically. See [Invariants C.17-C.24](invariants.md#c-rebalance-intent--temporal-invariants). ### Rebalance -Background process adjusting cache to desired range. Phases: (1) Decision (5-stage), (2) Execution (fetch/merge/trim), (3) Mutation (atomic). See [Architecture Model](architecture-model.md#rebalance-lifecycle). +Background process adjusting cache to desired range. Phases: (1) Decision (5-stage), (2) Execution (fetch/merge/trim), (3) Mutation (atomic). See [Architecture Model](architecture-model.md#smart-eventual-consistency-model). ### User Path Handles user requests. Runs on user thread until intent published. Read-only. See [Invariants A.7-A.9](invariants.md#a-user-path--fast-user-access-invariants). ### Background Path -Rebalance processing. Runs on background threads (IntentController, RebalanceExecutionController, RebalanceExecutor). See [Architecture Model](architecture-model.md#execution-contexts). +Rebalance processing. Runs on background threads (IntentController, RebalanceExecutionController, RebalanceExecutor). See [Architecture Model](architecture-model.md#deterministic-background-job-synchronization). ### Debouncing Delays execution (e.g., 100ms) to let bursts settle. Cancels previous if new scheduled during window. Prevents thrashing. @@ -123,7 +123,7 @@ Delays execution (e.g., 100ms) to let bursts settle. Cancels previous if new sch **Volatile Read/Write**: Memory barriers. `Write` = release fence, `Read` = acquire fence. Lock-free publishing. **Interlocked Ops**: Atomic operations (`Increment`, `Decrement`, `Exchange`, `CompareExchange`). -**Acquire-Release**: Memory ordering. Writes before "release" visible after "acquire". See [Architecture Model](architecture-model.md#memory-model). +**Acquire-Release**: Memory ordering. Writes before "release" visible after "acquire". See [Architecture Model](architecture-model.md#lock-free-implementation). --- diff --git a/docs/invariants.md b/docs/invariants.md index 82a1998..49f1fe4 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -157,7 +157,7 @@ without polling or timing dependencies. **Rationale:** Eliminates write-write races and simplifies reasoning about cache consistency through architectural constraints. -**Implementation:** See [component-map.md - Single-Writer Architecture](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Single-Writer Architecture](component-map.md#single-writer-architecture) for enforcement mechanism details. **A.0** 🔵 **[Architectural]** The User Path **always has higher priority** than Rebalance Execution. @@ -168,7 +168,7 @@ without polling or timing dependencies. **Rationale:** Ensures responsive user experience by preventing background optimization from interfering with user-facing operations. -**Implementation:** See [component-map.md - Priority and Cancellation](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Priority and Cancellation](component-map.md#priority-and-cancellation) for enforcement mechanism details. **A.0a** 🟢 **[Behavioral — Test: `Invariant_A_0a_UserRequestCancelsRebalance`]** A User Request **MAY cancel** an ongoing or pending Rebalance Execution **ONLY when a new rebalance is validated as necessary** by the multi-stage decision pipeline. @@ -181,7 +181,7 @@ without polling or timing dependencies. **Rationale:** Prevents thrashing while allowing necessary cache adjustments when user access pattern changes significantly. -**Implementation:** See [component-map.md - Intent Management and Cancellation](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Intent Management and Cancellation](component-map.md#intent-management-and-cancellation) for enforcement mechanism details. ### A.2 User-Facing Guarantees @@ -206,7 +206,7 @@ without polling or timing dependencies. **Rationale:** Centralizes intent origination to single actor, simplifying reasoning about when and why rebalances occur. -**Implementation:** See [component-map.md - UserRequestHandler Responsibilities](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - UserRequestHandler Responsibilities](component-map.md#userrequesthandler-responsibilities) for enforcement mechanism details. **A.4** 🔵 **[Architectural]** Rebalance execution is **always performed asynchronously** relative to the User Path. @@ -217,7 +217,7 @@ without polling or timing dependencies. **Rationale:** Prevents user requests from blocking on background optimization work, ensuring responsive user experience. -**Implementation:** See [component-map.md - Async Execution Model](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Async Execution Model](component-map.md#async-execution-model) for enforcement mechanism details. **A.5** 🔵 **[Architectural]** The User Path performs **only the work necessary to return data to the user**. @@ -228,7 +228,7 @@ without polling or timing dependencies. **Rationale:** Minimizes user-facing latency by deferring non-essential work to background threads. -**Implementation:** See [component-map.md - UserRequestHandler Responsibilities](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - UserRequestHandler Responsibilities](component-map.md#userrequesthandler-responsibilities) for enforcement mechanism details. **A.6** 🟡 **[Conceptual]** The User Path may synchronously request data from `IDataSource` in the user execution context if needed to serve `RequestedRange`. - *Design decision*: Prioritizes user-facing latency over background work @@ -265,7 +265,7 @@ without polling or timing dependencies. **Rationale:** Enforces single-writer architecture, eliminating write-write races and simplifying concurrency reasoning. -**Implementation:** See [component-map.md - Single-Writer Architecture](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Single-Writer Architecture](component-map.md#single-writer-architecture) for enforcement mechanism details. **A.8** 🔵 **[Architectural — Tests: `Invariant_A3_8_ColdStart`, `_CacheExpansion`, `_FullCacheReplacement`]** The User Path **MUST NOT mutate cache under any circumstance**. @@ -277,7 +277,7 @@ without polling or timing dependencies. **Rationale:** Enforces single-writer architecture at the strictest level, preventing any mutation-related bugs in User Path. -**Implementation:** See [component-map.md - Single-Writer Enforcement](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Single-Writer Enforcement](component-map.md#single-writer-architecture) for enforcement mechanism details. **A.9** 🔵 **[Architectural]** Cache mutations are performed **exclusively by Rebalance Execution** (single-writer architecture). @@ -288,7 +288,7 @@ without polling or timing dependencies. **Rationale:** Single-writer architecture eliminates write-write races and simplifies concurrency model. -**Implementation:** See [component-map.md - Single-Writer Architecture](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Single-Writer Architecture](component-map.md#single-writer-architecture) for enforcement mechanism details. **A.9a** 🟢 **[Behavioral — Test: `Invariant_A3_9a_CacheContiguityMaintained`]** **Cache Contiguity Rule:** `CacheData` **MUST always remain contiguous** — gapped or partially materialized cache states are invalid. - *Observable via*: All requests return valid contiguous data @@ -311,7 +311,7 @@ without polling or timing dependencies. **Rationale:** Prevents readers from observing inconsistent cache state during updates. -**Implementation:** See [component-map.md - Atomic Cache Updates](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Atomic Cache Updates](component-map.md#atomic-cache-updates) for enforcement mechanism details. **B.13** 🔵 **[Architectural]** The system **never enters a permanently inconsistent state** with respect to `CacheData ↔ CurrentCacheRange`. @@ -322,7 +322,7 @@ without polling or timing dependencies. **Rationale:** Ensures cache remains usable even when rebalance operations are cancelled mid-flight. -**Implementation:** See [component-map.md - Consistency Under Cancellation](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Consistency Under Cancellation](component-map.md#consistency-under-cancellation) for enforcement mechanism details. **B.14** 🟡 **[Conceptual]** Temporary geometric or coverage inefficiencies in the cache are acceptable **if they can be resolved by rebalance execution**. - *Design decision*: User Path prioritizes speed over optimal cache shape @@ -341,7 +341,7 @@ without polling or timing dependencies. **Rationale:** Prevents cache from being updated with results that no longer match current user access pattern. -**Implementation:** See [component-map.md - Obsolete Result Prevention](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Obsolete Result Prevention](component-map.md#obsolete-result-prevention) for enforcement mechanism details. --- @@ -356,7 +356,7 @@ without polling or timing dependencies. **Rationale:** Prevents queue buildup and ensures system always works toward most recent user access pattern. -**Implementation:** See [component-map.md - Intent Singularity](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Intent Singularity](component-map.md#intent-singularity) for enforcement mechanism details. **C.18** 🟡 **[Conceptual]** Previously created intents may become **logically superseded** when a new intent is published, but rebalance execution relevance is determined by the **multi-stage rebalance validation logic**. - *Design intent*: Obsolescence ≠ cancellation; obsolescence ≠ guaranteed execution prevention @@ -371,7 +371,7 @@ without polling or timing dependencies. **Rationale:** Enables User Path priority by allowing cancellation of obsolete background work. -**Implementation:** See [component-map.md - Cancellation Protocol](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Cancellation Protocol](component-map.md#cancellation-protocol) for enforcement mechanism details. **C.20** 🔵 **[Architectural]** If a rebalance intent becomes obsolete before execution begins, the execution **must not start**. @@ -382,7 +382,7 @@ without polling or timing dependencies. **Rationale:** Avoids wasting CPU and I/O resources on obsolete cache shapes that no longer match user needs. -**Implementation:** See [component-map.md - Early Exit Validation](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Early Exit Validation](component-map.md#early-exit-validation) for enforcement mechanism details. **C.21** 🔵 **[Architectural]** At any point in time, **at most one rebalance execution is active**. @@ -393,7 +393,7 @@ without polling or timing dependencies. **Rationale:** Enforces single-writer architecture by ensuring only one component can mutate cache at any time. -**Implementation:** See [component-map.md - Serial Execution Guarantee](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Serial Execution Guarantee](component-map.md#serial-execution-guarantee) for enforcement mechanism details. **C.22** 🟡 **[Conceptual]** The results of rebalance execution **always reflect the latest user access pattern**. - *Design guarantee*: Obsolete results are discarded @@ -421,7 +421,7 @@ without polling or timing dependencies. **Rationale:** Prevents duplicate data fetching and ensures cache converges to exact data user saw. -**Implementation:** See [component-map.md - Intent Data Contract](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Intent Data Contract](component-map.md#intent-data-contract) for enforcement mechanism details. **C.24f** 🟡 **[Conceptual]** Delivered data in intent serves as the **authoritative source** for Rebalance Execution, avoiding duplicate fetches and ensuring consistency with user view. - *Design guarantee*: Rebalance Execution uses delivered data as base, not current cache @@ -509,7 +509,7 @@ The system prioritizes **decision correctness and work avoidance** over aggressi **Rationale:** Pure decision logic enables reasoning about correctness and prevents unintended side effects. -**Implementation:** See [component-map.md - Pure Decision Logic](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Pure Decision Logic](component-map.md#pure-decision-logic) for enforcement mechanism details. **D.26** 🔵 **[Architectural]** The Decision Path **never mutates cache state**. @@ -520,7 +520,7 @@ The system prioritizes **decision correctness and work avoidance** over aggressi **Rationale:** Enforces clean separation between decision-making and state mutation, simplifying reasoning. -**Implementation:** See [component-map.md - Decision-Execution Separation](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Decision-Execution Separation](component-map.md#decision-execution-separation) for enforcement mechanism details. **D.27** 🟢 **[Behavioral — Test: `Invariant_D27_NoRebalanceIfRequestInNoRebalanceRange`]** If `RequestedRange` is fully contained within `NoRebalanceRange`, **rebalance execution is prohibited**. - *Observable via*: DEBUG counters showing execution skipped (policy-based, see C.24b) @@ -548,7 +548,7 @@ The system prioritizes **decision correctness and work avoidance** over aggressi **Rationale:** Multi-stage validation prevents thrashing while ensuring cache converges to optimal state. -**Implementation:** See [component-map.md - Multi-Stage Decision Pipeline](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Multi-Stage Decision Pipeline](component-map.md#multi-stage-decision-pipeline) for enforcement mechanism details. --- @@ -567,7 +567,7 @@ The system prioritizes **decision correctness and work avoidance** over aggressi **Rationale:** Deterministic range computation ensures predictable cache behavior independent of history. -**Implementation:** See [component-map.md - Desired Range Computation](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Desired Range Computation](component-map.md#desired-range-computation) for enforcement mechanism details. **E.32** 🟡 **[Conceptual]** `DesiredCacheRange` represents the **canonical target state** towards which the system converges. - *Design concept*: Single source of truth for "what cache should be" @@ -586,7 +586,7 @@ The system prioritizes **decision correctness and work avoidance** over aggressi **Rationale:** Stability zone prevents thrashing when user makes small movements within already-cached area. -**Implementation:** See [component-map.md - NoRebalanceRange Computation](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - NoRebalanceRange Computation](component-map.md#norebalancerange-computation) for enforcement mechanism details. **E.35** 🟢 **[Behavioral]** When both `LeftThreshold` and `RightThreshold` are specified (non-null), their sum must not exceed 1.0. @@ -632,7 +632,7 @@ leftThreshold.HasValue && rightThreshold.HasValue **Rationale:** Ensures background work never degrades responsiveness to user requests. -**Implementation:** See [component-map.md - Cancellation Checkpoints](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Cancellation Checkpoints](component-map.md#cancellation-checkpoints) for enforcement mechanism details. **F.35b** 🟢 **[Behavioral — Covered by `Invariant_B15`]** Partially executed or cancelled Rebalance Execution **MUST NOT leave cache in inconsistent state**. - *Observable via*: Cache continues serving valid data after cancellation @@ -649,7 +649,7 @@ leftThreshold.HasValue && rightThreshold.HasValue **Rationale:** Single-writer architecture eliminates all write-write races and simplifies concurrency reasoning. -**Implementation:** See [component-map.md - Single-Writer Architecture](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Single-Writer Architecture](component-map.md#single-writer-architecture) for enforcement mechanism details. **F.36a** 🟢 **[Behavioral — Test: `Invariant_F36a_RebalanceNormalizesCache`]** Rebalance Execution mutates cache for normalization using **delivered data from intent as authoritative base**: - **Uses delivered data** from intent (not current cache) as starting point @@ -671,7 +671,7 @@ leftThreshold.HasValue && rightThreshold.HasValue **Rationale:** Complete mutation authority enables efficient convergence to optimal cache shape in single operation. -**Implementation:** See [component-map.md - Cache Normalization Operations](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Cache Normalization Operations](component-map.md#cache-normalization-operations) for enforcement mechanism details. **F.38** 🔵 **[Architectural]** Rebalance Execution requests data from `IDataSource` **only for missing subranges**. @@ -682,7 +682,7 @@ leftThreshold.HasValue && rightThreshold.HasValue **Rationale:** Avoids wasting I/O bandwidth by re-fetching data already in cache. -**Implementation:** See [component-map.md - Incremental Data Fetching](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Incremental Data Fetching](component-map.md#incremental-data-fetching) for enforcement mechanism details. **F.39** 🔵 **[Architectural]** Rebalance Execution **does not overwrite existing data** that intersects with `DesiredCacheRange`. @@ -693,7 +693,7 @@ leftThreshold.HasValue && rightThreshold.HasValue **Rationale:** Preserves valid cached data, avoiding redundant fetches and ensuring consistency. -**Implementation:** See [component-map.md - Data Preservation During Expansion](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Data Preservation During Expansion](component-map.md#data-preservation-during-expansion) for enforcement mechanism details. ### F.3 Post-Execution Guarantees @@ -729,7 +729,7 @@ The Rebalance Decision Path and Rebalance Execution Path MUST execute asynchrono **Rationale:** Ensures user requests remain responsive by offloading all optimization work to background threads. -**Implementation:** See [component-map.md - Async Execution Model](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Async Execution Model](component-map.md#async-execution-model) for enforcement mechanism details. - 🔵 **[Architectural — Covered by same test as G.43]** ### G.45: I/O responsibilities are separated between User Path and Rebalance Execution Path @@ -747,7 +747,7 @@ I/O operations (data fetching via IDataSource) are divided by responsibility: **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. +**Implementation:** See [component-map.md - I/O Isolation](component-map.md#io-isolation) for enforcement mechanism details. - 🔵 **[Architectural — Covered by same test as G.43]** **G.46** 🟢 **[Behavioral — Tests: `Invariant_G46_UserCancellationDuringFetch`, `Invariant_F35_G46_RebalanceCancellationBehavior`]** Cancellation **must be supported** for all scenarios: @@ -793,7 +793,7 @@ When activity counter reaches zero (idle state), NO work exists in any of these **Rationale:** Ensures idle detection accurately reflects all enqueued work, preventing premature idle signals. -**Implementation:** See [component-map.md - Activity Counter Ordering](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Activity Counter Ordering](component-map.md#activity-counter-ordering) for enforcement mechanism details. - 🔵 **[Architectural — Enforced by call site ordering]** ### H.48: Decrement-After-Completion Invariant @@ -812,7 +812,7 @@ Activity counter accurately reflects active work count at all times: **Rationale:** Ensures `WaitForIdleAsync()` will eventually complete by preventing counter leaks on any execution path. -**Implementation:** See [component-map.md - Activity Counter Cleanup](#implementation) for enforcement mechanism details. +**Implementation:** See [component-map.md - Activity Counter Cleanup](component-map.md#activity-counter-cleanup) for enforcement mechanism details. - 🔵 **[Architectural — Enforced by finally blocks]** **H.49** 🟡 **[Conceptual — Eventual consistency design]** **"Was Idle" Semantics:** diff --git a/docs/storage-strategies.md b/docs/storage-strategies.md index a79ce65..a3845da 100644 --- a/docs/storage-strategies.md +++ b/docs/storage-strategies.md @@ -1,7 +1,7 @@ # Sliding Window Cache - Storage Strategies Guide > **📖 For component implementation details, see:** -> - [Component Map - Storage Section](component-map.md#3-storage-implementations) - SnapshotReadStorage and CopyOnReadStorage architecture +> - [Component Map - Storage Section](component-map.md#2-storage-layer) - SnapshotReadStorage and CopyOnReadStorage architecture ## Overview @@ -16,15 +16,15 @@ This guide explains when to use each strategy and their trade-offs. ## Storage Strategy Comparison -| Aspect | Snapshot Storage | CopyOnRead Storage | -|------------------------|-----------------------------------|-----------------------------------| -| **Read Cost** | O(1) - zero allocation | O(n) - allocates and copies | -| **Rematerialize Cost** | O(n) - always allocates new array | O(1)* - reuses capacity | +| Aspect | Snapshot Storage | CopyOnRead Storage | +|------------------------|-----------------------------------|-----------------------------------------| +| **Read Cost** | O(1) - zero allocation | O(n) - allocates and copies | +| **Rematerialize Cost** | O(n) - always allocates new array | O(1)* - reuses capacity | | **Memory Pattern** | Single array, replaced atomically | Dual buffers, swap synchronized by lock | -| **Buffer Growth** | Always allocates exact size | Grows but never shrinks | -| **LOH Risk** | High for >85KB arrays | Lower (List growth strategy) | -| **Best For** | Read-heavy workloads | Rematerialization-heavy workloads | -| **Typical Use Case** | User-facing cache layer | Background cache layer | +| **Buffer Growth** | Always allocates exact size | Grows but never shrinks | +| **LOH Risk** | High for >85KB arrays | Lower (List growth strategy) | +| **Best For** | Read-heavy workloads | Rematerialization-heavy workloads | +| **Typical Use Case** | User-facing cache layer | Background cache layer | *Amortized O(1) when capacity is sufficient @@ -285,11 +285,11 @@ This composition leverages the strengths of both strategies: ### CopyOnRead Storage -| Operation | Time | Allocation | Notes | -|----------------------|------|---------------|------------------------------| -| Read | O(n) | n × sizeof(T) | Lock acquired + copy | -| Rematerialize (cold) | O(n) | n × sizeof(T) | Enumerate outside lock | -| Rematerialize (warm) | O(n) | 0 bytes** | Enumerate outside lock | +| Operation | Time | Allocation | Notes | +|----------------------|------|---------------|----------------------------------------| +| Read | O(n) | n × sizeof(T) | Lock acquired + copy | +| Rematerialize (cold) | O(n) | n × sizeof(T) | Enumerate outside lock | +| Rematerialize (warm) | O(n) | 0 bytes** | Enumerate outside lock | | ToRangeData | O(1) | 0 bytes* | Not synchronized (rebalance path only) | *Returns lazy enumerable From 823b2e19980b05618a79caa9eed09ddf9c8e05ed Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Sat, 28 Feb 2026 03:20:37 +0100 Subject: [PATCH 10/21] chore: update documentation links and structure in various markdown files; refactor: enhance .gitignore for IDE and OS-specific files; docs: add public API and user path documentation --- .gitignore | 10 +- AGENTS.md | 41 +- README.md | 998 +------ SlidingWindowCache.sln | 25 +- docs/actors-and-responsibilities.md | 308 --- docs/actors-to-components-mapping.md | 694 ----- docs/actors.md | 272 ++ docs/architecture-model.md | 692 ----- docs/architecture.md | 348 +++ docs/boundary-handling.md | 4 +- docs/cache-state-machine.md | 296 -- docs/component-map.md | 2390 ----------------- docs/components/decision.md | 80 + docs/components/execution.md | 117 + docs/components/infrastructure.md | 216 ++ docs/components/intent-management.md | 100 + docs/components/overview.md | 449 ++++ docs/components/public-api.md | 115 + docs/components/rebalance-path.md | 120 + docs/components/state-and-storage.md | 131 + docs/components/user-path.md | 72 + docs/diagnostics.md | 4 +- docs/glossary.md | 222 +- docs/invariants.md | 70 +- docs/scenario-model.md | 516 ---- docs/scenarios.md | 372 +++ docs/state-machine.md | 277 ++ docs/storage-strategies.md | 2 +- .../Core/Planning/ProportionalRangePlanner.cs | 8 +- .../README.md | 12 +- 30 files changed, 3025 insertions(+), 5936 deletions(-) delete mode 100644 docs/actors-and-responsibilities.md delete mode 100644 docs/actors-to-components-mapping.md create mode 100644 docs/actors.md delete mode 100644 docs/architecture-model.md create mode 100644 docs/architecture.md delete mode 100644 docs/cache-state-machine.md delete mode 100644 docs/component-map.md create mode 100644 docs/components/decision.md create mode 100644 docs/components/execution.md create mode 100644 docs/components/infrastructure.md create mode 100644 docs/components/intent-management.md create mode 100644 docs/components/overview.md create mode 100644 docs/components/public-api.md create mode 100644 docs/components/rebalance-path.md create mode 100644 docs/components/state-and-storage.md create mode 100644 docs/components/user-path.md delete mode 100644 docs/scenario-model.md create mode 100644 docs/scenarios.md create mode 100644 docs/state-machine.md diff --git a/.gitignore b/.gitignore index add57be..cd50262 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,12 @@ bin/ obj/ /packages/ riderModule.iml -/_ReSharper.Caches/ \ No newline at end of file +/_ReSharper.Caches/ + +# IDE / user-specific +.idea/ +.vs/ +*.DotSettings.user + +# Stray / OS artifacts +nul diff --git a/AGENTS.md b/AGENTS.md index d9467e4..bf173c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,29 +305,32 @@ refactor: AsyncActivityCounter lock has been removed and replaced with lock-free - Documentation may be outdated; long-term goal is synchronization with code ### Documentation Update Map -| File | Update When | Focus | -|------|-------------|-------| -| `README.md` | Public API changes, new features | User-facing examples, configuration | -| `docs/invariants.md` | Architectural invariants changed | System constraints, concurrency rules | -| `docs/architecture-model.md` | Concurrency mechanisms changed | Thread safety, synchronization primitives | -| `docs/component-map.md` | New components, major refactoring | Component catalog, dependencies | -| `docs/actors-and-responsibilities.md` | Component responsibilities changed | Actor roles, explicit responsibilities | -| `docs/cache-state-machine.md` | State transitions changed | State machine specification | -| `docs/storage-strategies.md` | Storage implementation changed | Strategy comparison, performance | -| `docs/scenario-model.md` | Temporal behavior changed | Scenario walkthroughs, sequences | -| `docs/diagnostics.md` | New diagnostics events | Instrumentation guide | -| `benchmarks/*/README.md` | Benchmark changes | Performance methodology, results | -| `tests/*/README.md` | Test architecture changes | Test suite documentation | -| XML comments (in code) | All code changes | Component purpose, invariant references | + +| File | Update When | Focus | +|-------------------------------|------------------------------------|-----------------------------------------| +| `README.md` | Public API changes, new features | User-facing examples, configuration | +| `docs/invariants.md` | Architectural invariants changed | System constraints, concurrency rules | +| `docs/architecture.md` | Concurrency mechanisms changed | Thread safety, coordination model | +| `docs/components/overview.md` | New components, major refactoring | Component catalog, dependencies | +| `docs/actors.md` | Component responsibilities changed | Actor roles, explicit responsibilities | +| `docs/state-machine.md` | State transitions changed | State machine specification | +| `docs/storage-strategies.md` | Storage implementation changed | Strategy comparison, performance | +| `docs/scenarios.md` | Temporal behavior changed | Scenario walkthroughs, sequences | +| `docs/diagnostics.md` | New diagnostics events | Instrumentation guide | +| `docs/glossary.md` | Terms or semantics change | Canonical terminology | +| `benchmarks/*/README.md` | Benchmark changes | Performance methodology, results | +| `tests/*/README.md` | Test architecture changes | Test suite documentation | +| XML comments (in code) | All code changes | Component purpose, invariant references | ## Architecture References **Before making changes, consult these critical documents:** -- `docs/invariants.md` - System invariants (33KB) - READ THIS FIRST -- `docs/architecture-model.md` - Architecture and concurrency model -- `docs/actors-and-responsibilities.md` - Component responsibilities -- `docs/component-map.md` - Detailed component catalog (86KB) -- `README.md` - User guide and examples (32KB) +- `docs/invariants.md` - System invariants - READ THIS FIRST +- `docs/architecture.md` - Architecture and concurrency model +- `docs/actors.md` - Actor responsibilities and boundaries +- `docs/components/overview.md` - Component catalog (split by subsystem) +- `docs/glossary.md` - Canonical terminology +- `README.md` - User guide and examples **Key Invariants to NEVER violate:** 1. Cache Contiguity: No gaps allowed in cached ranges diff --git a/README.md b/README.md index d7e413e..6779949 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@ # Sliding Window Cache -**A read-only, range-based, sequential-optimized cache with decision-driven background rebalancing, smart eventual -consistency, and intelligent work avoidance.** - ---- +A read-only, range-based, sequential-optimized cache with decision-driven background rebalancing, smart eventual consistency, and intelligent work avoidance. [![CI/CD](https://github.com/blaze6950/SlidingWindowCache/actions/workflows/slidingwindowcache.yml/badge.svg)](https://github.com/blaze6950/SlidingWindowCache/actions/workflows/slidingwindowcache.yml) [![NuGet](https://img.shields.io/nuget/v/SlidingWindowCache.svg)](https://www.nuget.org/packages/SlidingWindowCache/) @@ -12,152 +9,34 @@ consistency, and intelligent work avoidance.** [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![.NET 8.0](https://img.shields.io/badge/.NET-8.0-blue.svg)](https://dotnet.microsoft.com/download/dotnet/8.0) ---- - -## 📑 Table of Contents - -- [Overview](#-overview) -- [Key Features](#key-features) -- [Decision-Driven Rebalance Execution](#decision-driven-rebalance-execution) -- [Sliding Window Cache Concept](#-sliding-window-cache-concept) -- [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) -- [Optional Diagnostics](#-optional-diagnostics) -- [Documentation](#-documentation) -- [Performance Considerations](#-performance-considerations) -- [CI/CD & Package Information](#-cicd--package-information) -- [Contributing & Feedback](#-contributing--feedback) -- [License](#license) - ---- - -## 📦 Overview - -The Sliding Window Cache is a high-performance caching library designed for scenarios where data is accessed in -sequential or predictable patterns across ranges. It automatically prefetches and maintains a "window" of data around -the most recently requested range, significantly reducing the need for repeated data source queries. - -### Key Features - -- **Automatic Prefetching**: Intelligently prefetches data on both sides of requested ranges based on configurable - coefficients -- **Smart Eventual Consistency**: Decision-driven rebalance execution with multi-stage analytical validation ensures the - cache converges to optimal configuration while avoiding unnecessary work -- **Work Avoidance Through Validation**: Multi-stage decision pipeline (NoRebalanceRange containment, pending rebalance - coverage, cache geometry analysis) prevents thrashing, reduces redundant I/O, and maintains system stability under - rapidly changing access patterns -- **Background Rebalancing**: Asynchronously adjusts the cache window when validation confirms necessity, with - debouncing to control convergence timing -- **Opportunistic Execution**: Rebalance operations may be skipped when validation determines they are unnecessary ( - intent represents observed access, not mandatory work) -- **Single-Writer Architecture**: User Path is read-only; only Rebalance Execution mutates cache state, eliminating race - conditions with cancellation support for coordination -- **Range-Based Operations**: Built on top of the [`Intervals.NET`](https://github.com/blaze6950/Intervals.NET) library - for robust range handling -- **Configurable Read Modes**: Choose between different materialization strategies based on your performance - requirements -- **Optional Diagnostics**: Built-in instrumentation for monitoring cache behavior and validating system invariants -- **Full Cancellation Support**: User-provided `CancellationToken` propagates through the async pipeline; rebalance - operations support cancellation at all stages - -### Decision-Driven Rebalance Execution - -> **📖 For detailed architectural explanation, see:** [Architecture Model - Decision-Driven Execution](docs/architecture-model.md#rebalance-validation-vs-cancellation) - -The cache uses a sophisticated **decision-driven model** where rebalance necessity is determined by analytical -validation rather than blindly executing every user request. This prevents thrashing, reduces unnecessary I/O, and -maintains stability under rapid access pattern changes. - -**Visual Flow:** - -``` -User Request - │ - ▼ -┌─────────────────────────────────────────────────┐ -│ User Path (User Thread - Synchronous) │ -│ • Read from cache or fetch missing data │ -│ • Return data immediately to user │ -│ • Publish intent with delivered data │ -└────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────┐ -│ Decision Engine (Background Loop - CPU-only) │ -│ Stage 1: NoRebalanceRange check │ -│ Stage 2: Pending coverage check │ -│ Stage 3: Desired == Current check │ -│ → Decision: SKIP or SCHEDULE │ -└────────────┬────────────────────────────────────┘ - │ - ├─── If SKIP: return (work avoidance) ✓ - │ - └─── If SCHEDULE: - │ - ▼ - ┌─────────────────────────────────────┐ - │ Background Rebalance (ThreadPool) │ - │ • Debounce delay │ - │ • Fetch missing data (I/O) │ - │ • Normalize cache to desired range │ - │ • Update cache state atomically │ - └─────────────────────────────────────┘ -``` - -**Key Points:** - -1. **User requests never block** - data returned immediately, rebalance happens later -2. **Decision happens in background** - validation is CPU-only (microseconds), happens in the intent processing loop before scheduling -3. **Work avoidance prevents thrashing** - validation may skip rebalance entirely if unnecessary -4. **Only I/O happens in background** - debounce + data fetching + cache updates run asynchronously -5. **Smart eventual consistency** - cache converges to optimal state while avoiding unnecessary operations - -**Why This Matters:** - -- **Handles request bursts correctly**: First request schedules rebalance, subsequent requests validate and skip if - pending rebalance covers them -- **No background queue buildup**: Decisions made immediately, not queued -- **Prevents oscillation**: Stage 2 validation checks if pending rebalance will satisfy request -- **Lightweight**: Decision logic is pure CPU (math, conditions), no I/O blocking - -**For complete architectural details, see:** - -- [Architecture Model](docs/architecture-model.md) - Smart eventual consistency and synchronous decision execution -- [Invariants](docs/invariants.md) - Multi-stage validation pipeline specification (Section D) -- [Scenario Model](docs/scenario-model.md) - Temporal behavior and decision scenarios +## What It Is ---- +Optimized for access patterns that move predictably across a domain (scrolling, playback, time-series inspection): -## 🎯 Sliding Window Cache Concept +- Serves `GetDataAsync` immediately; background work converges the cache window asynchronously +- Single-writer architecture: only rebalance execution mutates shared cache state +- Decision-driven execution: multi-stage analytical validation prevents thrashing and unnecessary I/O +- Smart eventual consistency: cache converges to optimal configuration while avoiding unnecessary work -Traditional caches work with individual keys. A sliding window cache, in contrast, operates on **continuous ranges** of -data: +For the canonical architecture docs, see `docs/architecture.md`. -1. **User requests a range** (e.g., records 100-200) -2. **Cache fetches more than requested** (e.g., records 50-300) based on configured left/right cache coefficients -3. **Subsequent requests within the window are served instantly** from materialized data -4. **Window automatically rebalances** when the user moves outside threshold boundaries +## Install -This pattern is ideal for: +```bash +dotnet add package SlidingWindowCache +``` -- Time-series data (sensor readings, logs, metrics) -- Paginated datasets with forward/backward navigation -- Sequential data processing (video frames, audio samples) -- Any scenario with high spatial or temporal locality of access +## Sliding Window Cache Concept ---- +Traditional caches work with individual keys. A sliding window cache operates on **continuous ranges** of data: -## 🔍 Understanding the Sliding Window +1. User requests a range (e.g., records 100–200) +2. Cache fetches more than requested (e.g., records 50–300) based on left/right cache coefficients +3. Subsequent requests within the window are served instantly from materialized data +4. Window automatically rebalances when the user moves outside threshold boundaries ### Visual: Requested Range vs. Cache Window -When you request a range, the cache actually fetches and stores a larger window: - ``` Requested Range (what user asks for): [======== USER REQUEST ========] @@ -167,13 +46,8 @@ Actual Cache Window (what cache stores): ← leftCacheSize requestedRange size rightCacheSize → ``` -The **left** and **right buffers** are calculated as multiples of the requested range size using the `leftCacheSize` and -`rightCacheSize` coefficients. - ### Visual: Rebalance Trigger -Rebalancing occurs when a new request moves outside the threshold boundaries: - ``` Current Cache Window: [========*===================== CACHE ======================*=======] @@ -193,109 +67,79 @@ Scenario 2: Request outside threshold → Rebalance triggered ### Visual: Configuration Impact -How coefficients control the cache window size: - ``` Example: User requests range of size 100 leftCacheSize = 1.0, rightCacheSize = 2.0 [==== 100 ====][======= 100 =======][============ 200 ============] Left Buffer Requested Range Right Buffer - + Total Cache Window = 100 + 100 + 200 = 400 items leftThreshold = 0.2 (20% of 400 = 80 items) rightThreshold = 0.2 (20% of 400 = 80 items) ``` -**Key insight:** Threshold percentages are calculated based on the **total cache window size**, not individual buffer -sizes. - ---- - -## 💾 Materialization for Fast Access - -### Why Materialization? - -The cache **always materializes** the data it fetches, meaning it stores the data in memory in a directly accessible -format (arrays or lists) rather than keeping lazy enumerables. This design choice ensures: - -- **Fast, predictable read performance**: No deferred execution chains on the hot path -- **Multiple reads without re-enumeration**: The same data can be read many times at zero cost (in Snapshot mode) -- **Clean separation of concerns**: Data fetching (I/O-bound) is decoupled from data serving (CPU-bound) - -### Read Modes: Snapshot vs. CopyOnRead - -The cache supports two materialization strategies, configured at creation time via the `UserCacheReadMode` enum: - -#### Snapshot Mode (`UserCacheReadMode.Snapshot`) - -**Storage**: Contiguous array (`TData[]`) -**Read behavior**: Returns `ReadOnlyMemory` pointing directly to internal array -**Rebalance behavior**: Always allocates a new array - -**Advantages:** - -- ✅ **Zero allocations on read** – no memory overhead per request -- ✅ **Fastest read performance** – direct memory view -- ✅ Ideal for **read-heavy scenarios** with frequent access to cached data +**Key insight:** Threshold percentages are calculated based on the **total cache window size**, not individual buffer sizes. -**Disadvantages:** +## Decision-Driven Rebalance Execution -- ❌ **Expensive rebalancing** – always allocates a new array, even if size is unchanged -- ❌ **Large Object Heap (LOH) pressure** – arrays ≥85,000 bytes go to LOH, which can cause fragmentation -- ❌ Higher memory usage during rebalance (old + new arrays temporarily coexist) +The cache uses a **decision-driven model** where rebalance necessity is determined by analytical validation, not by blindly executing every user request. This prevents thrashing, reduces unnecessary I/O, and maintains stability under rapid access pattern changes. -**Best for:** - -- Applications that read the same data many times -- Scenarios where cache updates are infrequent relative to reads -- Systems with ample memory and minimal LOH concerns - -#### CopyOnRead Mode (`UserCacheReadMode.CopyOnRead`) - -**Storage**: Growable list (`List`) -**Read behavior**: Allocates a new array and copies the requested range -**Rebalance behavior**: Uses `List` operations (Clear + AddRange) - -**Advantages:** - -- ✅ **Cheaper rebalancing** – `List` can grow without always allocating large arrays -- ✅ **Reduced LOH pressure** – avoids large contiguous allocations in most cases -- ✅ Ideal for **memory-sensitive scenarios** or when rebalancing is frequent - -**Disadvantages:** - -- ❌ **Allocates on every read** – new array per request -- ❌ **Copy overhead** – data must be copied from list to array -- ❌ Slower read performance compared to Snapshot mode - -**Best for:** - -- Applications with frequent cache rebalancing -- Memory-constrained environments -- Scenarios where each range is typically read once or twice -- Systems sensitive to LOH fragmentation +``` +User Request + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ User Path (User Thread — Synchronous) │ +│ • Read from cache or fetch missing data │ +│ • Return data immediately to user │ +│ • Publish intent with delivered data │ +└────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ Decision Engine (Background Loop — CPU-only) │ +│ Stage 1: Current NoRebalanceRange check │ +│ Stage 2: Pending coverage check │ +│ Stage 3: DesiredCacheRange computation │ +│ Stage 4: Desired == Current check │ +│ → Decision: SKIP or SCHEDULE │ +└────────────┬────────────────────────────────────┘ + │ + ├─── If SKIP: return (work avoidance) ✓ + │ + └─── If SCHEDULE: + │ + ▼ + ┌─────────────────────────────────────┐ + │ Background Rebalance (ThreadPool) │ + │ • Debounce delay │ + │ • Fetch missing data (I/O) │ + │ • Normalize cache to desired range │ + │ • Update cache state atomically │ + └─────────────────────────────────────┘ +``` -### Choosing a Read Mode +Key points: +1. **User requests never block** — data returned immediately, rebalance happens later +2. **Decision happens in background** — CPU-only validation (microseconds) in the intent processing loop +3. **Work avoidance prevents thrashing** — validation may skip rebalance entirely if unnecessary +4. **Only I/O happens asynchronously** — debounce + data fetching + cache updates run in background +5. **Smart eventual consistency** — cache converges to optimal state while avoiding unnecessary operations -**Quick Decision Guide:** +## Materialization for Fast Access -| Your Scenario | Recommended Mode | Why | -|----------------------|------------------|------------------------| -| Read data many times | **Snapshot** | Zero-allocation reads | -| Frequent rebalancing | **CopyOnRead** | Cheaper cache updates | -| Large cache (>85KB) | **CopyOnRead** | Avoid LOH pressure | -| Memory constrained | **CopyOnRead** | Better memory behavior | -| Read-once patterns | **CopyOnRead** | Copy cost already paid | -| Read-heavy workload | **Snapshot** | Direct memory access | +The cache always materializes data in memory. Two storage strategies are available: -**For detailed comparison, performance benchmarks, multi-level cache composition patterns, and staging buffer -implementation details, see [Storage Strategies Guide](docs/storage-strategies.md).** +| Strategy | Read | Write | Best For | +|-------------------------------------------------|----------------------------------------------------|----------------------------------|------------------------------------------| +| **Snapshot** (`UserCacheReadMode.Snapshot`) | Zero-allocation (`ReadOnlyMemory` directly) | Expensive (new array allocation) | Read-heavy workloads | +| **CopyOnRead** (`UserCacheReadMode.CopyOnRead`) | Allocates per read (copy) | Cheap (`List` operations) | Frequent rebalancing, memory-constrained | ---- +For detailed comparison and guidance, see `docs/storage-strategies.md`. -## 🚀 Usage Example +## Quick Start ```csharp using SlidingWindowCache; @@ -303,7 +147,6 @@ using SlidingWindowCache.Configuration; using Intervals.NET; using Intervals.NET.Domain.Default.Numeric; -// Configure the cache behavior var options = new WindowCacheOptions( leftCacheSize: 1.0, // Cache 100% of requested range size to the left rightCacheSize: 2.0, // Cache 200% of requested range size to the right @@ -311,7 +154,6 @@ var options = new WindowCacheOptions( rightThreshold: 0.2 // Rebalance if <20% right buffer remains ); -// Create cache with Snapshot mode (zero-allocation reads) var cache = WindowCache.Create( dataSource: myDataSource, domain: new IntegerFixedStepDomain(), @@ -319,715 +161,169 @@ var cache = WindowCache.Create( readMode: UserCacheReadMode.Snapshot ); -// Request data - returns RangeResult -var result = await cache.GetDataAsync( - Range.Closed(100, 200), - cancellationToken -); +var result = await cache.GetDataAsync(Range.Closed(100, 200), cancellationToken); -// Access the data foreach (var item in result.Data.Span) -{ Console.WriteLine(item); -} ``` ---- - -## 🎯 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 -); -``` +## Boundary Handling -### Basic Usage +`GetDataAsync` returns `RangeResult` where `Range` may be `null` when the data source has no data for the requested range. Always check before accessing data: ```csharp -var result = await cache.GetDataAsync( - Intervals.NET.Factories.Range.Closed(100, 200), - ct -); +var result = await cache.GetDataAsync(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}"); - + // Data available 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); - } + // No data available for this range } - -// 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 +Canonical guide: `docs/boundary-handling.md`. -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. +## Resource Management -### Disposal Pattern - -WindowCache implements `IAsyncDisposable` for proper async resource cleanup: +`WindowCache` implements `IAsyncDisposable`. Always dispose when done: ```csharp -// Recommended: Use await using declaration +// Recommended: await using await using var cache = new WindowCache( - dataSource, - domain, - options, - cacheDiagnostics + dataSource, domain, options, cacheDiagnostics ); -// Use the cache -var data = await cache.GetDataAsync(Range.Closed(0, 100), cancellationToken); - +var data = await cache.GetDataAsync(Range.Closed(0, 100), ct); // DisposeAsync called automatically at end of scope ``` -### What Disposal Does - -When `DisposeAsync()` is called, the cache: - -1. **Stops accepting new requests** - All methods throw `ObjectDisposedException` after disposal -2. **Cancels background rebalance processing** - Signals cancellation to intent processing and execution loops -3. **Waits for current operations to complete** - Gracefully allows in-flight rebalance operations to finish -4. **Releases all resources** - Disposes channels, semaphores, and cancellation token sources -5. **Is idempotent** - Safe to call multiple times, handles concurrent disposal attempts - -### Disposal Behavior - -**Graceful Shutdown:** -```csharp -await using var cache = CreateCache(); - -// Make requests -await cache.GetDataAsync(range1, ct); -await cache.GetDataAsync(range2, ct); - -// No need to call WaitForIdleAsync() before disposal -// DisposeAsync() handles graceful shutdown automatically -``` - -**After Disposal:** -```csharp -var cache = CreateCache(); -await cache.DisposeAsync(); - -// All operations throw ObjectDisposedException -await cache.GetDataAsync(range, ct); // ❌ Throws ObjectDisposedException -await cache.WaitForIdleAsync(); // ❌ Throws ObjectDisposedException -await cache.DisposeAsync(); // ✅ Succeeds (idempotent) -``` - -**Long-Lived Cache:** -```csharp -public class DataService : IAsyncDisposable -{ - private readonly WindowCache _cache; - - public DataService(IDataSource dataSource) - { - _cache = new WindowCache( - dataSource, - new IntegerFixedStepDomain(), - options - ); - } - - public ValueTask> GetDataAsync(Range range, CancellationToken ct) - => _cache.GetDataAsync(range, ct); - - public async ValueTask DisposeAsync() - { - await _cache.DisposeAsync(); - } -} -``` - -### Important Notes - -- **No timeout needed**: Disposal completes when background tasks finish their current work (typically milliseconds) -- **Thread-safe**: Multiple concurrent disposal calls are handled safely using lock-free synchronization -- **No forced termination**: Background operations are cancelled gracefully, not forcibly terminated -- **Memory eligible for GC**: After disposal, the cache becomes eligible for garbage collection - ---- - -## ⚙️ Configuration - -The `WindowCacheOptions` class provides fine-grained control over cache behavior. Understanding these parameters is -essential for optimal performance. - -### Configuration Parameters - -#### Cache Size Coefficients - -**`leftCacheSize`** (double, default: 1.0) - -- **Definition**: Multiplier applied to the requested range size to determine the left buffer size -- **Practical meaning**: How much data to prefetch *before* the requested range -- **Example**: If user requests 100 items and `leftCacheSize = 1.5`, the cache prefetches 150 items to the left -- **Typical values**: 0.5 to 2.0 (depending on backward navigation patterns) - -**`rightCacheSize`** (double, default: 2.0) - -- **Definition**: Multiplier applied to the requested range size to determine the right buffer size -- **Practical meaning**: How much data to prefetch *after* the requested range -- **Example**: If user requests 100 items and `rightCacheSize = 2.0`, the cache prefetches 200 items to the right -- **Typical values**: 1.0 to 3.0 (higher for forward-scrolling scenarios) +After disposal, all operations throw `ObjectDisposedException`. Disposal is idempotent and concurrent-safe. Background operations are cancelled gracefully, not forcibly terminated. -#### Threshold Policies +## Configuration -**`leftThreshold`** (double, default: 0.2) +### Cache Size Coefficients -- **Definition**: Percentage of the **total cache window size** that triggers rebalancing when crossed on the left -- **Calculation**: `leftThreshold × (Left Buffer + Requested Range + Right Buffer)` -- **Example**: With total window of 400 items and `leftThreshold = 0.2`, rebalance triggers when user moves within 80 - items of the left edge -- **Typical values**: 0.15 to 0.3 (lower = more aggressive rebalancing) +**`leftCacheSize`** — multiplier of requested range size for left buffer. `1.0` = cache as much to the left as the user requested. -**`rightThreshold`** (double, default: 0.2) +**`rightCacheSize`** — multiplier of requested range size for right buffer. `2.0` = cache twice as much to the right. -- **Definition**: Percentage of the **total cache window size** that triggers rebalancing when crossed on the right -- **Calculation**: `rightThreshold × (Left Buffer + Requested Range + Right Buffer)` -- **Example**: With total window of 400 items and `rightThreshold = 0.2`, rebalance triggers when user moves within 80 - items of the right edge -- **Typical values**: 0.15 to 0.3 (lower = more aggressive rebalancing) +### Threshold Policies -**🚨 Important Constraint: Threshold Sum** +**`leftThreshold`** / **`rightThreshold`** — percentage of the **total cache window size** that triggers rebalancing when crossed. E.g., with a total window of 400 items and `rightThreshold: 0.2`, rebalance triggers when the request moves within 80 items of the right edge. -The **sum of `leftThreshold` and `rightThreshold` must not exceed 1.0** when both are specified. +**⚠️ Threshold Sum Constraint:** `leftThreshold + rightThreshold` must not exceed `1.0` when both are specified. Exceeding this creates overlapping stability zones (impossible geometry). Examples: +- ✅ `leftThreshold: 0.3, rightThreshold: 0.3` (sum = 0.6) +- ✅ `leftThreshold: 0.5, rightThreshold: 0.5` (sum = 1.0) +- ❌ `leftThreshold: 0.6, rightThreshold: 0.6` (sum = 1.2 — throws `ArgumentException`) -**Why?** Thresholds represent percentages of the total cache window that are shrunk inward from each side to create the no-rebalance stability zone. If their sum exceeds 1.0 (100%), the shrinkage zones would overlap, creating an impossible geometric configuration. +### Debouncing -**Examples:** -- ✅ Valid: `leftThreshold: 0.3, rightThreshold: 0.3` (sum = 0.6) -- ✅ Valid: `leftThreshold: 0.5, rightThreshold: 0.5` (sum = 1.0 - boundaries meet at center) -- ✅ Valid: `leftThreshold: 0.8, rightThreshold: null` (only one threshold) -- ❌ Invalid: `leftThreshold: 0.6, rightThreshold: 0.6` (sum = 1.2 - overlapping!) +**`debounceDelay`** (default: 100ms) — delay before background rebalance executes. Prevents thrashing when the user rapidly changes access patterns. -**Validation:** This constraint is enforced at construction time - `WindowCacheOptions` constructor will throw `ArgumentException` if violated. +### Execution Strategy -**⚠️ Critical Understanding**: Thresholds are **NOT** calculated against individual buffer sizes. They represent a -percentage of the **entire cache window** (left buffer + requested range + right buffer). -See [Understanding the Sliding Window](#-understanding-the-sliding-window) for visual examples. +**`rebalanceQueueCapacity`** (default: `null`) — controls rebalance serialization strategy: -#### Debouncing - -**`debounceDelay`** (TimeSpan, default: 100ms) - -- **Definition**: Minimum time delay before executing a rebalance operation after it's triggered -- **Purpose**: Prevents cache thrashing when user rapidly changes access patterns -- **Behavior**: If multiple rebalance requests occur within the debounce window, only the last one executes -- **Typical values**: 20ms to 200ms (depending on data source latency) -- **Trade-off**: Higher values reduce rebalance frequency but may delay cache optimization - -#### Execution Strategy - -**`rebalanceQueueCapacity`** (int?, default: null) - -- **Definition**: Controls the rebalance execution serialization strategy -- **Default**: `null` (unbounded task-based strategy - recommended for most scenarios) -- **Bounded capacity**: Set to `>= 1` to use channel-based strategy with backpressure -- **Purpose**: Choose between lightweight task chaining or strict queue capacity control -- **When to use bounded strategy**: - - High-frequency rebalance scenarios requiring backpressure - - Memory-constrained environments where queue growth must be limited - - Testing scenarios requiring deterministic queue behavior -- **When to use unbounded strategy (default)**: - - Normal operation with typical rebalance frequencies - - Maximum performance with minimal overhead - - Fire-and-forget execution model preferred -- **Trade-off**: Bounded capacity provides backpressure control but may slow intent processing when queue is full - -**Strategy Comparison:** - -| Strategy | Queue Capacity | Backpressure | Overhead | Use Case | -|--------------------------|---------------------------|------------------|-----------------|----------------------------------------| -| **Task-based** (default) | Unbounded | None | Minimal | Recommended for most scenarios | -| **Channel-based** | Bounded (`capacity >= 1`) | Blocks when full | Slightly higher | High-frequency or resource-constrained | - -**Note**: Both strategies guarantee single-writer architecture - only one rebalance executes at a time. +| Value | Strategy | Backpressure | Use Case | +|------------------|--------------------------------------|------------------|-----------------------------------------| +| `null` (default) | Task-based (lock-free task chaining) | None | Recommended for 99% of scenarios | +| `>= 1` | Channel-based (bounded queue) | Blocks when full | Extreme high-frequency with I/O latency | ### Configuration Examples -**Forward-heavy scrolling** (e.g., log viewer, video player): - +**Forward-heavy scrolling:** ```csharp var options = new WindowCacheOptions( - leftCacheSize: 0.5, // Minimal backward buffer - rightCacheSize: 3.0, // Aggressive forward prefetching + leftCacheSize: 0.5, + rightCacheSize: 3.0, leftThreshold: 0.25, - rightThreshold: 0.15 // Trigger rebalance earlier when moving forward + rightThreshold: 0.15 ); ``` -**Bidirectional navigation** (e.g., paginated data grid): - +**Bidirectional navigation:** ```csharp var options = new WindowCacheOptions( - leftCacheSize: 1.5, // Balanced backward buffer - rightCacheSize: 1.5, // Balanced forward buffer + leftCacheSize: 1.5, + rightCacheSize: 1.5, leftThreshold: 0.2, rightThreshold: 0.2 ); ``` -**Aggressive prefetching with stability** (e.g., high-latency data source): - +**High-latency data source with stability:** ```csharp var options = new WindowCacheOptions( leftCacheSize: 2.0, rightCacheSize: 3.0, - leftThreshold: 0.1, // Rebalance early to maintain large buffers + leftThreshold: 0.1, rightThreshold: 0.1, - debounceDelay: TimeSpan.FromMilliseconds(100) // Wait for access pattern to stabilize -); -``` - -**Bounded execution strategy** (e.g., high-frequency access with backpressure control): - -```csharp -var options = new WindowCacheOptions( - leftCacheSize: 1.0, - rightCacheSize: 2.0, - readMode: UserCacheReadMode.Snapshot, - leftThreshold: 0.2, - rightThreshold: 0.2, - rebalanceQueueCapacity: 5 // Limit pending rebalance operations to 5 + debounceDelay: TimeSpan.FromMilliseconds(150) ); ``` ---- - -## ⚡ Execution Strategy Selection - -The `rebalanceQueueCapacity` configuration parameter controls how the cache serializes background rebalance operations. Choosing the right strategy depends on your expected burst load characteristics and I/O latency patterns. - -### Strategy Overview - -| Configuration | Implementation | Queue Behavior | Best For | -|---------------------|----------------|------------------------------------------|--------------------------------------------------------------------------| -| `null` (default) | Task-based | Unbounded accumulation via task chaining | **99% of use cases** - typical workloads with moderate burst patterns | -| `>= 1` (e.g., `10`) | Channel-based | Bounded queue with backpressure | Extreme high-frequency scenarios (1000+ rapid requests with I/O latency) | - -### Unbounded Execution (Default - Recommended) - -**Configuration**: -```csharp -var options = new WindowCacheOptions( - leftCacheSize: 1.0, - rightCacheSize: 2.0, - rebalanceQueueCapacity: null // Unbounded (default) -); -``` - -**Characteristics**: -- Task-based execution with unbounded task chaining -- Minimal overhead -- Excellent for typical workloads (burst ≤100 requests) -- Effective cancellation of obsolete rebalance operations -- No backpressure - intent processing never blocks - -**Best for**: -- Web APIs with moderate scrolling (10-100 rapid requests) -- Gaming/real-time applications with fast local data -- Most production scenarios with typical access patterns -- Any scenario where request bursts are ≤100 or I/O latency is low - -✅ **Recommended for 99% of use cases** - ---- - -### Bounded Execution (High-Frequency Optimization) - -**Configuration**: -```csharp -var options = new WindowCacheOptions( - leftCacheSize: 1.0, - rightCacheSize: 2.0, - rebalanceQueueCapacity: 10 // Bounded queue with capacity of 10 -); -``` - -**Characteristics**: -- Channel-based execution with bounded queue and backpressure -- Prevents unbounded queue accumulation under extreme burst loads -- Intent processing blocks when queue is full (applies backpressure) -- Provides dramatic speedup (25-196×) under extreme conditions (1000+ burst with I/O latency) -- Slightly less memory usage (5-9% reduction) -- Performs identically to unbounded for typical workloads (burst ≤100) - -**Best for**: -- Streaming sensor data at 1000+ Hz with network I/O -- Any scenario with 1000+ rapid requests and significant I/O latency (50-100ms+) -- Systems requiring predictable bounded queue behavior -- Memory-constrained environments where accumulation must be prevented - -⚠️ **Use for extreme high-frequency edge cases only** - ---- - -### Decision Guide - -**Choose Unbounded (null) if:** -- ✅ Your application has typical access patterns (10-100 rapid requests) -- ✅ I/O latency is low (<50ms) or burst size is moderate (≤100) -- ✅ You want minimal overhead and maximum performance for common scenarios -- ✅ **This covers 99% of production use cases** - -**Choose Bounded (capacity ≥ 10) if:** -- ✅ Your application experiences extreme burst loads (1000+ rapid requests) -- ✅ Data source has significant latency (50-100ms+) during bursts -- ✅ You need predictable queue depth to prevent accumulation -- ✅ You require bounded memory usage for rebalance operations - -**Key Insight**: Both strategies perform identically for typical workloads (burst ≤100). The bounded strategy's dramatic performance advantage (25-196× faster) only appears under **extreme conditions** (1000+ burst with I/O latency), making unbounded the safer default choice. - -**For comprehensive benchmark methodology, performance data, and detailed analysis**, see: -- [ExecutionStrategyBenchmarks Documentation](benchmarks/SlidingWindowCache.Benchmarks/README.md#-execution-strategy-benchmarks) -- [Benchmark Results](benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.ExecutionStrategyBenchmarks-report-github.md) - ---- - -## 📊 Optional Diagnostics - -The cache supports optional diagnostics for monitoring behavior, measuring performance, and validating system -invariants. This is useful for: - -- **Testing and validation**: Verify cache behavior meets expected patterns -- **Performance monitoring**: Track cache hit/miss ratios and rebalance frequency -- **Debugging**: Understand cache lifecycle events in development -- **Production observability**: Optional instrumentation for metrics collection - -### ⚠️ CRITICAL: Exception Handling - -**You MUST handle the `RebalanceExecutionFailed` event in production applications.** - -Rebalance operations run in fire-and-forget background tasks. When exceptions occur, they are silently swallowed to -prevent application crashes. Without proper handling of `RebalanceExecutionFailed`: +## Diagnostics -- ❌ Silent failures in background operations -- ❌ Cache stops rebalancing with no indication -- ❌ Degraded performance with no diagnostics -- ❌ Data source errors go unnoticed - -**Minimum requirement: Log all failures** +⚠️ **CRITICAL: You MUST handle `RebalanceExecutionFailed` in production.** Rebalance operations run in background tasks. Without handling this event, failures are silently swallowed and the cache stops rebalancing with no indication. ```csharp public class LoggingCacheDiagnostics : ICacheDiagnostics { - private readonly ILogger _logger; - - public LoggingCacheDiagnostics(ILogger logger) - { - _logger = logger; - } - + private readonly ILogger _logger; + + public LoggingCacheDiagnostics(ILogger logger) => _logger = logger; + public void RebalanceExecutionFailed(Exception ex) { - // CRITICAL: Always log rebalance failures - _logger.LogError(ex, "Cache rebalance execution failed. Cache may not be optimally sized."); + // CRITICAL: always log rebalance failures + _logger.LogError(ex, "Cache rebalance failed. Cache may not be optimally sized."); } - - // ...implement other methods (can be no-op if you only care about failures)... -} -``` - -For production systems, consider: - -- **Alerting**: Trigger alerts after N consecutive failures -- **Metrics**: Track failure rate and exception types -- **Circuit breaker**: Disable rebalancing after repeated failures -- **Structured logging**: Include cache state and requested range context - -### Using Diagnostics - -```csharp -using SlidingWindowCache.Public.Instrumentation; - -// Create diagnostics instance -var diagnostics = new EventCounterCacheDiagnostics(); - -// Pass to cache constructor -var cache = new WindowCache( - dataSource: myDataSource, - domain: new IntegerFixedStepDomain(), - options: options, - cacheDiagnostics: diagnostics // Optional parameter -); - -// Access diagnostic counters -Console.WriteLine($"Full cache hits: {diagnostics.UserRequestFullCacheHit}"); -Console.WriteLine($"Rebalances completed: {diagnostics.RebalanceExecutionCompleted}"); -``` - -### Zero-Cost Abstraction - -If no diagnostics instance is provided (default), the cache uses `NoOpDiagnostics` - a zero-overhead implementation with -empty method bodies that the JIT compiler can optimize away completely. This ensures diagnostics add zero performance -overhead when not used. - -**For complete metric descriptions, custom implementations, and advanced patterns, -see [Diagnostics Guide](docs/diagnostics.md).** - ---- - -## 📚 Documentation - -### Learning Paths - -**Choose your path based on your needs:** - -#### 🚀 Path 1: Quick Start (Getting Started Fast) - -**Goal**: Get up and running with working code and common patterns. - -1. **[README - Quick Start](#-usage-example)** - Basic usage examples (you're already here!) -2. **[README - Configuration Guide](#-configuration)** - Understand the 5 key parameters -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. - ---- - -#### 🏗️ Path 2: Deep Dive (Advanced Understanding) - -**Goal**: Understand architecture, invariants, and implementation details. - -1. **[Glossary](docs/glossary.md)** - 📖 **Start here** - Canonical term definitions with navigation guide -2. **[Architecture Model](docs/architecture-model.md)** - Core architectural patterns (single-writer, decision-driven execution, smart eventual consistency) -3. **[Invariants](docs/invariants.md)** - 49 system invariants with formal specifications -4. **[Component Map](docs/component-map.md)** - Comprehensive component catalog with invariant implementation mapping -5. **[Scenario Model](docs/scenario-model.md)** - Temporal behavior scenarios (User Path, Decision Path, Execution Path) -6. **[Cache State Machine](docs/cache-state-machine.md)** - Formal state transitions and mutation ownership -7. **[Actors & Responsibilities](docs/actors-and-responsibilities.md)** - Actor model with invariant ownership -8. **[Actors to Components Mapping](docs/actors-to-components-mapping.md)** - Architectural actors → concrete components - -**When to use this path**: Contributing code, debugging complex issues, understanding design decisions, architectural review. - ---- -### Reference Documentation - -#### Mathematical Foundations - -- **[Intervals.NET](https://github.com/blaze6950/Intervals.NET)** - Interval/range library providing `Range`, `Domain`, `RangeData`, and interval operations - -#### Testing & Benchmarking - -- **[Invariant Test Suite](tests/SlidingWindowCache.Invariants.Tests/README.md)** - 27 automated invariant tests validating architectural contracts -- **[Benchmark Suite](benchmarks/SlidingWindowCache.Benchmarks/README.md)** - BenchmarkDotNet performance benchmarks: - - **RebalanceFlowBenchmarks** - Rebalance cost analysis (Fixed/Growing/Shrinking patterns) - - **UserFlowBenchmarks** - User-facing API latency (Hit/Partial/Miss scenarios) - - **ScenarioBenchmarks** - End-to-end cold start performance - - **Storage Comparison** - Snapshot vs CopyOnRead tradeoffs - -#### Testing Infrastructure - -**Deterministic Synchronization**: `WaitForIdleAsync()` provides race-free synchronization with background operations for testing, shutdown, health checks. Uses "was idle at some point" semantics (eventual consistency). See [Invariants - Testing Infrastructure](docs/invariants.md#testing-infrastructure-deterministic-synchronization). - -### Key Architectural Principles - -> **📖 For detailed explanations, see:** [Architecture Model](docs/architecture-model.md) | [Invariants](docs/invariants.md) | [Glossary](docs/glossary.md) - -1. **Single-Writer Architecture**: Only Rebalance Execution writes to cache state; User Path is read-only. Eliminates race conditions through architectural constraints. - -2. **Decision-Driven Execution**: Rebalance necessity determined by analytical validation before execution. Enables work avoidance and prevents thrashing. - -3. **Multi-Stage Validation Pipeline**: Four validation stages must all pass before rebalance executes (NoRebalanceRange check, pending coverage check, desired==current check). See [Scenario Model - Decision Path](docs/scenario-model.md#ii-rebalance-decision-path--decision-scenarios). - -4. **Smart Eventual Consistency**: Cache converges to optimal state asynchronously while avoiding unnecessary operations through validation. - -5. **Intent Semantics**: Intents are signals (observed access patterns), not commands (mandatory work). Validation determines execution necessity. - -6. **Cache Contiguity**: Cache data remains contiguous without gaps. Non-intersecting requests replace cache entirely. - -7. **User Path Priority**: User requests always served immediately. Background rebalancing never blocks user operations. - -8. **Lock-Free Concurrency**: Intent management uses atomic operations (`Volatile`, `Interlocked`). Execution serialization ensures single-writer semantics. - ---- - -## ⚡ Performance Considerations - -- **Snapshot mode**: O(1) reads, but O(n) rebalance with array allocation -- **CopyOnRead mode**: O(n) reads (copy cost), but cheaper rebalance operations -- **Rebalancing is asynchronous**: Does not block user reads -- **Debouncing**: Multiple rapid requests trigger only one rebalance operation -- **Diagnostics overhead**: Zero when not used (NoOpDiagnostics); minimal when enabled (~1-5ns per event) - ---- - -## 🔧 CI/CD & Package Information - -### Continuous Integration - -This project uses GitHub Actions for automated testing and deployment: - -- **Build & Test**: Runs on every push and pull request - - Compiles entire solution in Release configuration - - Executes all test suites (Unit, Integration, Invariants) with code coverage - - Validates WebAssembly compatibility via `net8.0-browser` compilation - - Uploads coverage reports to Codecov - -- **NuGet Publishing**: Automatic on main branch pushes - - Packages library with symbols and source link - - Publishes to NuGet.org with skip-duplicate - - Stores package artifacts in workflow runs - -### WebAssembly Support - -SlidingWindowCache is validated for WebAssembly compatibility: - -- **Target Framework**: `net8.0-browser` compilation validated in CI -- **Validation Project**: `SlidingWindowCache.WasmValidation` ensures all public APIs work in browser environments -- **Compatibility**: All library features available in Blazor WebAssembly and other WASM scenarios - -### NuGet Package - -**Package ID**: `SlidingWindowCache` -**Current Version**: 1.0.0 - -```bash -# Install via .NET CLI -dotnet add package SlidingWindowCache - -# Install via Package Manager -Install-Package SlidingWindowCache + // Other methods can be no-op if you only care about failures +} ``` -**Package Contents**: - -- Main library assembly (`SlidingWindowCache.dll`) -- Debug symbols (`.snupkg` for debugging) -- Source Link (GitHub source integration for "Go to Definition") -- README.md (this file) - -**Dependencies**: +If no diagnostics instance is provided, the cache uses `NoOpDiagnostics` — zero overhead, JIT-optimized away completely. -- Intervals.NET.Data (>= 0.0.1) -- Intervals.NET.Domain.Default (>= 0.0.2) -- Intervals.NET.Domain.Extensions (>= 0.0.3) -- .NET 8.0 or higher +Canonical guide: `docs/diagnostics.md`. ---- +## Performance Considerations -## 🤝 Contributing & Feedback +- Snapshot mode: O(1) reads, O(n) rebalance (array allocation) +- CopyOnRead mode: O(n) reads (copy cost), cheaper rebalance operations +- Rebalancing is asynchronous — does not block user reads +- Debouncing: multiple rapid requests trigger only one rebalance operation +- Diagnostics overhead: zero when not used (NoOpDiagnostics); ~1–5 ns per event when enabled -This project is a **personal R&D and engineering exploration** focused on cache design patterns, concurrent systems -architecture, and performance optimization. While it's primarily a research endeavor, feedback and community input are -highly valued and welcomed. +## Documentation -### We Welcome +### Path 1: Quick Start -- **Bug reports** - Found an issue? Please open a GitHub issue with reproduction steps -- **Feature suggestions** - Have ideas for improvements? Start a discussion or open an issue -- **Performance insights** - Benchmarked the cache in your scenario? Share your findings -- **Architecture feedback** - Thoughts on the design patterns or implementation? Let's discuss -- **Documentation improvements** - Found something unclear? Contributions to docs are appreciated -- **Positive feedback** - If the library is useful to you, that's great to know! +1. `README.md` — you are here +2. `docs/boundary-handling.md` — RangeResult usage, bounded data sources +3. `docs/storage-strategies.md` — choose Snapshot vs CopyOnRead for your use case +4. `docs/glossary.md` — canonical term definitions and common misconceptions +5. `docs/diagnostics.md` — optional instrumentation -### How to Contribute +### Path 2: Architecture Deep Dive -- **Issues**: Use [GitHub Issues](https://github.com/blaze6950/SlidingWindowCache/issues) for bugs, feature requests, or - questions -- **Discussions**: Use [GitHub Discussions](https://github.com/blaze6950/SlidingWindowCache/discussions) for broader - topics, ideas, or design conversations -- **Pull Requests**: Code contributions are welcome, but please open an issue first to discuss significant changes +1. `docs/glossary.md` — start here for canonical terminology +2. `docs/architecture.md` — single-writer, decision-driven execution, disposal +3. `docs/invariants.md` — formal system invariants +4. `docs/components/overview.md` — component catalog with invariant implementation mapping +5. `docs/scenarios.md` — temporal behavior walkthroughs +6. `docs/state-machine.md` — formal state transitions and mutation ownership +7. `docs/actors.md` — actor responsibilities and execution contexts -This project benefits from community feedback while maintaining a focused research direction. All constructive input -helps improve the library's design, implementation, and documentation. +### Deterministic Testing ---- +`WaitForIdleAsync()` provides race-free synchronization with background operations for tests. Uses "was idle at some point" semantics — does not guarantee still idle after completion. See `docs/invariants.md` (Activity tracking invariants). ## License -MIT \ No newline at end of file +MIT diff --git a/SlidingWindowCache.sln b/SlidingWindowCache.sln index a418d9d..89a5d6b 100644 --- a/SlidingWindowCache.sln +++ b/SlidingWindowCache.sln @@ -10,15 +10,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionItems", "SolutionIt EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{B0276F89-7127-4A8C-AD8F-C198780A1E34}" ProjectSection(SolutionItems) = preProject - docs\scenario-model.md = docs\scenario-model.md + docs\scenarios.md = docs\scenarios.md docs\invariants.md = docs\invariants.md - docs\actors-and-responsibilities.md = docs\actors-and-responsibilities.md - docs\cache-state-machine.md = docs\cache-state-machine.md - docs\actors-to-components-mapping.md = docs\actors-to-components-mapping.md - docs\component-map.md = docs\component-map.md + docs\actors.md = docs\actors.md + docs\state-machine.md = docs\state-machine.md + docs\architecture.md = docs\architecture.md + docs\boundary-handling.md = docs\boundary-handling.md docs\storage-strategies.md = docs\storage-strategies.md docs\diagnostics.md = docs\diagnostics.md - docs\architecture-model.md = docs\architecture-model.md docs\glossary.md = docs\glossary.md EndProjectSection EndProject @@ -43,6 +42,19 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlidingWindowCache.Benchmarks", "benchmarks\SlidingWindowCache.Benchmarks\SlidingWindowCache.Benchmarks.csproj", "{8E83B41E-08E9-4AF4-8272-1AB2D2DEDBAB}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "components", "components", "{CE3B07FD-0EC6-4C58-BA45-C23111D5A934}" + ProjectSection(SolutionItems) = preProject + docs\components\decision.md = docs\components\decision.md + docs\components\execution.md = docs\components\execution.md + docs\components\infrastructure.md = docs\components\infrastructure.md + docs\components\intent-management.md = docs\components\intent-management.md + docs\components\overview.md = docs\components\overview.md + docs\components\public-api.md = docs\components\public-api.md + docs\components\rebalance-path.md = docs\components\rebalance-path.md + docs\components\state-and-storage.md = docs\components\state-and-storage.md + docs\components\user-path.md = docs\components\user-path.md + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -88,5 +100,6 @@ Global {C1D2E3F4-A5B6-4C7D-8E9F-0A1B2C3D4E5F} = {8C504091-1383-4EEB-879E-7A3769C3DF13} {9C6688E8-071B-48F5-9B84-4779B58822CC} = {EB667A96-0E73-48B6-ACC8-C99369A59D0D} {8E83B41E-08E9-4AF4-8272-1AB2D2DEDBAB} = {EB0F4813-1FA9-4C40-A975-3B8C6BBFF8D5} + {CE3B07FD-0EC6-4C58-BA45-C23111D5A934} = {B0276F89-7127-4A8C-AD8F-C198780A1E34} EndGlobalSection EndGlobal diff --git a/docs/actors-and-responsibilities.md b/docs/actors-and-responsibilities.md deleted file mode 100644 index 4b26b8b..0000000 --- a/docs/actors-and-responsibilities.md +++ /dev/null @@ -1,308 +0,0 @@ -# Sliding Window Cache — System Actors & Invariant Ownership - -This document maps **system actors** to the invariants they enforce or guarantee. - -> **📖 For detailed architectural explanations, see:** -> - [Architecture Model](architecture-model.md) - Threading model, decision-driven execution, single-writer architecture -> - [Invariants](invariants.md) - Complete invariant specifications -> - [Component Map](component-map.md) - Component relationships and structure - ---- - -## 1. User Path (Fast Path / Read Path Actor) - -**Role:** -Handles user requests with minimal latency and maximal isolation from background processes. - -**Implementation:** -**Internal class:** `UserRequestHandler` (in `UserPath/` namespace) -**Public facade:** `WindowCache` delegates all requests to UserRequestHandler - -**Execution Context:** -**Lives in: User Thread** - -**Critical Contract:** -``` -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. -``` - -**Responsible for invariants:** -- -1. User Path and Rebalance Execution never write to cache concurrently -- 0. User Path has higher priority than rebalance execution -- 0a. User Request MAY cancel any ongoing or pending Rebalance Execution ONLY when a new rebalance is validated as necessary -- 1. User Path always serves user requests -- 2. User Path never waits for rebalance execution -- 3. User Path is the sole source of rebalance intent -- 5. Performs only work necessary to return data -- 6. May synchronously request from IDataSource -- 7. May read cache and source, but does not mutate cache state -- 8. (NEW) MUST NOT mutate cache under any circumstance (read-only) -- 9a. Cache data MUST always remain contiguous (no gaps allowed) -- 10. Always returns exactly RequestedRange -- 24e. Intent MUST contain delivered data (RangeData) -- 24f. Delivered data represents what user actually received - -**Explicit Non-Responsibilities:** -- ❌ **NEVER checks NoRebalanceRange** (belongs to DecisionEngine) -- ❌ **NEVER computes DesiredCacheRange** (belongs to GeometryPolicy) -- ❌ **NEVER decides whether to rebalance** (belongs to DecisionEngine) -- ❌ **NEVER writes to cache** (no Rematerialize calls) -- ❌ **NEVER writes to IsInitialized** -- ❌ **NEVER writes to NoRebalanceRange** - -**Responsibility Type:** ensures and enforces fast, correct user access with strict read-only boundaries - ---- - -## 2. Rebalance Decision Engine (Pure Decision Actor) - -**Role:** -The **sole authority for rebalance necessity determination**. Analyzes the need for rebalance through multi-stage analytical validation without mutating system state. Enables **smart eventual consistency** through work avoidance mechanisms. - -**Execution Context:** -**Lives in: Background Thread** (invoked by `IntentController.ProcessIntentsAsync` in the background intent processing loop) - -**Visibility:** -- **Not visible to external users** -- **Owned and invoked by IntentController** (not by Scheduler) -- Invoked from `IntentController.ProcessIntentsAsync()` (background intent processing loop) -- May execute many times; work avoidance allows skipping scheduling entirely - -**Critical Rule:** -``` -DecisionEngine lives in the background intent processing loop. -DecisionEngine is THE ONLY authority for rebalance necessity determination. -All execution decisions flow from this component's analytical validation. -Decision happens BEFORE execution is scheduled, preventing work buildup. -IntentController OWNS the DecisionEngine instance. -``` - -**Multi-Stage Validation Pipeline (Work Avoidance):** -1. **Stage 1**: Current Cache NoRebalanceRange containment check (fast path work avoidance) -2. **Stage 2**: Pending Desired Cache NoRebalanceRange validation (anti-thrashing — fully implemented) -3. **Stage 3**: Compute DesiredCacheRange from RequestedRange + configuration -4. **Stage 4**: DesiredCacheRange vs CurrentCacheRange equality check (no-op prevention) - -**Enables Smart Eventual Consistency:** -- Prevents thrashing through multi-stage validation -- Reduces redundant I/O via work avoidance (skip unnecessary operations) -- Maintains stability under rapidly changing access patterns -- Ensures convergence to optimal configuration without aggressive over-execution - -**Responsible for invariants:** -- 24. Decision Path is purely analytical (CPU-only, no I/O) -- 25. Never mutates cache state -- 26. No rebalance if inside NoRebalanceRange (Stage 1 validation) -- 27. No rebalance if DesiredCacheRange == CurrentCacheRange (Stage 4 validation) -- 28. Rebalance triggered only if ALL validation stages confirm necessity - -**Responsibility Type:** ensures correctness of rebalance necessity decisions through analytical validation, enabling smart eventual consistency - -**Note:** Not a top-level actor — internal tool of IntentController/Executor pipeline, but THE authority for necessity determination and work avoidance. - ---- - -## 3. Cache Geometry Policy (Configuration & Policy Actor) - -**Role:** -Defines canonical sliding window shape and rules. - -**Implementation:** -This logical actor is internally decomposed into two components for separation of concerns: -- **NoRebalanceRangePlanner** - Computes NoRebalanceRange, checks threshold-based triggering -- **ProportionalRangePlanner** - Computes DesiredCacheRange, plans cache geometry - -**Configuration Validation** (WindowCacheOptions): -- Cache size coefficients ≥ 0 -- Individual thresholds ≥ 0 (when specified) -- **Threshold sum ≤ 1.0** (when both thresholds specified) - prevents overlapping shrinkage zones -- RebalanceQueueCapacity > 0 or null -- All validation occurs at construction time (fail-fast) - -**Execution Context:** -**Lives in: Background Thread** (invoked synchronously by RebalanceDecisionEngine within intent processing loop) - -**Characteristics:** -Pure functions, lightweight structs (value types), CPU-only, side-effect free - -**Responsible for invariants:** -- 29. DesiredCacheRange computed from RequestedRange + config [ProportionalRangePlanner] -- 30. Independent of current cache contents [ProportionalRangePlanner] -- 31. Canonical target cache state [ProportionalRangePlanner] -- 32. Sliding window geometry defined by configuration [Both components] -- 33. NoRebalanceRange derived from current cache range + config [NoRebalanceSatisfactionPolicy + NoRebalanceRangePlanner] -- 35. Threshold sum constraint (leftThreshold + rightThreshold ≤ 1.0) [WindowCacheOptions validation] - -**Responsibility Type:** sets rules and constraints - -**Note:** Internally decomposed into two components that handle different aspects: -- **When to rebalance** (threshold rules) → NoRebalanceRangePlanner -- **What shape to target** (cache geometry) → ProportionalRangePlanner - ---- - -## 4. IntentController (Intent & Concurrency Actor) - -**Role:** -Manages lifecycle of rebalance intents, orchestrates decision pipeline, and coordinates cancellation based on validation results. - -**Implementation:** -This logical actor is internally decomposed into two components for separation of concerns: -- **IntentController** (Intent Controller) - owns DecisionEngine, intent lifecycle, cancellation coordination, decision invocation, background intent processing loop -- **IRebalanceExecutionController** (Execution Controller) - timing, debounce, background execution orchestration (owned by IntentController) - -**Execution Context:** -**Mixed:** -- **User Thread**: PublishIntent() only (atomic ops + signal, fire-and-forget) -- **Background Thread**: Intent processing loop, decision evaluation, cancellation, execution request enqueuing - - -**Ownership Hierarchy:** -``` -IntentController (User Thread for PublishIntent; Background Thread for ProcessIntentsAsync) -├── owns DecisionEngine (invokes in ProcessIntentsAsync loop) -├── owns IRebalanceExecutionController (created in constructor) -│ └── owns RebalanceExecutor (passed to ExecutionController) -└── manages _pendingIntent snapshot (Interlocked.Exchange — latest-wins) -``` - -**Enhanced Role (Decision-Driven Model):** - -Now responsible for: -- **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] -- **Deduplication** via synchronous decision evaluation [IntentController - Background Thread (intent processing loop)] -- **Debouncing** [Execution Controller - Background] -- **Single-flight execution** enforcement [Both components via cancellation] -- **Starting background execution** [Execution Controller] -- **Orchestrating the validation-driven decision pipeline**: [IntentController - Background Thread (intent processing loop), synchronous] - 1. **IntentController.ProcessIntentsAsync()** invokes owned DecisionEngine synchronously (Background Thread) - 2. If ALL validation stages pass → cancel old pending, enqueue new execution request via ExecutionController - 3. If validation rejects → continue loop (work avoidance, no execution) - 4. **ExecutionController.PublishExecutionRequest()** enqueues to channel (processed by separate execution loop) - 5. **Background Task** performs debounce delay + ExecuteAsync (only this part is async) - -**Authority:** *Owns DecisionEngine and invokes it synchronously. Owns time and concurrency, orchestrates validation-driven execution. Does NOT determine rebalance necessity (delegates to owned DecisionEngine).* - -**Key Principle:** Cancellation is mechanical coordination (prevents concurrent executions), NOT a decision mechanism. The **DecisionEngine (owned by IntentController) is THE sole authority** for determining rebalance necessity. IntentController invokes it in the background intent processing loop (`ProcessIntentsAsync`), enabling work avoidance and preventing intent thrashing. This separation enables smart eventual consistency through work avoidance. - -**Responsible for invariants:** -- 17. At most one active rebalance intent -- 18. Older intents may become logically superseded -- 19. Executions can be cancelled based on validation results -- 20. Obsolete intent must not start execution -- 21. At most one rebalance execution active -- 22. Execution reflects latest access pattern and validated necessity -- 23. System eventually stabilizes under load through work avoidance -- 24. Intent does not guarantee execution - execution is opportunistic and validation-driven - -**Responsibility Type:** controls and coordinates intent execution based on validation results - -**Note:** Internally decomposed into IntentController + RebalanceExecutionController, -but externally appears as a single unified actor. - ---- - -## 5. Rebalance Executor (Single-Writer Actor) - -**Role:** -The **ONLY component** that mutates cache state (single-writer architecture). Performs mechanical cache normalization using delivered data from intent as authoritative source. **Intentionally simple**: no analytical decisions, assumes decision layer already validated necessity. - -**Execution Context:** -**Lives in: Background / ThreadPool** - -**Single-Writer Guarantee:** -Rebalance Executor is the ONLY component that mutates: -- Cache data and range (via `Cache.Rematerialize()`) -- `IsInitialized` field -- `NoRebalanceRange` field - -This eliminates race conditions and ensures consistent cache state. - -**Critical Principle:** -Executor is **mechanically simple** with no analytical logic: -- Does NOT validate rebalance necessity (DecisionEngine already validated) -- Does NOT check NoRebalanceRange (validation stage 1 already passed) -- Does NOT compute whether Desired == Current (validation stage 3 already passed) -- Assumes decision pipeline already confirmed necessity -- Performs only: fetch missing data, merge with delivered data, trim to desired range, write atomically - -**Responsible for invariants:** -- A.4. Rebalance is asynchronous relative to User Path -- F.35. MUST support cancellation at all stages -- F.35a. MUST yield to User Path requests immediately upon cancellation -- F.35b. Partially executed or cancelled execution MUST NOT leave cache inconsistent -- F.36. Only path responsible for cache normalization (single-writer architecture) -- F.36a. Mutates cache ONLY for normalization, using delivered data from intent: - - Uses delivered data from intent as authoritative base (not current cache) - - Expanding to DesiredCacheRange by fetching only truly missing ranges - - Trimming excess data outside DesiredCacheRange - - Writing to Cache.Rematerialize() - - Writing to IsInitialized (= true) - - Recomputing NoRebalanceRange -- F.37. May replace / expand / shrink cache to achieve normalization -- F.38. Requests data only for missing subranges (not covered by delivered data) -- F.39. Does not overwrite intersecting data -- F.40. Upon completion: CacheData corresponds to DesiredCacheRange -- F.41. Upon completion: CurrentCacheRange == DesiredCacheRange -- F.42. Upon completion: NoRebalanceRange recomputed - -**Responsibility Type:** executes rebalance and normalizes cache (cancellable, never concurrent with User Path, assumes validated necessity) - ---- - -## 6. Cache State Manager (Consistency & Atomicity Actor) - -**Role:** -Ensures atomicity and internal consistency of cache state, coordinates cancellation between User Path and Rebalance Execution based on validation results. - -**Responsible for invariants:** -- 11. CacheData and CurrentCacheRange are consistent -- 12. Changes applied atomically -- 13. No permanent inconsistent state -- 14. Temporary inefficiencies are acceptable -- 15. Partial / cancelled execution cannot break consistency -- 16. Only latest intent results may be applied -- 0a. Coordinates cancellation: User Request cancels ongoing/pending Rebalance ONLY when validation confirms new rebalance is necessary - -**Responsibility Type:** guarantees state correctness and coordinates single-writer execution - ---- - -## 🧠 Architectural Summary - -- **User Path:** speed and availability -- **Decision Engine:** pure logic -- **IntentController:** temporal correctness and concurrency -- **Executor:** mutation -- **State Manager:** correctness and consistency -- **Geometry Policy:** deterministic cache shape - ---- - -# Sliding Window Cache — Actors vs Scenarios Reference - -This table maps **actors** to the scenarios they participate in and clarifies **read/write responsibilities**. - -| Scenario | User Path | Decision Engine | Geometry Policy | IntentController | Rebalance Executor | Cache State Manager | Notes | -|-----------------------------------------|-------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------|----------------------------|------------------------------------------|----------------------------------------------------------------------------------------|--------------------------------------------------------|------------------------------------| -| **U1 – Cold Cache** | Requests data from IDataSource, returns data to user, publishes rebalance intent | – | Computes DesiredCacheRange | Receives intent | Executes rebalance asynchronously (writes IsInitialized, CurrentCacheRange, CacheData) | Validates atomic update of CacheData/CurrentCacheRange | User served directly | -| **U2 – Full Cache Hit (Exact)** | Reads from cache, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes if rebalance required | Monitors consistency | Minimal I/O | -| **U3 – Full Cache Hit (Shifted)** | Reads subrange from cache, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes if rebalance required | Monitors consistency | Cache hit but shifted range | -| **U4 – Partial Cache Hit** | Reads intersection, requests missing from IDataSource, merges locally, returns data to user, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes merge and normalization | Ensures atomic merge & consistency | Temporary excess data allowed | -| **U5 – Full Cache Miss (Jump)** | Requests full range from IDataSource, returns data to user, publishes rebalance intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes full normalization | Ensures atomic replacement | No cached data usable | -| **D1 – NoRebalanceRange Block** | – | Checks NoRebalanceRange, decides no execution | – | Receives intent (blocked) | – | – | Fast path skip | -| **D2 – Desired == Current** | – | Computes DesiredCacheRange, decides no execution | Computes DesiredCacheRange | Receives intent (no-op) | – | – | No mutation required | -| **D3 – Rebalance Required** | – | Computes DesiredCacheRange, confirms execution | Computes DesiredCacheRange | Issues rebalance intent | Executes rebalance | Ensures consistency | Rebalance triggered asynchronously | -| **R1 – Build from Scratch** | – | – | Defines DesiredCacheRange | Receives intent | Requests full range, replaces cache | Atomic replacement | Cache initialized from empty | -| **R2 – Expand Cache (Partial Overlap)** | – | – | Defines DesiredCacheRange | Receives intent | Requests missing subranges, merges with existing cache | Atomic merge, consistency | Cache partially reused | -| **R3 – Shrink / Normalize** | – | – | Defines DesiredCacheRange | Receives intent | Trims cache to DesiredCacheRange | Atomic trim, consistency | Cache normalized to target | -| **C1 – Rebalance Trigger Pending** | Executes normally | – | – | Debounces old intent, allows only latest | Cancels obsolete | Ensures atomicity | Fast user response guaranteed | -| **C2 – Rebalance Executing** | Executes normally | – | – | Marks latest intent | Cancels or discards obsolete execution | Ensures atomicity | Latest execution wins | -| **C3 – Spike / Multiple Requests** | Executes normally | – | – | Debounces & coordinates intents | Executes only latest rebalance | Ensures atomicity | Single-flight execution enforced | \ No newline at end of file diff --git a/docs/actors-to-components-mapping.md b/docs/actors-to-components-mapping.md deleted file mode 100644 index fab7ed0..0000000 --- a/docs/actors-to-components-mapping.md +++ /dev/null @@ -1,694 +0,0 @@ -# Sliding Window Cache — Actors to Components Mapping - -This document maps the **conceptual system actors** defined by the Scenario Model -to **concrete architectural components** of the Sliding Window Cache library. - -> **📖 For detailed architectural explanations, see:** -> - [Architecture Model](architecture-model.md) - Threading model, execution contexts, coordination mechanisms -> - [Component Map](component-map.md) - Complete component catalog with relationships -> - [Actors and Responsibilities](actors-and-responsibilities.md) - Invariant ownership by actor - -The purpose of this document is: - -- to fix architectural intent -- to clarify responsibility boundaries -- to guide refactoring and further development -- to serve as long-term documentation for contributors and reviewers - -Actors are **stable roles**, not execution paths and not necessarily 1:1 with classes. - ---- - -## High-Level Structure - -### Execution Context Flow - -``` -═══════════════════════════════════════════════════════════ -User Thread -═══════════════════════════════════════════════════════════ - -┌───────────────────────┐ -│ SlidingWindowCache │ ← Public Facade -└───────────┬───────────┘ - │ - ▼ -┌───────────────────────┐ -│ UserRequestHandler │ ← Fast user-facing logic -└───────────┬───────────┘ - │ - │ publish rebalance intent (fire-and-forget) - │ - ▼ - -═══════════════════════════════════════════════════════════ -═══════════════════════════════════════════════════════════ -User Thread (Synchronous - Publish Intent Only) -═══════════════════════════════════════════════════════════ - -┌───────────────────────┐ -│ SlidingWindowCache │ ← Public Facade -└───────────┬───────────┘ - │ - ▼ -┌───────────────────────┐ -│ UserRequestHandler │ ← Fast user-facing logic -└───────────┬───────────┘ - │ - │ publish rebalance intent (synchronous) - │ - ▼ -┌───────────────────────────┐ -│ IntentController │ ← Intent Lifecycle & Orchestration -│ (Rebalance Intent Mgr) │ • publishes intent atomically -│ │ • signals background loop -└───────────┬───────────────┘ • returns immediately (fire-and-forget) - │ - │ atomic publish + semaphore signal (returns to user) - │ - ▼ - RETURN TO USER (User thread ends here) ← 🔄 Background loop picks up intent - │ - ▼ -┌───────────────────────────┐ -│ RebalanceDecisionEngine │ ← Pure Decision Logic (Background Loop!) -│ │ • NoRebalanceRange check -│ + CacheGeometryPolicy │ • DesiredCacheRange computation -└───────────┬───────────────┘ • allow/block execution - │ - │ if validation confirms necessity - │ - ▼ -┌───────────────────────────┐ -│ ScheduleRebalance() │ ← Creates background task (returns synchronously) -└───────────┬───────────────┘ - │ - │ Background scheduling - HERE background starts ⚡→🔄 - │ - ▼ - -═══════════════════════════════════════════════════════════ -Background / ThreadPool (After background scheduling) -═══════════════════════════════════════════════════════════ - - ▼ -┌───────────────────────────┐ -│ Debounce Delay │ ← Wait before execution -└───────────┬───────────────┘ - │ - ▼ -┌───────────────────────────┐ -│ RebalanceExecutor │ ← Mutating Actor (I/O operations) -└───────────┬───────────────┘ - │ - │ atomic mutation - │ - ▼ -┌───────────────────────────┐ -│ CacheState │ ← Consistency (single-writer) -└───────────────────────────┘ -``` - -**Critical:** Everything up to `PublishIntent()` happens **synchronously in the user thread** (atomic intent publish + semaphore signal only). Decision evaluation, scheduling, and all execution happen in background loops. - ---- - -## 1. SlidingWindowCache (Public Facade) - -### Role - -The single public entry point of the library. - -### Implementation - -**Implemented as:** `WindowCache` class - -### Responsibilities - -- Exposes the public API -- Owns configuration and lifecycle -- Wires internal components together (composition root) -- **Delegates all user requests to UserRequestHandler** -- Does **not** implement business logic itself - -### Actor Coverage - -- Acts as a **composition root** and **pure facade** -- Does **not** directly correspond to a scenario actor -- All behavioral logic is delegated to internal actors - -### Architecture Pattern - -WindowCache implements the **Facade Pattern**: -- Public interface: `IWindowCache.GetDataAsync(...)` -- Internal delegation: Forwards all requests to `UserRequestHandler.HandleRequestAsync(...)` -- Composition: Wires together all internal actors (UserRequestHandler, IntentController, DecisionEngine, Executor) - -### Notes - -This component should remain thin. -It delegates all behavioral logic to internal actors. - -**Key architectural principle:** WindowCache is a **pure facade** - it contains no business logic, only composition and delegation. - ---- - -## 2. UserRequestHandler - -*(Fast Path / Read Path Actor)* - -### Mapped Actor - -**User Path (Fast Path / Read Path Actor)** - -### Implementation - -**Implemented as:** internal class `UserRequestHandler` in `UserPath/` namespace - -### Execution Context - -**Lives in: User Thread** - -### Responsibilities - -- Handles user requests synchronously -- Decides how to serve RequestedRange: - - from cache - - from IDataSource - - or mixed -- Updates: - - IsInitialized (set to true after first rebalance execution) - - CacheData / CurrentCacheRange **only to cover RequestedRange** -- Triggers rebalance intent -- Never blocks on rebalance - -### Critical Contract - -``` -Every user access produces a rebalance intent. -The UserRequestHandler NEVER invokes decision logic. -``` - -### Explicit Non-Responsibilities - -- No cache normalization -- No trimming or shrinking -- No rebalance execution -- No concurrency control -- **NEVER checks NoRebalanceRange** (belongs to DecisionEngine) -- **NEVER computes DesiredCacheRange** (belongs to GeometryPolicy) -- **NEVER decides whether to rebalance** (belongs to DecisionEngine) - -### Key Guarantees - -- Always returns exactly RequestedRange -- Always responds, regardless of rebalance state - -### Implementation Note - -Invoked by WindowCache via delegation: -```csharp -// WindowCache.GetDataAsync(...) implementation: -return _userRequestHandler.HandleRequestAsync(requestedRange, cancellationToken); -``` - ---- - -## 3. RebalanceDecisionEngine - -*(Pure Decision Actor)* - -### Mapped Actor - -**Rebalance Decision Engine** - -### Execution Context - -**Lives in: Background Thread (Intent Processing Loop)** (invoked by IntentController.ProcessIntentsAsync) - -**Critical:** Decision evaluation happens ASYNCHRONOUSLY in background intent processing loop after PublishIntent() returns to user. - -### Visibility - -- **Not visible to external users** -- **Owned by IntentController** (composed in constructor) -- Invoked by `IntentController.ProcessIntentsAsync` (background intent processing loop) -- May execute many times; work avoidance allows skipping scheduling entirely - -### Ownership - -**Owned by:** IntentController -**Created by:** IntentController constructor -**Lifecycle:** Same as IntentController (cache lifetime) - -### Critical Rule - -``` -DecisionEngine executes in the background intent processing loop. -DecisionEngine is THE SOLE AUTHORITY for rebalance necessity determination. -Decision happens BEFORE execution is scheduled (prevents work buildup, intent thrashing). -``` - -### Responsibilities - -- **THE sole authority for rebalance necessity determination** (not a helper, but THE decision maker) -- Evaluates whether rebalance is required through multi-stage analytical validation: - - **Stage 1**: NoRebalanceRange containment check (fast path work avoidance) - - **Stage 2**: Pending Desired Cache NoRebalanceRange validation (anti-thrashing — fully implemented) - - **Stage 3**: Compute DesiredCacheRange from RequestedRange + configuration - - **Stage 4**: DesiredCacheRange vs CurrentCacheRange equality check (no-op prevention) -- Produces analytical decision (execute or skip) that drives system behavior -- Enables smart eventual consistency through work avoidance mechanisms -- Rebalance executes ONLY if ALL validation stages confirm necessity (prevents thrashing, redundant I/O, oscillation) - -### Characteristics - -- Pure (CPU-only, no I/O) -- Deterministic -- Side-effect free -- Does not mutate cache state -- Authority for necessity determination (not a mere helper) - -### Notes - -This component should be: - -- easily testable -- fully synchronous -- independent of execution context - -**Critical Distinction:** While this is an internal tool of IntentController/Executor pipeline, -it is **THE sole authority** for determining rebalance necessity. All execution decisions -flow from this component's analytical validation. - ---- - -## 4. CacheGeometryPolicy - -*(Configuration & Policy Actor)* - -### Mapped Actor - -**Cache Geometry Policy** - -### Implementation - -**Implemented as:** Two separate components working together as a unified policy: - -1. **NoRebalanceSatisfactionPolicy** - - `internal readonly struct NoRebalanceSatisfactionPolicy` - - File: `src/SlidingWindowCache/Core/Rebalance/Decision/NoRebalanceSatisfactionPolicy.cs` - - Computes `NoRebalanceRange` - - Checks if rebalance is needed based on threshold rules - -2. **ProportionalRangePlanner** - - `internal readonly struct ProportionalRangePlanner` - - File: `src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs` - - Computes `DesiredCacheRange` - - Plans canonical cache geometry based on proportional expansion - -**Key Principle:** The logical actor (Cache Geometry Policy) is decomposed into -two cooperating components for separation of concerns. Each component handles -one aspect of cache geometry: thresholds (when to rebalance) and planning (what -shape to target). - -**Used by:** RebalanceDecisionEngine composes both components to make rebalance decisions. - -### Execution Context - -**Background Thread (Intent Processing Loop)** (invoked by RebalanceDecisionEngine during intent processing) - -**Characteristics:** -- Pure functions, lightweight structs (value types) -- CPU-only calculations (no I/O) -- Side-effect free -- Inline execution as part of DecisionEngine.Evaluate() call chain - -### Component Responsibilities - -#### NoRebalanceSatisfactionPolicy (Threshold Rules) -- Computes `NoRebalanceRange` from `CurrentCacheRange` + threshold configuration -- Determines if requested range falls outside no-rebalance zone -- Enforces threshold-based rebalance triggering rules -- Configuration: `LeftThreshold`, `RightThreshold` - -#### ProportionalRangePlanner (Shape Planning) -- Computes `DesiredCacheRange` from `RequestedRange` + size configuration -- Defines canonical cache shape by expanding request proportionally -- Independent of current cache contents (pure function of request + config) -- Configuration: `LeftCacheSize`, `RightCacheSize` - -### Responsibilities - -Together, these components: -- Compute `DesiredCacheRange` [ProportionalRangePlanner] -- Compute `NoRebalanceRange` [NoRebalanceSatisfactionPolicy] -- Encapsulate all sliding window rules: - - left/right sizes [ProportionalRangePlanner] - - thresholds [NoRebalanceSatisfactionPolicy] - - expansion rules [ProportionalRangePlanner] - -### Characteristics - -- Stateless (both are readonly structs) -- Fully configuration-driven -- Independent of cache contents -- Pure functions (deterministic, no side effects) - -### Notes - -This actor defines the **canonical shape** of the cache. - -The split into two components reflects separation of concerns: -- **When to rebalance** (threshold-based triggering) → NoRebalanceSatisfactionPolicy -- **What shape to target** (desired cache geometry) → ProportionalRangePlanner - -Similar to RebalanceIntentController, this logical actor is internally decomposed -but externally appears as a unified policy concept. - ---- - -## 5. RebalanceIntentController - -*(Intent & Concurrency Actor)* - -### Mapped Actor - -**IntentController Actor** - -### Implementation - -**Implemented as:** Two internal components working together as a unified actor: - -1. **IntentController** - - `internal sealed class IntentController` - - File: `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - - **Owns DecisionEngine** (composes in constructor) - - **Owns IRebalanceExecutionController** (injected) - - Manages intent lifecycle: `PublishIntent()` (user thread) + `ProcessIntentsAsync()` (background loop) - - Atomically tracks latest intent via `_pendingIntent` field (`Interlocked.Exchange` — latest-wins) - - Signals background loop via `SemaphoreSlim` - -2. **IRebalanceExecutionController** (Execution Controller) - - Interface: `IRebalanceExecutionController` - - Implementations: `TaskBasedRebalanceExecutionController` (default) and `ChannelBasedRebalanceExecutionController` - - **Owned by IntentController** (injected in constructor) - - Handles debounce timing and background execution - - `PublishExecutionRequest()` is `ValueTask` (enqueues or creates execution) - - Ensures single-flight execution via cancellation tokens - -**Key Principle:** IntentController is the owner/orchestrator. It owns both DecisionEngine and the ExecutionController, invokes DecisionEngine in the background intent processing loop, and delegates background execution to ExecutionController. - -### Execution Context - -**Mixed:** -- **User Thread**: `PublishIntent()` only (atomic `Interlocked.Exchange` + semaphore signal, fire-and-forget) -- **Background Loop #1**: `ProcessIntentsAsync()` — reads intent, evaluates decision, schedules execution - -### Enhanced Role (Decision-Driven Model) - -The IntentController actor is responsible for: - -- **Receiving intents** (on every user request) [`IntentController.PublishIntent()` - User Thread, atomic only] -- **Owning and invoking DecisionEngine** [`IntentController` owns; invokes in `ProcessIntentsAsync` background loop] -- **Intent lifecycle management** via `_pendingIntent` field (`Interlocked.Exchange` — latest-wins atomics) -- **Cancellation coordination** based on validation results from owned DecisionEngine [`IntentController` - Background Loop] -- **Work avoidance** through background decision evaluation [`IntentController.ProcessIntentsAsync`] -- **Debouncing** [Execution Controller — Background, after execution request enqueued] -- **Single-flight execution** enforcement [Both components via cancellation + execution serialization] -- **Starting background execution** [Execution Controller — `PublishExecutionRequest()`] -- **Orchestrating the validation-driven decision pipeline**: [`IntentController.ProcessIntentsAsync` - Background Loop] - 1. **`IntentController.PublishIntent()`** atomically replaces `_pendingIntent`, signals semaphore (User Thread — returns immediately) - 2. **`IntentController.ProcessIntentsAsync()`** wakes on semaphore; reads latest intent via `Interlocked.Exchange` - 3. **`RebalanceDecisionEngine.Evaluate()`** performs multi-stage validation (Background Loop, CPU-only) - 4. If validation rejects → record diagnostic, decrement activity counter, continue loop (work avoidance) - 5. If validation confirms → cancel prior execution request, call `ExecutionController.PublishExecutionRequest()` - 6. **ExecutionController** performs debounce delay + `RebalanceExecutor.ExecuteAsync()` (Background) - -**Key Principle:** `IntentController` is the owner/orchestrator. It **owns DecisionEngine** and invokes it **in the background intent processing loop**, enabling work avoidance and preventing intent thrashing. The **DecisionEngine (owned by IntentController) is THE sole authority** for necessity determination. This separation enables **smart eventual consistency**: the system converges to optimal configuration while avoiding unnecessary operations. - -### Component Responsibilities - -#### Intent Controller (IntentController) -- Owns `_pendingIntent` field — updated via `Interlocked.Exchange` for atomic latest-wins semantics -- Provides `PublishIntent()` to receive new intents from User Path (user thread — lightweight signal only) -- Runs `ProcessIntentsAsync()` background loop: waits on semaphore, evaluates decision, schedules execution -- Invalidates previous intent atomically when new intent arrives (Interlocked.Exchange replaces and discards prior) -- Does NOT perform scheduling or timing logic (delegates to ExecutionController) -- Does NOT determine rebalance necessity (DecisionEngine's job) -- **Lock-free implementation** using `Interlocked.Exchange` for safe atomic intent replacement -- **Thread-safe without locks** — no race conditions, no blocking -- Validated by `ConcurrencyStabilityTests` under concurrent load - -#### Execution Controller (IRebalanceExecutionController) -- Receives execution request from Intent Controller -- Performs debounce delay -- Checks execution request validity/cancellation before execution starts -- Orchestrates `RebalanceExecutor.ExecuteAsync()` based on cancellation token -- Ensures only one execution runs at a time (via cancellation of prior request) -- Does NOT own intent identity or versioning -- Does NOT decide whether rebalance is logically required (delegated to DecisionEngine) - -### Key Decision Authority - -- **When to wake and process** [Background semaphore signal from `PublishIntent()`] -- **Whether rebalance is necessary** [DecisionEngine validates through multi-stage pipeline] -- **When to skip execution entirely** [DecisionEngine validation result] - -### Owns - -- Intent versioning [Intent Controller via `_pendingIntent`] -- Cancellation tokens [Execution Controller per execution request] -- Scheduling logic [Execution Controller] -- Pipeline orchestration based on validation results [Both components] - -### Pipeline Orchestration (Validation-Driven Model) - -``` -User Thread -───────────────────────────────────────────────────── -IntentController.PublishIntent() - ├── Interlocked.Exchange(_pendingIntent, intent) ← latest-wins - ├── _activityCounter.IncrementActivity() - └── _intentSignal.Release() ← returns to user - -Background Loop #1 (IntentController.ProcessIntentsAsync) -───────────────────────────────────────────────────── - ├── await _intentSignal.WaitAsync() - ├── intent = Interlocked.Exchange(_pendingIntent, null) - └── RebalanceDecisionEngine.Evaluate(intent, lastExecutionRequest, currentRange) - ├── Stage 1: Current NoRebalanceRange containment → skip if contained - ├── Stage 2: Pending execution NoRebalanceRange → skip if covered - ├── Stage 3: Compute DesiredCacheRange - ├── Stage 4: DesiredCacheRange == CurrentCacheRange → skip if equal - └── Stage 5: ShouldSchedule = true - ↓ - ├── if !ShouldSchedule → continue loop (work avoidance) - └── if ShouldSchedule → ExecutionController.PublishExecutionRequest(...) - -Background Execution (ExecutionController + RebalanceExecutor) -───────────────────────────────────────────────────── - ├── debounce delay - ├── check cancellation - └── RebalanceExecutor.ExecuteAsync(...) - └── atomic cache mutation -``` - -**Benefits:** -- Clear separation: lifecycle vs. execution vs. decision -- User thread returns immediately (atomic signal only) -- Decision authority clearly assigned to DecisionEngine (background loop) -- Executor mechanically simple (assumes validated necessity) -- Single Responsibility Principle maintained -- Cancellation is coordination (prevents concurrent executions), NOT decision mechanism - -### Notes - -This is the **temporal authority** of the system, orchestrating validation-driven execution. - -The internal decomposition is an implementation detail — from an architectural -perspective, this is a single unified actor that coordinates intent lifecycle, -validation pipeline, and execution timing. - ---- - -## 6. RebalanceExecutor - -*(Mutating Actor)* - -### Mapped Actor - -**Rebalance Executor** - -### Responsibilities - -- Executes rebalance when authorized by DecisionEngine validation -- Performs I/O with IDataSource -- Computes missing ranges -- Merges / trims / replaces cache data -- Produces normalized cache state -- **Mechanically simple**: No analytical decisions, assumes DecisionEngine already validated necessity - -### Characteristics - -- Asynchronous -- Cancellable -- Heavyweight (I/O operations) -- **No decision logic**: Does NOT validate rebalance necessity -- **No range checks**: Does NOT check NoRebalanceRange (Stage 1 already passed) -- **No geometry validation**: Does NOT check if Desired == Current (Stage 3 already passed) -- **Assumes validated**: Decision pipeline already confirmed necessity before invocation - -### Constraints - -- Must be overwrite-safe -- Must respect cancellation -- Must never apply obsolete results -- Must maintain atomic cache updates - -### Critical Principle - -Executor is intentionally simple and mechanical: -1. Receive validated DesiredCacheRange from DecisionEngine -2. Use delivered data from intent as authoritative base -3. Fetch missing data for DesiredCacheRange -4. Merge delivered + fetched data -5. Trim to DesiredCacheRange -6. Write atomically via Rematerialize() - -**NO analytical validation** - all decision logic belongs to DecisionEngine. - ---- - -## 7. CacheStateManager - -*(Consistency & Atomicity Actor)* - -### Mapped Actor - -**Cache State Manager** - -### Responsibilities - -- Owns CacheData and CurrentCacheRange -- Applies mutations atomically -- Guards consistency invariants -- Ensures overwrite safety - -### Notes - -This actor may be: - -- a separate component -- or a well-defined internal module - -Its **conceptual separation is mandatory** even if physically co-located. - ---- - -## Architectural Intent Summary - -| Actor | Primary Concern | -|--------------------|-------------------------| -| UserRequestHandler | Speed & availability | -| DecisionEngine | Correctness of decision | -| GeometryPolicy | Deterministic shape | -| IntentController | Time & concurrency | -| RebalanceExecutor | Physical mutation | -| CacheStateManager | Safety & consistency | - ---- - -## Execution Context Model - -### Corrected Mental Model - -``` -User Thread -─────────── -UserRequestHandler - ├── serve request (sync) - └── publish rebalance intent (fire-and-forget) - │ - ▼ -Background / ThreadPool -─────────────────────── -RebalanceIntentController - ├── debounce / cancel obsolete intents - ├── enforce single-flight - └── schedule execution - │ - ▼ -RebalanceDecisionEngine - ├── NoRebalanceRange check - ├── DesiredCacheRange computation - └── no-op or allow execution - │ - ▼ -RebalanceExecutor - └── mutate cache if allowed -``` - -### Key Principle - -🔑 **DecisionEngine executes in the background intent processing loop (`IntentController.ProcessIntentsAsync`), enabling work avoidance and preventing intent thrashing. The user thread returns immediately after `PublishIntent()`.** - -### Actor Execution Contexts - -| Actor | Execution Context | Invoked By | -|------------------------------------------|--------------------------------------------------|-----------------------------------------------| -| UserRequestHandler | User Thread | User (public API) | -| IntentController.PublishIntent | **User Thread (atomic publish only)** | UserRequestHandler | -| IntentController.ProcessIntentsAsync | **Background Loop #1 (intent processing)** | Background task (awaits semaphore) | -| RebalanceDecisionEngine | **Background Loop #1 (intent processing)** | IntentController.ProcessIntentsAsync | -| CacheGeometryPolicy | **Background Loop #1 (intent processing)** | RebalanceDecisionEngine | -| IRebalanceExecutionController | **Background Execution (strategy-specific)** | IntentController.ProcessIntentsAsync | -| TaskBasedRebalanceExecutionController | **Background (ThreadPool task chain)** | Via interface (default strategy) | -| ChannelBasedRebalanceExecutionController | **Background Loop #2 (channel reader)** | Via interface (optional strategy) | -| RebalanceExecutor | **Background Execution (both strategies)** | IRebalanceExecutionController implementations | -| CacheStateManager | Both (User: reads, Background execution: writes) | Both paths (single-writer) | - -**Critical:** User thread ends at `PublishIntent()` return (after atomic operations). Decision evaluation runs in background intent processing loop. Cache mutations run in separate background execution loop. - -### Responsibilities Refixed - -#### UserRequestHandler (Updated Role) - -- ✅ Serves user requests -- ✅ **Always publishes rebalance intent** -- ❌ **Never** checks NoRebalanceRange -- ❌ **Never** computes DesiredCacheRange -- ❌ **Never** decides "to rebalance or not" - -**Contract:** *Every user access produces a rebalance intent.* - -#### RebalanceIntentController (Enhanced Role) - -The IntentController ACTOR (implemented via `IntentController` + `IRebalanceExecutionController`) is the **orchestrator** responsible for: - -- ✅ Receiving intent on **every user request** [`IntentController.PublishIntent()`] -- ✅ Deduplication and debouncing [`IRebalanceExecutionController`] -- ✅ Cancelling obsolete intents [`IntentController` via `Interlocked.Exchange` latest-wins] -- ✅ Single-flight enforcement [Both components via cancellation] -- ✅ **Launching background execution** [`IRebalanceExecutionController.PublishExecutionRequest()`] -- ✅ **Deciding when to start decision logic** [`IntentController.ProcessIntentsAsync` background loop] -- ✅ **Deciding when to skip execution** [DecisionEngine via `IntentController.ProcessIntentsAsync`] -- ⚠️ **Intent does not guarantee execution** — execution is opportunistic - -**Authority:** *Owns time and concurrency.* - -#### RebalanceDecisionEngine (Clarified Role) - -**Not a top-level actor** — internal tool of IntentController/Executor pipeline. - -- ❌ Not visible to User Path -- ✅ Invoked only in background -- ✅ Can execute many times -- ✅ Results may be discarded - -**Contract:** *Given intent + current snapshot, decide if execution is allowed.* - ---- - -This mapping is **normative**. -Future refactoring must preserve these responsibility boundaries. \ No newline at end of file diff --git a/docs/actors.md b/docs/actors.md new file mode 100644 index 0000000..1622623 --- /dev/null +++ b/docs/actors.md @@ -0,0 +1,272 @@ +# Actors + +## Overview + +Actors are stable responsibilities in the system. They are not necessarily 1:1 with classes; classes implement actor responsibilities. + +This document is the canonical merge of the legacy actor mapping docs. It focuses on: + +- responsibility and non-responsibility boundaries +- invariant ownership per actor +- execution context +- concrete components involved + +Formal rules live in `docs/invariants.md`. + +## Execution Contexts + +- User thread: serves `GetDataAsync`. +- Background intent loop: evaluates the latest intent and produces validated execution requests. +- Background execution: debounced, cancellable rebalance work and cache mutation. + +## Actors + +### User Path + +Responsibilities +- Serve user requests immediately. +- Assemble `RequestedRange` from cache and/or `IDataSource`. +- Publish an intent containing delivered data. + +Non-responsibilities +- Does not decide whether to rebalance. +- Does not mutate shared cache state. +- Does not check `NoRebalanceRange` (belongs to Decision Engine). +- Does not compute `DesiredCacheRange` (belongs to Cache Geometry Policy). + +Invariant ownership +- -1. User Path and Rebalance Execution never write to cache concurrently +- 0. User Path has higher priority than rebalance execution +- 0a. User Request MAY cancel any ongoing or pending Rebalance Execution ONLY when a new rebalance is validated as necessary +- 1. User Path always serves user requests +- 2. User Path never waits for rebalance execution +- 3. User Path is the sole source of rebalance intent +- 5. Performs only work necessary to return data +- 6. May synchronously request from IDataSource +- 7. May read cache and source, but does not mutate cache state +- 8. MUST NOT mutate cache under any circumstance (read-only) +- 10. Always returns exactly RequestedRange +- 24e. Intent MUST contain delivered data (RangeData) +- 24f. Delivered data represents what user actually received + +Components +- `WindowCache` (facade / composition root) +- `UserRequestHandler` +- `CacheDataExtensionService` + +--- + +### Cache Geometry Policy + +Responsibilities +- Compute `DesiredCacheRange` from `RequestedRange` + size configuration. +- Compute `NoRebalanceRange` from `CurrentCacheRange` + threshold configuration. +- Encapsulate all sliding window geometry rules (sizes, thresholds). + +Non-responsibilities +- Does not schedule execution. +- Does not mutate cache state. +- Does not perform I/O. + +Invariant ownership +- 29. DesiredCacheRange computed from RequestedRange + config +- 30. Independent of current cache contents +- 31. Canonical target cache state +- 32. Sliding window geometry defined by configuration +- 33. NoRebalanceRange derived from current cache range + config +- 35. Threshold sum constraint (leftThreshold + rightThreshold ≤ 1.0) + +Components +- `ProportionalRangePlanner` — computes `DesiredCacheRange` +- `NoRebalanceSatisfactionPolicy` / `NoRebalanceRangePlanner` — computes `NoRebalanceRange` + +--- + +### Rebalance Decision + +Responsibilities +- Sole authority for rebalance necessity. +- Analytical validation only (CPU-only, deterministic, no side effects). +- Enable smart eventual consistency through multi-stage work avoidance. + +Non-responsibilities +- Does not schedule execution directly. +- Does not mutate cache state. +- Does not call `IDataSource`. + +Invariant ownership +- 24. Decision Path is purely analytical (CPU-only, no I/O) +- 25. Never mutates cache state +- 26. No rebalance if inside NoRebalanceRange (Stage 1 validation) +- 27. No rebalance if DesiredCacheRange == CurrentCacheRange (Stage 4 validation) +- 28. Rebalance triggered only if ALL validation stages confirm necessity + +Components +- `RebalanceDecisionEngine` +- `ProportionalRangePlanner` +- `NoRebalanceSatisfactionPolicy` / `NoRebalanceRangePlanner` + +--- + +### Intent Management + +Responsibilities +- Own intent lifecycle and supersession (latest wins). +- Run the background intent loop and orchestrate decision → cancel → publish execution request. +- Cancellation coordination based on validation results (not a standalone decision mechanism). + +Non-responsibilities +- Does not mutate cache state. +- Does not perform I/O. +- Does not determine rebalance necessity (delegates to Decision Engine). + +Invariant ownership +- 17. At most one active rebalance intent +- 18. Older intents may become logically superseded +- 19. Executions can be cancelled based on validation results +- 20. Obsolete intent must not start execution +- 21. At most one rebalance execution active +- 22. Execution reflects latest access pattern and validated necessity +- 23. System eventually stabilizes under load through work avoidance +- 24. Intent does not guarantee execution — execution is opportunistic and validation-driven + +Components +- `IntentController` +- `IRebalanceExecutionController` implementations + +--- + +### Rebalance Execution Control + +Responsibilities +- Debounce and serialize validated executions. +- Cancel obsolete scheduled/active work so only the latest validated execution wins. + +Non-responsibilities +- Does not decide necessity. +- Does not determine rebalance necessity (DecisionEngine already validated). + +Components +- `IRebalanceExecutionController` implementations + +--- + +### Mutation (Single Writer) + +Responsibilities +- Perform the only mutations of shared cache state. +- Apply cache updates atomically during normalization. +- Mechanically simple: no analytical decisions; assumes decision layer already validated necessity. + +Non-responsibilities +- Does not validate rebalance necessity. +- Does not check `NoRebalanceRange` (Stage 1 already passed). +- Does not check if `DesiredCacheRange == CurrentCacheRange` (Stage 4 already passed). + +Invariant ownership +- A.4. Rebalance is asynchronous relative to User Path +- F.35. MUST support cancellation at all stages +- F.35a. MUST yield to User Path requests immediately upon cancellation +- F.35b. Partially executed or cancelled execution MUST NOT leave cache inconsistent +- F.36. Only path responsible for cache normalization (single-writer architecture) +- F.36a. Mutates cache ONLY for normalization using delivered data from intent +- F.37. May replace / expand / shrink cache to achieve normalization +- F.38. Requests data only for missing subranges (not covered by delivered data) +- F.39. Does not overwrite intersecting data +- F.40. Upon completion: CacheData corresponds to DesiredCacheRange +- F.41. Upon completion: CurrentCacheRange == DesiredCacheRange +- F.42. Upon completion: NoRebalanceRange recomputed + +Components +- `RebalanceExecutor` +- `CacheState` + +--- + +### Cache State Manager + +Responsibilities +- Ensure atomicity and internal consistency of cache state. +- Coordinate single-writer access between User Path (reads) and Rebalance Execution (writes). + +Invariant ownership +- 11. CacheData and CurrentCacheRange are consistent +- 12. Changes applied atomically +- 13. No permanent inconsistent state +- 14. Temporary inefficiencies are acceptable +- 15. Partial / cancelled execution cannot break consistency +- 16. Only latest intent results may be applied + +Components +- `CacheState` + +--- + +### Resource Management + +Responsibilities +- Graceful shutdown and idempotent disposal of background loops/resources. + +Components +- `WindowCache` and owned internals + +--- + +## Actor Execution Contexts + +| Actor | Execution Context | Invoked By | +|--------------------------------------------|--------------------------------------------------|-------------------------------------------------| +| `UserRequestHandler` | User Thread | User (public API) | +| `IntentController.PublishIntent` | User Thread (atomic publish only) | `UserRequestHandler` | +| `IntentController.ProcessIntentsAsync` | Background Loop #1 (intent processing) | Background task (awaits semaphore) | +| `RebalanceDecisionEngine` | Background Loop #1 (intent processing) | `IntentController.ProcessIntentsAsync` | +| `CacheGeometryPolicy` (both components) | Background Loop #1 (intent processing) | `RebalanceDecisionEngine` | +| `IRebalanceExecutionController` | Background Execution (strategy-specific) | `IntentController.ProcessIntentsAsync` | +| `TaskBasedRebalanceExecutionController` | Background (ThreadPool task chain) | Via interface (default strategy) | +| `ChannelBasedRebalanceExecutionController` | Background Loop #2 (channel reader) | Via interface (optional strategy) | +| `RebalanceExecutor` | Background Execution (both strategies) | `IRebalanceExecutionController` implementations | +| `CacheState` | Both (User: reads; Background execution: writes) | Both paths (single-writer) | + +**Critical:** The user thread ends at `PublishIntent()` return (after atomic operations only). Decision evaluation runs in the background intent loop. Cache mutations run in a separate background execution loop. + +--- + +## Actors vs Scenarios Reference + +| Scenario | User Path | Decision Engine | Geometry Policy | Intent Management | Rebalance Executor | Cache State Manager | +|------------------------------------|---------------------------------------------------------------------------------|--------------------------------------------------|----------------------------|---------------------------------|-------------------------------------------------------------------------|----------------------------| +| **U1 – Cold Cache** | Requests from IDataSource, returns data, publishes intent | – | Computes DesiredCacheRange | Receives intent | Executes rebalance (writes IsInitialized, CurrentCacheRange, CacheData) | Validates atomic update | +| **U2 – Full Cache Hit (Exact)** | Reads from cache, publishes intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes if required | Monitors consistency | +| **U3 – Full Cache Hit (Shifted)** | Reads subrange from cache, publishes intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes if required | Monitors consistency | +| **U4 – Partial Cache Hit** | Reads intersection, requests missing from IDataSource, merges, publishes intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes merge and normalization | Ensures atomic merge | +| **U5 – Full Cache Miss (Jump)** | Requests full range from IDataSource, publishes intent | Checks NoRebalanceRange | Computes DesiredCacheRange | Receives intent | Executes full normalization | Ensures atomic replacement | +| **D1 – NoRebalanceRange Block** | – | Checks NoRebalanceRange, decides no execution | – | Receives intent (blocked) | – | – | +| **D2 – Desired == Current** | – | Computes DesiredCacheRange, decides no execution | Computes DesiredCacheRange | Receives intent (no-op) | – | – | +| **D3 – Rebalance Required** | – | Computes DesiredCacheRange, confirms execution | Computes DesiredCacheRange | Issues rebalance request | Executes rebalance | Ensures consistency | +| **R1 – Build from Scratch** | – | – | Defines DesiredCacheRange | Receives intent | Requests full range, replaces cache | Atomic replacement | +| **R2 – Expand Cache** | – | – | Defines DesiredCacheRange | Receives intent | Requests missing subranges, merges | Atomic merge | +| **R3 – Shrink / Normalize** | – | – | Defines DesiredCacheRange | Receives intent | Trims cache to DesiredCacheRange | Atomic trim | +| **C1 – Rebalance Trigger Pending** | Executes normally | – | – | Debounces, allows only latest | Cancels obsolete | Ensures atomicity | +| **C2 – Rebalance Executing** | Executes normally | – | – | Marks latest intent | Cancels or discards obsolete | Ensures atomicity | +| **C3 – Spike / Multiple Requests** | Executes normally | – | – | Debounces & coordinates intents | Executes only latest | Ensures atomicity | + +--- + +## Architectural Summary + +| Actor | Primary Concern | +|--------------------------|-----------------------------------------------| +| User Path | Speed and availability | +| Cache Geometry Policy | Deterministic cache shape | +| Rebalance Decision | Correctness of necessity determination | +| Intent Management | Time, concurrency, and pipeline orchestration | +| Mutation (Single Writer) | Physical cache mutation | +| Cache State Manager | Safety and consistency | +| Resource Management | Lifecycle and cleanup | + +## See Also + +- `docs/architecture.md` +- `docs/scenarios.md` +- `docs/components/overview.md` +- `docs/invariants.md` diff --git a/docs/architecture-model.md b/docs/architecture-model.md deleted file mode 100644 index 54dabf6..0000000 --- a/docs/architecture-model.md +++ /dev/null @@ -1,692 +0,0 @@ -# System Architecture Model - -## What This Document Covers - -This document describes the **complete architectural model** of SlidingWindowCache, including: - -1. **Threading Model** — Single consumer principle, internal concurrency, execution contexts -2. **Single-Writer Architecture** — Read-only User Path, exclusive writer pattern, lock-free coordination -3. **Decision-Driven Execution** — Multi-stage validation pipeline, work avoidance, smart consistency -4. **Resource Management** — Disposal, graceful shutdown, lock-free coordination mechanisms - -**Note**: This document was previously titled "Concurrency Model" but has been renamed to better reflect its broader scope beyond just threading concerns. It covers the fundamental architectural patterns that define how SlidingWindowCache operates. - -**Related Documentation**: -- [invariants.md](invariants.md) — Formal specifications for architectural concepts described here -- [component-map.md](component-map.md) — Implementation details and component structure -- [scenario-model.md](scenario-model.md) — Temporal behavior and execution flows -- [glossary.md](glossary.md) — Canonical term definitions - ---- - -## Core Principle - -This library is built around a **single logical consumer per cache instance** with a **single-writer architecture**. - -A cache instance: -- is designed for **one logical consumer** (one user, one viewport, one coherent access pattern) -- is **logically single-threaded** from the user's perspective (one conceptual access stream) -- **internally supports concurrent threads** (User thread + Intent processing loop + Rebalance execution loop) -- is **designed for concurrent reads** (User Path is read-only, safe for repeated calls) -- enforces **single-writer** for all mutations (Rebalance Execution only) - -**Important Distinction:** -- **User-facing model**: One logical consumer per cache (coherent access pattern from one source) -- **Internal implementation**: Multiple threads operate concurrently within the cache pipeline -- WindowCache **IS thread-safe** for its internal concurrency (user thread + background threads) -- WindowCache is **NOT designed for multiple users sharing one cache instance** (violates coherent access pattern) - -This is an **ideological requirement**, not merely an architectural or technical limitation. - -The architecture of the library reflects and enforces this principle. - ---- - -## Single-Writer Architecture - -### Core Design - -The cache implements a **single-writer** concurrency model: - -- **One Writer:** Rebalance Execution Path exclusively -- **Read-Only User Path:** User Path never mutates cache state -- **Coordination via Cancellation:** Cancellation prevents concurrent executions (mechanical coordination), not duplicate decision-making -- **Rebalance Decision Validation:** Multi-stage analytical pipeline determines rebalance necessity (CPU-only, no I/O) -- **Eventual Consistency:** Cache state converges asynchronously to optimal configuration - -### Write Ownership - -Only `RebalanceExecutor` may write to `CacheState` fields: -- Cache data and range (via `Cache.Rematerialize()` atomic swap) -- `IsInitialized` property (via `internal set` - restricted to rebalance execution) -- `NoRebalanceRange` property (via `internal set` - restricted to rebalance execution) - -All other components have read-only access to cache state (public getters only). - -### Read Safety - -User Path safely reads cache state without locks because: -- **User Path never writes to CacheState** (architectural invariant, no write access) -- **Rebalance Execution is sole writer** (single-writer architecture eliminates write-write races) -- **Cache storage performs atomic updates** via `Rematerialize()` (array/List reference assignment is atomic) -- **Property reads are safe** - reference reads are atomic on all supported platforms -- **Cancellation coordination** - Rebalance Execution checks cancellation before mutations -- **No read-write races** - User Path may read while Rebalance executes, but User Path sees consistent state (old or new, never partial) - -**Key Insight:** Thread-safety is achieved through **architectural constraints** (single-writer) and **coordination** (cancellation), not through locks or volatile keywords on CacheState fields. - -### Execution Serialization - -While the single-writer architecture eliminates write-write races between User Path and Rebalance Execution, multiple rebalance operations can be scheduled concurrently. To guarantee that only one rebalance execution writes to cache state at a time, the system uses two layers of serialization: - -1. **Execution Controller Layer**: Serializes rebalance execution requests using one of two strategies (configured via `WindowCacheOptions.RebalanceQueueCapacity`) -2. **Executor Layer**: `RebalanceExecutor` uses `SemaphoreSlim(1, 1)` for mutual exclusion during cache mutations - -**Execution Controller Strategies:** - -The system supports two strategies for serializing rebalance execution requests: - -| Strategy | Configuration | Mechanism | Backpressure | Use Case | -|--------------------------|--------------------------------|------------------------------------------------------|-----------------------------------------|---------------------------------------------------------------| -| **Task-based** (default) | `rebalanceQueueCapacity: null` | Lock-free task chaining with `ChainExecutionAsync()` | None (completes synchronously) | Recommended for most scenarios - minimal overhead | -| **Channel-based** | `rebalanceQueueCapacity: >= 1` | `System.Threading.Channels` with bounded capacity | Async await on `WriteAsync()` when full | High-frequency scenarios or resource-constrained environments | - -**Task-Based Strategy (Default - Unbounded):** - -```csharp -// Implementation: TaskBasedRebalanceExecutionController -// Serialization: Lock-free task chaining using volatile write (single-writer pattern) -// Backpressure: None - returns ValueTask.CompletedTask immediately -// Overhead: Minimal - single Task reference + volatile write -// Pattern: ChainExecutionAsync(previousTask, request) ensures sequential execution -``` - -- **Single-Writer Pattern**: Lock-free using volatile write (only intent processing loop writes) -- **Execution**: Fire-and-forget (returns `ValueTask.CompletedTask` immediately, executes on ThreadPool) -- **Cancellation**: Previous request cancelled before chaining new execution -- **Task Chaining**: `await previousTask; await ExecuteRequestAsync(request);` ensures serial execution -- **Disposal**: Captures task chain via volatile read and awaits completion for graceful shutdown - -**Channel-Based Strategy (Bounded):** - -```csharp -// Implementation: ChannelBasedRebalanceExecutionController -// Serialization: Bounded channel with single reader/writer -// Backpressure: Async await on WriteAsync() - blocks intent loop when full -// Overhead: Channel infrastructure + background processing loop -// Pattern: await WriteAsync(request) creates proper backpressure -``` - -- **Capacity Control**: Strict limit on pending rebalance operations (bounded channel capacity) -- **Backpressure**: `await WriteAsync()` blocks intent processing loop when channel is full (intentional throttling) -- **Execution**: Background loop processes requests sequentially from channel (one at a time) -- **Cancellation**: Superseded operations cancelled before new ones are enqueued -- **Disposal**: Completes channel writer and awaits loop completion for graceful shutdown - -**Executor Layer (Both Strategies):** - -Regardless of the controller strategy, `RebalanceExecutor.ExecuteAsync()` uses `SemaphoreSlim(1, 1)` for mutual exclusion: - -- **`SemaphoreSlim`**: Ensures only one rebalance execution can proceed through cache mutation at a time -- **Cancellation Token**: Provides early exit signaling - operations can be cancelled while waiting for the semaphore -- **Ordering**: New rebalance scheduled AFTER old one is cancelled, ensuring proper semaphore acquisition order -- **Atomic cancellation**: `Interlocked.Exchange` prevents race where multiple threads call `Cancel()` on same `PendingRebalance` - -**Why Both CTS and SemaphoreSlim:** - -- **CTS**: Lightweight signaling mechanism for cooperative cancellation (intent obsolescence, user cancellation) -- **SemaphoreSlim**: Mutual exclusion for cache writes (prevents concurrent execution) -- Together: CTS signals "don't do this work anymore", semaphore enforces "only one at a time" - -**Design Properties (Both Strategies):** - -- ✅ **WebAssembly compatible** - async, no blocking threads -- ✅ **Zero User Path blocking** - User Path never acquires semaphore, only rebalance execution does -- ✅ **Production-grade** - prevents data corruption from parallel cache writes -- ✅ **Lightweight** - semaphore rarely contended (rebalance is rare operation) -- ✅ **Cancellation-friendly** - `WaitAsync(cancellationToken)` exits cleanly if cancelled -- ✅ **Single-writer guarantee** - Only one rebalance executes at a time (architectural invariant) - -**Acquisition Point:** - -The semaphore is acquired at the start of `RebalanceExecutor.ExecuteAsync()`, before any I/O operations. This prevents queue buildup while allowing cancellation to propagate immediately. If cancelled during wait, the operation exits without acquiring the semaphore. - -**Strategy Selection Guidance:** - -- **Use Task-based (default)** for: - - Normal operation with typical rebalance frequencies - - Maximum performance with minimal overhead - - Fire-and-forget execution model - -- **Use Channel-based (bounded)** for: - - High-frequency rebalance scenarios requiring backpressure - - Memory-constrained environments where queue growth must be limited - - Testing scenarios requiring deterministic queue behavior - -### Rebalance Validation vs Cancellation - -**Key Distinction:** -- **Rebalance Validation** = Decision mechanism (analytical, CPU-only, determines necessity) - **THE authority** -- **Cancellation** = Coordination mechanism (mechanical, prevents concurrent executions) - coordination tool only - -**Decision-Driven Execution Model:** -1. User Path publishes intent with delivered data (signal, not command) -2. **Rebalance Decision Engine validates necessity** via multi-stage analytical pipeline (THE sole authority) -3. **Validation confirms necessity** → pending rebalance cancelled + new execution scheduled (coordination via cancellation) -4. **Validation rejects necessity** → no cancellation, work avoidance (skip entirely: NoRebalanceRange containment, pending coverage, Desired==Current) - -**Smart Eventual Consistency Principle:** - -Cancellation does NOT drive decisions; **validated rebalance necessity drives cancellation**. - -The Decision Engine determines necessity through analytical validation (work avoidance authority). Cancellation is merely the coordination tool that prevents concurrent executions (single-writer enforcement). This separation enables smart eventual consistency: the system converges to optimal configuration while avoiding unnecessary work (thrashing prevention, redundant I/O elimination, oscillation avoidance). - -### Smart Eventual Consistency Model - -Cache state converges to optimal configuration asynchronously through **decision-driven rebalance execution**: - -1. **User Path** returns correct data immediately (from cache or IDataSource) -2. **User Path** publishes intent with delivered data (**synchronously in user thread** — lightweight signal only) -3. **Intent processing loop** (background) wakes on semaphore signal, reads latest intent via `Interlocked.Exchange` -4. **Rebalance Decision Engine** validates rebalance necessity through multi-stage analytical pipeline (**in background intent loop — CPU-only, side-effect free, lightweight**) -5. **Work avoidance**: Rebalance skipped if validation determines it's unnecessary (NoRebalanceRange containment, Desired==Current, pending rebalance coverage) — **all happens in background intent loop before scheduling** -6. **Scheduling**: if execution required, cancels prior execution request and publishes new one (**in background intent loop**) -7. **Background execution** (rebalance loop): debounce delay + actual rebalance I/O operations -8. **Debounce delay** controls convergence timing and prevents thrashing (background) -9. **User correctness** never depends on cache state being up-to-date - -**Key insight:** User always receives correct data, regardless of whether cache has converged yet. - -**"Smart" characteristic:** The system avoids unnecessary work through multi-stage validation rather than blindly executing every intent. This prevents thrashing, reduces redundant I/O, and maintains stability under rapidly changing access patterns while ensuring eventual convergence to optimal configuration. - -**Critical Architectural Detail - Intent Processing is in Background Loop:** - -The decision logic (multi-stage validation) and scheduling execute in a **dedicated background intent processing loop** (`IntentController.ProcessIntentsAsync`), NOT synchronously in the user thread. The user thread only performs a lightweight `Interlocked.Exchange` + semaphore release when publishing an intent, then returns immediately. - -This design is intentional and critical for handling user request bursts: -- ✅ **User thread returns immediately** after publishing intent (signal only) -- ✅ **CPU-only validation** in background loop (math, conditions, no I/O) -- ✅ **Side-effect free** decision — just calculations -- ✅ **Lightweight** — completes in microseconds -- ✅ **Prevents intent thrashing** — validates necessity before scheduling, skips if not needed -- ✅ **Latest-wins** — `Interlocked.Exchange` ensures only the most recent intent is acted upon -- ⚠️ Only actual **I/O operations** (data fetching, cache mutation) happen in the rebalance execution loop - ---- - -## Single Cache Instance = Single Consumer - -A sliding window cache models the behavior of **one observer moving through data**. - -Each cache instance represents: -- one user -- one access trajectory -- one temporal sequence of requests - -Attempting to share a single cache instance across multiple users or threads -violates this fundamental assumption. - -**Note:** The single-consumer constraint exists for coherent access patterns, -not for mutation safety (User Path is read-only, so parallel reads would be safe -from a mutation perspective, but would still violate the single-consumer model). - ---- - -## Why This Is a Requirement (Not a Limitation) - -### 1. Sliding Window Requires a Unified Access Pattern - -The cache continuously adapts its window based on observed access. - -If multiple consumers request unrelated ranges: -- there is no single `DesiredCacheRange` -- the window oscillates or becomes unstable -- cache efficiency collapses - -This is not a concurrency bug — it is a **model mismatch**. - ---- - -### 2. Rebalance Logic Depends on a Single Timeline - -Rebalance behavior relies on: -- ordered intents representing sequential access observations -- multi-stage validation determining rebalance necessity -- cancellation of pending work when validation confirms new rebalance needed -- "latest validated decision wins" semantics -- eventual stabilization through work avoidance (NoRebalanceRange, Desired==Current checks) - -These guarantees require a **single temporal sequence of access events**. - -Multiple consumers introduce conflicting timelines that cannot be meaningfully -merged without fundamentally changing the model. - ---- - -### 3. Architecture Reflects the Ideology - -The system architecture: -- enforces single-thread access -- isolates rebalance logic from user code -- assumes coherent access intent - -These choices do not define the constraint — -they **exist to preserve it**. - ---- - -## How to Use This Library in Multi-User Environments - -### ✅ Correct Approach - -If your system has multiple users or concurrent consumers: - -> **Create one cache instance per user (or per logical consumer).** - -Each cache instance: -- operates independently -- maintains its own sliding window -- runs its own rebalance lifecycle - -This preserves correctness, performance, and predictability. - ---- - -### ❌ Incorrect Approach - -Do **not**: -- share a cache instance across threads -- multiplex multiple users through a single cache -- attempt to synchronize access externally - -External synchronization does not solve the underlying model conflict and will -result in inefficient or unstable behavior. - ---- - -## Deterministic Background Job Synchronization - -### Testing Infrastructure API - -The cache provides a `WaitForIdleAsync()` method for deterministic synchronization with -background rebalance operations. This is **infrastructure/testing API**, not part of normal -usage patterns or domain semantics. - -### Implementation - -**Mechanism**: `AsyncActivityCounter` — TCS-based lock-free idle detection - -`AsyncActivityCounter` tracks all in-flight activity (user requests + background loops). When the counter reaches zero, the current `TaskCompletionSource` is completed, unblocking all waiters: - -``` -WaitForIdleAsync(): - 1. Volatile.Read(_idleTcs) → observe current TCS - 2. await observedTcs.Task → wait for idle signal - 3. (Re-entry prevention handled by TCS completion semantics) -``` - -- Guarantees: System **was idle at some point** when method returns (eventual consistency semantics) -- Safety: Lock-free — uses only `Interlocked` and `Volatile` operations; no deadlocks -- Multiple waiters supported: all await the same TCS -- See "AsyncActivityCounter - Lock-Free Idle Detection" section for full architecture details - -### Use Cases - -- **Test stabilization**: Ensure cache has converged before assertions -- **Integration testing**: Synchronize with background work completion -- **Diagnostic scenarios**: Verify rebalance execution finished - -### Architectural Preservation - -This synchronization mechanism does **not** alter actor responsibilities: - -- `UserRequestHandler` remains sole intent publisher -- `IntentController` remains lifecycle authority for intent cancellation -- `IRebalanceExecutionController` remains execution authority -- `WindowCache` remains pure facade - -Method exists only to expose idle synchronization through public API for testing purposes. - -### Lock-Free Implementation - -The system uses lock-free synchronization throughout: - -**IntentController** - Lock-free intent management: -- **No locks, no `lock` statements, no mutexes** -- `_pendingIntent` field updated via `Interlocked.Exchange` — atomic latest-wins semantics -- Prior intent replaced atomically; no `Volatile.Read/Write` loop needed -- `SemaphoreSlim` used as lightweight signal for background processing loop -- Thread-safe without blocking — guaranteed progress -- Zero contention overhead - -**AsyncActivityCounter** - Lock-free idle detection: -- **Fully lock-free**: Uses only `Interlocked` and `Volatile` operations -- `Interlocked.Increment/Decrement` for atomic counter operations -- `Volatile.Write/Read` for TaskCompletionSource reference with proper memory barriers -- State-based completion primitive (TaskCompletionSource, not event-based like SemaphoreSlim) -- Multiple awaiter support without coordination overhead -- See "AsyncActivityCounter - Lock-Free Idle Detection" section for detailed architecture - -**Safe Visibility Pattern:** -```csharp -// IntentController - Interlocked.Exchange for atomic intent replacement (latest-wins) -var previousIntent = Interlocked.Exchange(ref _pendingIntent, newIntent); -// (previousIntent is superseded; background loop picks up newIntent via another Exchange) - -// AsyncActivityCounter - Volatile + Interlocked for idle detection -var newCount = Interlocked.Increment(ref _activityCount); // Atomic counter -Volatile.Write(ref _idleTcs, newTcs); // Publish TCS with release fence -var tcs = Volatile.Read(ref _idleTcs); // Observe TCS with acquire fence -``` - -**Testing Coverage:** -- Lock-free behavior validated by `ConcurrencyStabilityTests` -- Tested under concurrent load (100+ simultaneous operations) -- No deadlocks, no race conditions, no data corruption observed - -This lightweight synchronization approach using `Volatile` and `Interlocked` operations ensures thread-safety without the overhead and complexity of traditional locking mechanisms. - -### Relation to Concurrency Model - -The `AsyncActivityCounter` idle detection: -- Does not introduce locking or mutual exclusion -- Leverages existing single-writer architecture -- Provides visibility through volatile reads -- Maintains eventual consistency model - -This is synchronization **with** background work, not synchronization **of** concurrent writers. - ---- - -## Disposal and Resource Management - -### Disposal Architecture - -WindowCache implements `IAsyncDisposable` to ensure proper cleanup of background processing resources. The disposal mechanism follows the same concurrency principles as the rest of the system: **lock-free synchronization** with graceful coordination. - -### Disposal State Machine - -Disposal uses a **three-state pattern** with lock-free transitions: - -``` -States: - 0 = Active (accepting operations) - 1 = Disposing (disposal in progress) - 2 = Disposed (cleanup complete) - -Transitions: - 0 → 1: First DisposeAsync() call wins via Interlocked.CompareExchange - 1 → 2: Disposal completes, state updated via Volatile.Write - -Concurrent Calls: - - First call (0→1): Performs actual disposal - - Concurrent calls (1): Spin-wait until state becomes 2 - - Subsequent calls (2): Return immediately (idempotent) -``` - -### Disposal Sequence - -When `DisposeAsync()` is called, cleanup cascades through the ownership hierarchy: - -``` -WindowCache.DisposeAsync() - └─> UserRequestHandler.DisposeAsync() - └─> IntentController.DisposeAsync() - ├─> Cancel intent processing loop (CancellationTokenSource) - ├─> Wait for processing loop to exit (Task.Wait) - ├─> IRebalanceExecutionController.DisposeAsync() - │ ├─> Task-based: Capture task chain (volatile read) + await completion - │ └─> Channel-based: Complete channel writer + await loop completion - └─> Dispose coordination resources (SemaphoreSlim, CancellationTokenSource) -``` - -**Key Properties:** -- **Graceful shutdown**: Background tasks finish current work before exiting -- **No forced termination**: Cancellation signals used, not thread aborts -- **Resource cleanup**: All channels, semaphores, and cancellation tokens disposed -- **Cascading disposal**: Follows ownership hierarchy (parent disposes children) - -### Operation Blocking After Disposal - -All public operations check disposal state using lock-free reads: - -```csharp -public ValueTask> GetDataAsync(...) -{ - // Check disposal state (lock-free) - if (Volatile.Read(ref _disposeState) != 0) - throw new ObjectDisposedException(...); - - // Proceed with operation -} - -public Task WaitForIdleAsync(...) -{ - // Check disposal state (lock-free) - if (Volatile.Read(ref _disposeState) != 0) - throw new ObjectDisposedException(...); - - // Proceed with operation -} -``` - -**Design Properties:** -- ✅ **Lock-free reads**: `Volatile.Read` ensures visibility without locks -- ✅ **Fail-fast**: Operations immediately throw `ObjectDisposedException` -- ✅ **No partial execution**: Disposal check happens before any work -- ✅ **Consistent behavior**: All operations blocked uniformly after disposal - -### Concurrent Disposal Safety - -The three-state disposal pattern handles concurrent disposal attempts safely using `TaskCompletionSource` for async coordination: - -```csharp -public async ValueTask DisposeAsync() -{ - // Atomic transition from active (0) to disposing (1) - var previousState = Interlocked.CompareExchange(ref _disposeState, 1, 0); - - if (previousState == 0) - { - // Winner thread - create TCS and perform disposal - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - Volatile.Write(ref _disposalCompletionSource, tcs); - - try - { - await _userRequestHandler.DisposeAsync(); - tcs.TrySetResult(true); - } - catch (Exception ex) - { - tcs.TrySetException(ex); - throw; - } - finally - { - // Mark disposal complete (transition to state 2) - Volatile.Write(ref _disposeState, 2); - } - } - else if (previousState == 1) - { - // Loser thread - await disposal completion asynchronously - // Brief spin-wait for TCS publication (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 - await tcs.Task.ConfigureAwait(false); - } - // If previousState == 2: already disposed, return immediately -} -``` - -**Coordination Pattern:** -- **Winner thread (0→1)**: Creates `TaskCompletionSource`, performs disposal, signals result/exception -- **Loser threads (state=1)**: Brief spin for TCS publication, then await TCS.Task asynchronously -- **Exception propagation**: All threads observe winner's disposal outcome (success or exception) -- **No CPU burn**: Loser threads await async work instead of spinning (similar to `AsyncActivityCounter` pattern) - -**Guarantees:** -- ✅ **Exactly-once execution**: Only first call performs disposal -- ✅ **Concurrent safety**: Multiple threads can call simultaneously -- ✅ **Async coordination**: Loser threads await without spinning on async work -- ✅ **Exception propagation**: All callers observe disposal failures -- ✅ **Idempotency**: Safe to call multiple times - -**Why TaskCompletionSource?** -- Disposal involves async operations (awaiting UserRequestHandler disposal) -- Spin-waiting would burn CPU while async work completes (potentially seconds) -- TCS allows async coordination without thread-pool starvation -- Consistent with project's lock-free async patterns (see `AsyncActivityCounter`) - - -### Disposal vs Active Operations - -**Race Condition Handling:** - -If `DisposeAsync()` is called while operations are in progress: -1. Disposal marks state as disposing (blocks new operations) -2. Background loops observe cancellation and exit gracefully -3. In-flight operations may complete or throw `ObjectDisposedException` -4. Disposal waits for background loops to exit -5. All resources released after loops exit - -**User Experience:** -- Operations started **before** disposal: May complete successfully or throw `ObjectDisposedException` -- Operations started **after** disposal: Always throw `ObjectDisposedException` -- No undefined behavior or resource corruption - -### Disposal and Single-Writer Architecture - -Disposal respects the single-writer architecture: -- **User Path**: Read-only, disposal just blocks new reads -- **Rebalance Execution**: Single writer, disposal waits for current execution to finish -- **No race conditions**: Disposal does not introduce write-write races -- **Graceful coordination**: Uses same cancellation mechanism as rebalance operations - -### AsyncActivityCounter - Lock-Free Idle Detection - -**Purpose:** -`AsyncActivityCounter` provides lock-free, thread-safe idle state detection for background operations. It tracks active work (intent processing, rebalance execution) and provides an awaitable notification when all work completes. - -**Architecture:** -- **Fully lock-free**: Uses only `Interlocked` and `Volatile` operations -- **State-based semantics**: TaskCompletionSource provides persistent idle state (not event-based) -- **Multiple awaiter support**: All threads awaiting idle state complete when signaled -- **Eventual consistency**: "Was idle at some point" semantics (not "is idle now") - -**Implementation Details:** - -```csharp -// Activity counter - atomic operations via Interlocked -private int _activityCount; - -// TaskCompletionSource - published/observed via Volatile operations -private TaskCompletionSource _idleTcs; -``` - -**Thread-Safety Model:** -- **IncrementActivity()**: `Interlocked.Increment` + `Volatile.Write` on 0→1 transition -- **DecrementActivity()**: `Interlocked.Decrement` + `Volatile.Read` + `TrySetResult` on N→0 transition -- **WaitForIdleAsync()**: `Volatile.Read` snapshot + `Task.WaitAsync()` for cancellation - -**Memory Barriers:** -- `Volatile.Write` (release fence): Publishes fully-constructed TCS on 0→1 transition -- `Volatile.Read` (acquire fence): Observes published TCS on N→0 transition and in WaitForIdleAsync -- Ensures proper happens-before relationship: TCS construction visible before reference read - -**Why TaskCompletionSource (Not SemaphoreSlim):** -| Primitive | Semantics | Idle State Behavior | Correct? | -|----------------------|----------------|----------------------------------------------------|----------| -| TaskCompletionSource | State-based | All awaiters observe persistent idle state | ✅ Yes | -| SemaphoreSlim | Event/token | First awaiter consumes release, others block | ❌ No | - -Idle detection requires state-based semantics: when system becomes idle, ALL current and future awaiters (until next busy period) should complete immediately. TCS provides this; SemaphoreSlim does not. - -**Usage Pattern:** - -```csharp -// Intent processing loop -try -{ - _activityCounter.IncrementActivity(); // Start work - await ProcessIntentAsync(intent); -} -finally -{ - _activityCounter.DecrementActivity(); // End work (even on exception) -} - -// Test or disposal wait for idle -await _activityCounter.WaitForIdleAsync(cancellationToken); // Complete when system idle -``` - -**Idle State Semantics - "Was Idle" NOT "Is Idle":** - -WaitForIdleAsync completes when the system **was idle at some point in time**. It does NOT guarantee the system is still idle after completion. This is correct behavior for eventual consistency models. - -**Example Race (Correct Behavior):** -1. T1 decrements to 0, signals TCS_old (idle state achieved) -2. T2 increments to 1, creates TCS_new (new busy period starts) -3. T3 calls WaitForIdleAsync, reads TCS_old (already completed) -4. Result: WaitForIdleAsync completes immediately even though count=1 - -This is **not a bug** - the system WAS idle between steps 1 and 2. Callers requiring stronger guarantees must implement application-specific logic (e.g., re-check state after await). - -**Call Sites:** -- **IntentController.PublishIntent()**: IncrementActivity when publishing intent -- **IntentController.ProcessIntentsAsync()**: DecrementActivity in finally block after processing -- **Execution controllers**: IncrementActivity on enqueue, DecrementActivity in finally after execution -- **WindowCache.WaitForIdleAsync()**: Exposes idle detection via public API for testing - -**Disposal and AsyncActivityCounter:** - -**Disposal does NOT use AsyncActivityCounter** - it directly waits for background loops to exit via `Task.Wait()` on the loop tasks. This ensures disposal completes even if counter state is inconsistent (e.g., leaked increment without matching decrement). - ---- - -## What Is Supported - -- Single logical consumer per cache instance (coherent access pattern) -- Single-writer architecture (Rebalance Execution only) -- Read-only User Path (safe for repeated calls from same consumer) -- **Internal concurrent threads** (user thread + intent processing loop + rebalance execution loop) -- **Thread-safe internal pipeline** (lock-free synchronization via Volatile/Interlocked) -- Background asynchronous rebalance -- Cancellation and debouncing of rebalance execution -- High-frequency access from one logical consumer -- Eventual consistency model (cache converges asynchronously) -- Intent-based data delivery (delivered data in intent avoids duplicate fetches) -- **Graceful disposal with resource cleanup** (lock-free, idempotent, concurrent-safe) -- **Background task coordination during disposal** (wait for loops to exit gracefully) - ---- - -## What Is Explicitly Not Supported - -- Multiple concurrent consumers per cache instance (multiple users sharing one cache) -- Multiple logical access patterns per cache instance (cross-user sliding window arbitration) -- User threads calling WindowCache methods concurrently from different logical consumers - -**Note:** Internal concurrency (user thread + background threads within single cache) IS supported. -What is NOT supported is multiple users/consumers sharing the same cache instance. - ---- - -## Design Philosophy - -This library prioritizes: -- conceptual clarity -- predictable behavior -- cache efficiency -- correctness of temporal and spatial logic - -Instead of providing superficial thread safety, -it enforces a model that remains stable, explainable, and performant. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..2fb7375 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,348 @@ +# Architecture + +## Overview + +SlidingWindowCache is a range-based cache optimized for sequential access. It serves user requests immediately (User Path) and converges the cache to an optimal window asynchronously (Rebalance Path). + +This document defines the canonical architecture: threading model, single-writer rule, intent model, decision-driven execution, coordination mechanisms, and disposal. + +## Motivation + +Traditional caches optimize for random access. SlidingWindowCache targets workloads where requests move predictably across a domain (e.g., scrolling, playback, time-series inspection). The goal is: + +- Fast reads for the requested range. +- Background window maintenance (prefetch/trim) without blocking the caller. +- Strong architectural constraints that make concurrency correct-by-construction. + +## Design + +### Public API vs Internal Mechanisms + +- Public API (user-facing): `WindowCache` / `IWindowCache`. +- Internal mechanisms: User request handling, intent processing loop, decision engine, execution controller(s), rebalance executor, storage strategy. + +The public API is intentionally small; most complexity is internal and driven by invariants. + +### Threading Model + +The system has three execution contexts: + +1. User Thread (User Path) + - Serves `GetDataAsync` calls. + - Reads cache and/or reads from `IDataSource` to assemble the requested range. + - Publishes an intent (lightweight atomic signal) and returns; it does not wait for rebalancing. + +2. Background Intent Loop (Decision Path) + - Processes the latest published intent ("latest wins"). + - Runs analytical validation (CPU-only) to decide whether rebalance is necessary. + - The user thread ends at `PublishIntent()` return. Decision evaluation happens here. + +3. Background Execution (Execution Path) + - Debounces, fetches missing data, and performs cache normalization. + - This is the only context allowed to mutate shared cache state. + +This library is designed for a single logical consumer per cache instance (one coherent access stream). Multiple threads may call the public API as long as the access pattern is still conceptually one consumer. See "Single Cache Instance = Single Consumer" below. + +### Single-Writer Architecture + +Single-writer is the core simplification: + +- **User Path**: read-only with respect to shared cache state (never mutates `Cache`, `IsInitialized`, or `NoRebalanceRange`). +- **Rebalance Execution**: sole writer of shared cache state. + +**Write Ownership:** Only `RebalanceExecutor` may write to `CacheState` fields: +- Cache data and range (via `Cache.Rematerialize()` atomic swap) +- `IsInitialized` property (via `internal set` — restricted to rebalance execution) +- `NoRebalanceRange` property (via `internal set` — restricted to rebalance execution) + +**Read Safety:** User Path safely reads cache state without locks because: +- User Path never writes to `CacheState` (architectural invariant) +- Rebalance Execution is sole writer (eliminates write-write races) +- `Cache.Rematerialize()` performs atomic reference assignment +- Reference reads are atomic on all supported platforms +- No read-write races: User Path may read while Rebalance executes, but always sees a consistent state (old or new, never partial) + +Thread-safety is achieved through **architectural constraints** (single-writer) and **coordination** (cancellation), not through locks on `CacheState` fields. + +The single-writer rule is formalized in `docs/invariants.md` and prevents write-write races by construction. + +### Execution Serialization + +While the single-writer architecture eliminates write-write races between User Path and Rebalance Execution, multiple rebalance operations can be scheduled concurrently. Two layers enforce that only one rebalance writes at a time: + +1. **Execution Controller Layer**: Serializes rebalance execution requests using one of two strategies (configured via `WindowCacheOptions.RebalanceQueueCapacity`). +2. **Executor Layer**: `RebalanceExecutor` uses `SemaphoreSlim(1, 1)` for mutual exclusion during cache mutations. + +**Execution Controller Strategies:** + +| Strategy | Configuration | Mechanism | Backpressure | Use Case | +|--------------------------|--------------------------------|-------------------------------------|-----------------------------------------|----------------------------------------| +| **Task-based** (default) | `rebalanceQueueCapacity: null` | Lock-free task chaining | None (returns immediately) | Recommended for most scenarios | +| **Channel-based** | `rebalanceQueueCapacity: >= 1` | `System.Threading.Channels` bounded | Async await on `WriteAsync()` when full | High-frequency or resource-constrained | + +**Task-Based Strategy (default):** +- Lock-free using volatile write (single-writer pattern — only intent processing loop writes) +- Fire-and-forget: returns `ValueTask.CompletedTask` immediately, executes on ThreadPool +- Previous request cancelled before chaining new execution +- `await previousTask; await ExecuteRequestAsync(request);` ensures serial execution +- Disposal: captures task chain via volatile read and awaits graceful completion + +**Channel-Based Strategy (bounded):** +- `await WriteAsync()` blocks the intent processing loop when the channel is full (intentional throttling) +- Background loop processes requests sequentially from channel (one at a time) +- Disposal: completes channel writer and awaits loop completion + +**Executor Layer (both strategies):** `RebalanceExecutor.ExecuteAsync()` uses `SemaphoreSlim(1, 1)`: +- Ensures only one rebalance execution can proceed through cache mutation at a time +- Cancellation token provides early exit while waiting for semaphore +- New rebalance scheduled after old one is cancelled (proper acquisition order) + +**Why both CTS and SemaphoreSlim:** +- **CTS**: Lightweight cooperative cancellation signaling (intent obsolescence, user cancellation) +- **SemaphoreSlim**: Mutual exclusion for cache writes (prevents concurrent execution) +- Together: CTS signals "don't do this work anymore"; semaphore enforces "only one at a time" + +**Strategy selection:** +- Use **Task-based** for normal operation, maximum performance, minimal overhead +- Use **Channel-based** for high-frequency rebalance scenarios requiring backpressure, or memory-constrained environments + +### Intent Model (Signals, Not Commands) + +After a user request completes and has "delivered data" (what the caller actually received), the User Path publishes an intent containing the delivered range/data. + +Key properties: + +- Intents represent observed access, not mandatory work. +- A newer intent supersedes an older intent (latest wins). +- Intents exist to inform the decision engine and provide authoritative delivered data for execution. +- Publishing an intent is synchronous in the user thread — atomic `Interlocked.Exchange` + semaphore signal only — then the user thread returns immediately. + +### Decision-Driven Execution + +Rebalance execution is gated by analytical validation. The decision engine runs a multi-stage pipeline and may decide to skip execution entirely. + +**Key distinction:** +- **Rebalance Validation** = Decision mechanism (analytical, CPU-only, determines necessity) — THE authority +- **Cancellation** = Coordination mechanism (mechanical, prevents concurrent executions) — coordination tool only + +Cancellation does NOT drive decisions; validated rebalance necessity drives cancellation. + +This separation matters: +- Decisions are fast, deterministic, and CPU-only. +- Execution is slow(er), may do I/O, and is cancellable. + +The canonical formal definition of the validation pipeline is in `docs/invariants.md` (Decision Path invariants). + +### Smart Eventual Consistency Model + +Cache state converges to optimal configuration asynchronously through decision-driven rebalance execution: + +1. **User Path** returns correct data immediately (from cache or `IDataSource`) +2. **User Path** publishes intent with delivered data (synchronously in user thread — lightweight signal only) +3. **Intent processing loop** (background) wakes on semaphore signal, reads latest intent via `Interlocked.Exchange` +4. **Rebalance Decision Engine** validates rebalance necessity through multi-stage analytical pipeline (background intent loop — CPU-only, side-effect free) +5. **Work avoidance**: Rebalance skipped if validation determines it is unnecessary (NoRebalanceRange containment, Desired==Current, pending rebalance coverage) — all in background intent loop before scheduling +6. **Scheduling**: if execution required, cancels prior execution request and publishes a new one (background intent loop) +7. **Background execution**: debounce delay + actual rebalance I/O operations +8. **Debounce delay** controls convergence timing and prevents thrashing +9. **User correctness** never depends on cache state being up-to-date + +Key insight: User always receives correct data, regardless of whether the cache has converged. + +"Smart" characteristic: The system avoids unnecessary work through multi-stage validation rather than blindly executing every intent. This prevents thrashing, reduces redundant I/O, and maintains stability under rapidly changing access patterns while ensuring eventual convergence to optimal configuration. + +### Coordination Mechanisms (Lock-Free) + +The architecture prioritizes user requests. Coordination uses atomic primitives instead of locks where practical: + +- **Intent publication**: `Interlocked.Exchange` for atomic latest-wins publication; `SemaphoreSlim` to signal background loop +- **Serialization**: at most one rebalance execution active (SemaphoreSlim + CTS) +- **Idle detection**: `AsyncActivityCounter` — fully lock-free, uses only `Interlocked` and `Volatile` operations; supports `WaitForIdleAsync` + +**Safe visibility pattern:** +```csharp +// IntentController — atomic intent replacement (latest-wins) +var previousIntent = Interlocked.Exchange(ref _pendingIntent, newIntent); + +// AsyncActivityCounter — idle detection +var newCount = Interlocked.Increment(ref _activityCount); // Atomic counter +Volatile.Write(ref _idleTcs, newTcs); // Publish TCS with release fence +var tcs = Volatile.Read(ref _idleTcs); // Observe TCS with acquire fence +``` + +See also: `docs/invariants.md` (Activity tracking invariants). + +### AsyncActivityCounter — Lock-Free Idle Detection + +`AsyncActivityCounter` tracks all in-flight activity (user requests + background loops). When the counter reaches zero, the current `TaskCompletionSource` is completed, unblocking all waiters. + +**Architecture:** +- Fully lock-free: `Interlocked` and `Volatile` operations only +- State-based semantics: `TaskCompletionSource` provides persistent idle state (not event-based) +- Multiple awaiter support: all threads awaiting idle state complete when signaled +- Eventual consistency: "was idle at some point" semantics (not "is idle now") + +**Why `TaskCompletionSource`, not `SemaphoreSlim`:** + +| Primitive | Semantics | Idle State Behavior | Correct? | +|---|---|---|---| +| `TaskCompletionSource` | State-based | All awaiters observe persistent idle state | ✅ Yes | +| `SemaphoreSlim` | Event/token | First awaiter consumes release; others block | ❌ No | + +Idle detection requires state-based semantics: when the system becomes idle, ALL current and future awaiters (until the next busy period) should complete immediately. + +**Memory barriers:** +- `Volatile.Write` (release fence): publishes fully-constructed TCS on 0→1 transition +- `Volatile.Read` (acquire fence): observes published TCS on N→0 transition and in `WaitForIdleAsync` + +**"Was idle" semantics — not "is idle":** `WaitForIdleAsync` completes when the system was idle at some point. It does not guarantee the system is still idle after completion. This is correct for eventual consistency models. Callers requiring stronger guarantees must re-check state after await. + +--- + +## Single Cache Instance = Single Consumer + +A sliding window cache models the behavior of **one observer moving through data**. + +Each cache instance represents one user, one access trajectory, one temporal sequence of requests. Attempting to share a single cache instance across multiple users or threads violates this fundamental assumption. + +The single-consumer constraint exists for coherent access patterns, not for mutation safety (User Path is read-only, so parallel reads are safe from a mutation perspective, but still violate the single-consumer model). + +### Why This Is a Requirement + +**1. Sliding Window Requires a Unified Access Pattern** + +The cache continuously adapts its window based on observed access. If multiple consumers request unrelated ranges: +- there is no single `DesiredCacheRange` +- the window oscillates or becomes unstable +- cache efficiency collapses + +This is not a concurrency bug — it is a model mismatch. + +**2. Rebalance Logic Depends on a Single Timeline** + +Rebalance behavior relies on ordered intents representing sequential access observations, multi-stage validation, "latest validated decision wins" semantics, and eventual stabilization through work avoidance. These guarantees require a single temporal sequence of access events. Multiple consumers introduce conflicting timelines that cannot be meaningfully merged. + +**3. Architecture Reflects the Ideology** + +The system architecture enforces single-thread access, isolates rebalance logic from user code, and assumes coherent access intent. These choices exist to preserve the model, not to define the constraint. + +### Multi-User Environments + +**✅ Correct approach:** Create one cache instance per user (or per logical consumer): + +```csharp +// Each consumer gets its own independent cache instance +var userACache = new WindowCache(dataSource, options); +var userBCache = new WindowCache(dataSource, options); +``` + +Each cache instance operates independently, maintains its own sliding window, and runs its own rebalance lifecycle. + +**❌ Incorrect approach:** Do not share a cache instance across threads, multiplex multiple users through a single cache, or attempt to synchronize access externally. External synchronization does not solve the underlying model conflict. + +--- + +## Disposal and Resource Management + +### Disposal Architecture + +`WindowCache` implements `IAsyncDisposable` to ensure proper cleanup of background processing resources. The disposal mechanism follows the same concurrency principles as the rest of the system: lock-free synchronization with graceful coordination. + +### Disposal State Machine + +Disposal uses a three-state pattern with lock-free transitions: + +``` +States: + 0 = Active (accepting operations) + 1 = Disposing (disposal in progress) + 2 = Disposed (cleanup complete) + +Transitions: + 0 → 1: First DisposeAsync() call wins via Interlocked.CompareExchange + 1 → 2: Disposal completes, state updated via Volatile.Write + +Concurrent Calls: + - First call (0→1): Performs actual disposal + - Concurrent (1): Spin-wait until state becomes 2 + - Subsequent (2): Return immediately (idempotent) +``` + +### Disposal Sequence + +When `DisposeAsync()` is called, cleanup cascades through the ownership hierarchy: + +``` +WindowCache.DisposeAsync() + └─> UserRequestHandler.DisposeAsync() + └─> IntentController.DisposeAsync() + ├─> Cancel intent processing loop (CancellationTokenSource) + ├─> Wait for processing loop to exit (Task.Wait) + ├─> IRebalanceExecutionController.DisposeAsync() + │ ├─> Task-based: Capture task chain (volatile read) + await completion + │ └─> Channel-based: Complete channel writer + await loop completion + └─> Dispose coordination resources (SemaphoreSlim, CancellationTokenSource) +``` + +Key properties: +- **Graceful shutdown**: Background tasks finish current work before exiting +- **No forced termination**: Cancellation signals used, not thread aborts +- **Cascading disposal**: Follows ownership hierarchy (parent disposes children) + +### Concurrent Disposal Safety + +The three-state pattern handles concurrent disposal using `TaskCompletionSource` for async coordination: + +- **Winner thread (0→1)**: Creates `TaskCompletionSource`, performs disposal, signals result or exception +- **Loser threads (state=1)**: Brief spin-wait for TCS publication (CPU-only), then `await tcs.Task` asynchronously +- **Exception propagation**: All threads observe the winner's disposal outcome (success or exception) +- **Idempotency**: Safe to call multiple times + +`TaskCompletionSource` is used (rather than spinning) because disposal involves async operations. Spin-waiting would burn CPU while async work completes. TCS allows async coordination without thread-pool starvation, consistent with the project's lock-free async patterns. + +### Operation Blocking After Disposal + +All public operations check disposal state using lock-free reads (`Volatile.Read`) before performing any work, and immediately throw `ObjectDisposedException` if the cache has been disposed. + +### Disposal and Single-Writer Architecture + +Disposal respects the single-writer architecture: +- **User Path**: read-only; disposal just blocks new reads +- **Rebalance Execution**: single writer; disposal waits for current execution to finish gracefully +- No write-write races introduced by disposal +- Uses same cancellation mechanism as rebalance operations + +--- + +## Invariants + +This document explains the model; the formal guarantees live in `docs/invariants.md`. + +Canonical references: + +- Single-writer and user-path priority: `docs/invariants.md` (User Path invariants) +- Intent semantics and temporal rules: `docs/invariants.md` (Intent invariants) +- Decision-driven validation pipeline: `docs/invariants.md` (Decision Path invariants) +- Execution serialization and cancellation: `docs/invariants.md` (Execution invariants) +- Activity tracking and idle detection: `docs/invariants.md` (Activity tracking invariants) + +## Edge Cases + +- Multi-user sharing a single cache instance: not a supported usage model; create one cache per logical consumer. +- Rapid bursty access: intent supersession plus validation plus debouncing avoids work thrash. +- Cancellation: user requests can cause validated cancellation of background execution; cancellation is a coordination mechanism, not a decision mechanism. + +## Limitations + +- Not designed as a general-purpose multi-tenant cache. +- Eventual convergence: the cache may temporarily be non-optimal; it converges asynchronously. +- Some behaviors depend on storage strategy trade-offs; see `docs/storage-strategies.md`. + +## Usage + +For how to use the public API: + +- Start at `README.md`. +- Boundary semantics: `docs/boundary-handling.md`. +- Storage strategy selection: `docs/storage-strategies.md`. +- Diagnostics: `docs/diagnostics.md`. diff --git a/docs/boundary-handling.md b/docs/boundary-handling.md index 6f341ea..e5e8933 100644 --- a/docs/boundary-handling.md +++ b/docs/boundary-handling.md @@ -412,8 +412,8 @@ dotnet test --filter "FullyQualifiedName~RangeResult_WithFullData_ReturnsRangeAn --- **For More Information:** -- [Architecture Model](architecture-model.md) - System design and concurrency model +- [Architecture](architecture.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 +- [Components](components/overview.md) - Internal component overview diff --git a/docs/cache-state-machine.md b/docs/cache-state-machine.md deleted file mode 100644 index f21fd3d..0000000 --- a/docs/cache-state-machine.md +++ /dev/null @@ -1,296 +0,0 @@ -# Sliding Window Cache — Cache State Machine - -This document defines the formal state machine for the Sliding Window Cache, clarifying state transitions, mutation ownership, and concurrency control. - -> **📖 For related architectural concepts, see:** -> - [Architecture Model](architecture-model.md) - Single-writer architecture, coordination mechanisms -> - [Invariants](invariants.md) - State invariants and constraints -> - [Scenario Model](scenario-model.md) - Temporal behavior and user scenarios - ---- - -## States - -The cache exists in one of three states: - -### 1. **Uninitialized** -- **Definition:** Cache has no data and no range defined -- **Characteristics:** - - `CurrentCacheRange == null` - - `CacheData == null` - - `IsInitialized == false` - - `NoRebalanceRange == null` - -### 2. **Initialized** -- **Definition:** Cache contains valid data corresponding to a defined range -- **Characteristics:** - - `CurrentCacheRange != null` - - `CacheData != null` - - `CacheData` is consistent with `CurrentCacheRange` (Invariant 11) - - Cache is contiguous (no gaps, Invariant 9a) - - System is ready to serve user requests - -### 3. **Rebalancing** -- **Definition:** Background normalization is in progress -- **Characteristics:** - - Cache remains in `Initialized` state from external perspective - - User Path continues to serve requests normally - - Rebalance Execution is mutating cache asynchronously - - Rebalance can be cancelled at any time by User Path - ---- - -## State Transitions - -``` -┌─────────────────┐ -│ Uninitialized │ -└────────┬────────┘ - │ - │ U1: First User Request - │ (User Path populates cache) - ▼ -┌─────────────────┐ -│ Initialized │◄──────────┐ -└────────┬────────┘ │ - │ │ - │ Any User Request │ - │ triggers rebalance │ - ▼ │ -┌─────────────────┐ │ -│ Rebalancing │ │ -└────────┬────────┘ │ - │ │ - │ Rebalance │ - │ completes │ - └────────────────────┘ - - (User Request during Rebalancing) - ┌────────────────────┐ - │ Cancel Rebalance │ - │ Return to │ - │ Initialized │ - └────────────────────┘ -``` - ---- - -## Transition Details - -### T1: Uninitialized → Initialized (Cold Start) -- **Trigger:** First user request (Scenario U1) -- **Actor:** Rebalance Execution (NOT User Path) -- **Sequence:** - 1. User Path fetches `RequestedRange` from IDataSource - 2. User Path returns data to user immediately - 3. User Path publishes intent with delivered data - 4. Rebalance Execution writes to cache (first cache write) -- **Mutation:** Performed by Rebalance Execution ONLY (single-writer) - - Set `CacheData` = delivered data from intent - - Set `CurrentCacheRange` = delivered range - - Set `IsInitialized` = true -- **Atomicity:** Changes applied atomically (Invariant 12) -- **Postcondition:** Cache enters `Initialized` state after rebalance execution completes -- **Note:** User Path is read-only; initial cache population is performed by Rebalance Execution - -### T2: Initialized → Rebalancing (Normal Operation) -- **Trigger:** User request (any scenario) -- **Actor:** User Path (reads), Rebalance Executor (writes) -- **Sequence:** - 1. User Path reads from cache or fetches from IDataSource (NO cache mutation) - 2. User Path returns data to user immediately - 3. User Path publishes intent with delivered data - 4. **Decision-driven validation:** Rebalance Decision Engine validates necessity via multi-stage pipeline (THE authority) - 5. **Validation-driven cancellation:** If validation confirms NEW rebalance is necessary, pending rebalance is cancelled and new execution scheduled (coordination mechanism) - 6. **Work avoidance:** If validation rejects (NoRebalanceRange containment, pending coverage, Desired==Current), no cancellation occurs and execution skipped entirely - 7. Rebalance Execution writes to cache (background, only if validated as necessary) -- **Mutation:** Performed by Rebalance Execution ONLY (single-writer architecture) - - User Path does NOT mutate cache, IsInitialized, or NoRebalanceRange (read-only) - - Rebalance Execution normalizes cache to DesiredCacheRange (only if validated) -- **Concurrency:** User Path is read-only; no race conditions -- **Cancellation Model:** Mechanical coordination tool (prevents concurrent executions), NOT decision mechanism; validation determines necessity -- **Postcondition:** Cache logically enters `Rebalancing` state (background process active, only if all validation stages passed) - -### T3: Rebalancing → Initialized (Rebalance Completion) -- **Trigger:** Rebalance execution completes successfully -- **Actor:** Rebalance Executor (sole writer) -- **Mutation:** Performed by Rebalance Execution ONLY - - Use delivered data from intent as authoritative base - - Fetch missing data for `DesiredCacheRange` (only truly missing parts) - - Merge delivered data with fetched data - - Trim to `DesiredCacheRange` (normalization) - - Set `CacheData` and `CurrentCacheRange` via `Rematerialize()` - - Set `IsInitialized` = true - - Recompute `NoRebalanceRange` -- **Atomicity:** Changes applied atomically (Invariant 12) -- **Postcondition:** Cache returns to stable `Initialized` state - -### T4: Rebalancing → Initialized (User Request MAY Cancel Rebalance) -- **Trigger:** User request arrives during rebalance execution (Scenarios C1, C2) -- **Actor:** User Path (publishes intent), Rebalance Decision Engine (validates and determines necessity), Rebalance Execution (yields if cancelled) -- **Sequence:** - 1. User Path reads from cache or fetches from IDataSource (NO cache mutation) - 2. User Path returns data to user immediately - 3. User Path publishes new intent with delivered data - 4. **Decision Engine validates:** Multi-stage analytical pipeline determines if NEW rebalance is necessary (THE authority) - 5. **Validation confirms necessity** → Pending rebalance is cancelled and new execution scheduled (coordination via cancellation token) - 6. **Validation rejects necessity** → Pending rebalance continues undisturbed (no cancellation, work avoidance) - 7. If cancelled: Rebalance yields; new rebalance uses new intent's delivered data (if validated) -- **Critical Principle:** User Path does NOT decide cancellation; Decision Engine validation determines necessity, cancellation is mechanical coordination -- **Priority Model:** User Path priority enforced via validation-driven cancellation, not automatic cancellation on every request -- **Cancellation Semantics:** Mechanical coordination tool (single-writer architecture), NOT decision mechanism; prevents concurrent executions, not duplicate decision-making -- **Note:** "User Request MAY Cancel" = cancellation occurs ONLY when validation confirms new rebalance necessary - ---- - -## Mutation Ownership Matrix - -| State | User Path Mutations | Rebalance Execution Mutations | -|---------------|---------------------|----------------------------------------------------------------------------------------------------------------------| -| Uninitialized | ❌ None | ✅ Initial cache write (after first user request) | -| Initialized | ❌ None | ❌ Not active | -| Rebalancing | ❌ None | ✅ All cache mutations (expand, trim, write to cache/IsInitialized/NoRebalanceRange)
⚠️ MUST yield on cancellation | - -### Mutation Rules Summary - -**User Path mutations (Invariant 8 - NEW):** -- ❌ **NONE** - User Path is read-only with respect to cache state -- User Path NEVER calls `Cache.Rematerialize()` -- User Path NEVER writes to `IsInitialized` -- User Path NEVER writes to `NoRebalanceRange` - -**Rebalance Execution mutations (Invariant 36, 36a):** -1. Uses delivered data from intent as authoritative base -2. Expanding to `DesiredCacheRange` (fetch only truly missing ranges) -3. Trimming excess data outside `DesiredCacheRange` -4. Writing to `Cache.Rematerialize()` (cache data and range) -5. Writing to `IsInitialized` (= true) -6. Recomputing and writing to `NoRebalanceRange` - -**Single-Writer Architecture (Invariant -1):** -- User Path **NEVER** mutates cache (read-only) -- Rebalance Execution is the **SOLE WRITER** of all cache state -- User Path **cancels rebalance** to prevent interference (priority via cancellation) -- Rebalance Execution **MUST yield** immediately on cancellation (Invariant 34a) -- No race conditions possible (single-writer eliminates mutation conflicts) - ---- - -## Concurrency Semantics - -### Cancellation Protocol - -User Path has priority but does NOT mutate cache: - -1. **Pre-operation cancellation:** User Path publishes new intent (atomically supersedes any prior intent); background loop cancels active rebalance execution when it processes the new intent -2. **Read/fetch:** User Path reads from cache or fetches from IDataSource (NO mutation) -3. **Immediate return:** User Path returns data to user (never waits) -4. **Intent publication:** User Path emits intent with delivered data -5. **Rebalance yields:** Background rebalance stops if cancelled -6. **New rebalance:** New intent triggers new rebalance execution with new delivered data - -### Cancellation Guarantees (Invariants 34, 34a, 34b) - -- Rebalance Execution **MUST support cancellation** at all stages -- Rebalance Execution **MUST yield** to User Path immediately -- Cancelled execution **MUST NOT leave cache inconsistent** - -### State Safety - -- **Atomicity:** All cache mutations are atomic (Invariant 12) -- **Consistency:** `CacheData ↔ CurrentCacheRange` always consistent (Invariant 11) -- **Contiguity:** Cache data never contains gaps (Invariant 9a) -- **Idempotence:** Multiple cancellations are safe - ---- - -## State Invariants by State - -### In Uninitialized State: -- ✅ All range and data fields are null -- ✅ User Path is read-only (no mutations) -- ✅ Rebalance Execution is not active (will activate after first user request) - -### In Initialized State: -- ✅ `CacheData ↔ CurrentCacheRange` consistent (Invariant 11) -- ✅ Cache is contiguous (Invariant 9a) -- ✅ User Path is read-only (Invariant 8 - NEW) -- ✅ Rebalance Execution is not active - -### In Rebalancing State: -- ✅ `CacheData ↔ CurrentCacheRange` remain consistent (Invariant 11) -- ✅ Cache is contiguous (Invariant 9a) -- ✅ User Path may cancel but NOT mutate (Invariants 0, 0a) -- ✅ Rebalance Execution is active and sole writer (Invariant 36) -- ✅ Rebalance Execution is cancellable (Invariant 34) -- ✅ **Single-writer architecture** (no race conditions) - ---- - -## Examples - -### Example 1: Cold Start → Initialized -``` -State: Uninitialized -User requests [100, 200] -→ User Path fetches [100, 200] from IDataSource -→ User Path returns data to user immediately -→ User Path publishes intent with delivered data -→ Rebalance Execution writes to cache (first cache write) -→ Sets CacheData, CurrentCacheRange, IsInitialized -→ Triggers rebalance (fire-and-forget) -State: Initialized -``` - -### Example 2: Expansion During Rebalancing -``` -State: Initialized -CurrentCacheRange = [100, 200] - -User requests [150, 250] -→ User Path reads [150, 200] from cache, fetches [200, 250] from IDataSource -→ User Path returns assembled data to user -→ User Path publishes intent with delivered data [150, 250] -→ Triggers rebalance R1 for DesiredCacheRange = [50, 300] -State: Rebalancing (R1 executing in background) - -User requests [200, 300] (before R1 completes) -→ CANCELS R1 (Invariant 0a - User Path priority) -→ User Path reads/fetches data (NO cache mutation) -→ User Path returns data [200, 300] to user -→ User Path publishes new intent with delivered data [200, 300] -→ Triggers rebalance R2 for new DesiredCacheRange -State: Rebalancing (R2 executing) -``` - -### Example 3: Full Cache Miss During Rebalancing -``` -State: Rebalancing -CurrentCacheRange = [100, 200] -Rebalance R1 executing for DesiredCacheRange = [50, 250] - -User requests [500, 600] (no intersection) -→ CANCELS R1 (Invariant 0a - User Path priority) -→ User Path fetches [500, 600] from IDataSource (cache miss) -→ User Path returns data to user -→ User Path publishes intent with delivered data [500, 600] -→ Triggers rebalance R2 for new DesiredCacheRange = [450, 650] -State: Rebalancing (R2 executing - will eventually replace cache) -``` - ---- - -## Architectural Summary - -This state machine enforces three critical architectural constraints: - -1. **Single-Writer Architecture:** Only Rebalance Execution mutates cache state (Invariant 36) -2. **User Path Read-Only:** User Path never mutates cache, IsInitialized, or NoRebalanceRange (Invariant 8) -3. **User Priority via Cancellation:** User requests cancel rebalance to prevent interference, not for mutation exclusion (Invariants 0, 0a) - -The state machine guarantees: -- Fast, non-blocking user access (Invariants 1, 2) -- Eventual convergence to optimal cache shape (Invariant 23) -- Atomic, consistent cache state (Invariants 11, 12) -- No race conditions (single-writer eliminates mutation conflicts) -- Safe cancellation at any time (Invariants 34, 34a, 34b) \ No newline at end of file diff --git a/docs/component-map.md b/docs/component-map.md deleted file mode 100644 index 9695f57..0000000 --- a/docs/component-map.md +++ /dev/null @@ -1,2390 +0,0 @@ -# Sliding Window Cache - Complete Component Map - -> **📖 Cross-References:** -> - For terminology definitions, see: [Glossary](glossary.md) -> - For architectural overview, see: [Architecture Model](architecture-model.md) -> - For detailed implementation mechanics, see: **Source code XML documentation** (each component has extensive inline docs) - -## Document Purpose - -This document provides a comprehensive catalog of all components in the Sliding Window Cache, focusing on: -- Component types and relationships -- Ownership hierarchy -- Read/write patterns -- Thread safety model - -**Note:** Detailed implementation mechanics (method-level behavior, algorithms, memory model details) are documented in source code XML comments. This document focuses on architecture and component interactions. - -**Last Updated**: February 16, 2026 - ---- - -## Table of Contents - -1. [Component Statistics](#component-statistics) -2. [Component Type Legend](#component-type-legend) -3. [Component Hierarchy](#component-hierarchy) -4. [Invariant Implementation Mapping](#invariant-implementation-mapping) -5. [Detailed Component Catalog](#detailed-component-catalog) -6. [Ownership & Data Flow Diagram](#ownership--data-flow-diagram) -7. [Read/Write Patterns](#readwrite-patterns) -8. [Thread Safety Model](#thread-safety-model) -9. [Type Summary Tables](#type-summary-tables) - ---- - -## Component Statistics - -**Total Components**: 22 files in the codebase - -**By Type**: -- 🟦 **Classes (Reference Types)**: 12 -- 🟩 **Structs (Value Types)**: 4 -- 🟧 **Interfaces**: 3 -- 🟪 **Enums**: 1 -- 🟨 **Records**: 3 - -**By Mutability**: -- **Immutable**: 12 components -- **Mutable**: 5 components (CacheState, IntentController._pendingIntent, Storage implementations) - -**By Execution Context**: -- **User Thread**: 2 (UserRequestHandler, IntentController.PublishIntent) -- **Background / ThreadPool**: 4 (IntentController.ProcessIntentsAsync loop, DecisionEngine, IRebalanceExecutionController, Executor) -- **Both Contexts**: 1 (CacheDataExtensionService) -- **Neutral**: 13 (configuration, data structures, interfaces) - -**Shared Mutable State**: -- **CacheState** (shared by UserRequestHandler, RebalanceExecutor, DecisionEngine) -- No other shared mutable state - -**External Dependencies**: -- **IDataSource** (user-provided implementation) -- **TDomain** (from Intervals.NET library) - ---- - -## Component Type Legend - -- **🟦 CLASS** = Reference type (heap-allocated, passed by reference) -- **🟩 STRUCT** = Value type (stack-allocated or inline, passed by value) -- **🟧 INTERFACE** = Contract definition -- **🟪 ENUM** = Value type enumeration -- **🟨 RECORD** = Reference type with value semantics - -**Ownership Arrows**: -- `owns →` = Component owns/contains the other -- `reads ⊳` = Component reads from the other -- `writes ⊲` = Component writes to the other -- `uses ◇` = Component uses/depends on the other - -**Mutability Indicators**: -- ✏️ = Mutable field/property -- 🔒 = Readonly/immutable -- ⚠️ = Mutable shared state (requires coordination) - ---- - -## Component Hierarchy - -### Public API Layer - -``` -🟦 WindowCache [Public Facade] -│ -├── owns → 🟦 UserRequestHandler -│ -└── composes (at construction): - ├── 🟦 CacheState ⚠️ Shared Mutable - ├── 🟦 IntentController - │ └── uses → 🟧 IRebalanceExecutionController - │ ├── implements → 🟦 TaskBasedRebalanceExecutionController (default) - │ └── implements → 🟦 ChannelBasedRebalanceExecutionController (optional) - ├── 🟦 RebalanceDecisionEngine - │ ├── owns → 🟩 NoRebalanceSatisfactionPolicy - │ └── owns → 🟩 ProportionalRangePlanner - ├── 🟦 RebalanceExecutor - └── 🟦 CacheDataExtensionService - └── uses → 🟧 IDataSource (user-provided) -``` - ---- - -## Rebalance Decision Model & Validation Pipeline - -### Core Conceptual Framework - -The system uses a **multi-stage rebalance decision pipeline**, not a cancellation policy. This section clarifies the conceptual model that drives the architecture. - -#### Key Distinctions - -**Rebalance Validation vs Cancellation:** -- **Rebalance Validation** = Analytical decision mechanism (determines necessity) -- **Cancellation** = Mechanical coordination tool (prevents concurrent executions) -- Cancellation is NOT a decision mechanism; it ensures single-writer architecture - -**Intent Semantics:** -- Intent = Access signal ("user accessed this range"), NOT command ("must rebalance") -- Publishing intent does NOT guarantee execution (opportunistic behavior) -- Execution determined by multi-stage validation, not intent existence - -### Multi-Stage Validation Pipeline - -**Authority**: `RebalanceDecisionEngine` is the sole authority for rebalance necessity determination. - -**Pipeline Stages** (all must pass for execution): - -1. **Stage 1: Current Cache NoRebalanceRange Validation** - - Component: `NoRebalanceSatisfactionPolicy.ShouldRebalance()` - - Check: Is RequestedRange contained in NoRebalanceRange(CurrentCacheRange)? - - Purpose: Fast-path rejection if current cache provides sufficient buffer - - Result: Skip if contained (no I/O needed) - -2. **Stage 2: Pending Desired Cache NoRebalanceRange Validation** (anti-thrashing) - - Conceptual: Check if pending rebalance will satisfy request - - Check: Is RequestedRange contained in NoRebalanceRange(PendingDesiredCacheRange)? - - Purpose: Prevent oscillating cache geometry (thrashing) - - Result: Skip if pending rebalance covers request - - Note: May be implemented via cancellation timing optimization - -3. **Stage 3: DesiredCacheRange Computation** - - Component: `ProportionalRangePlanner.Plan()` + `NoRebalanceRangePlanner.Plan()` - - Computes DesiredCacheRange and DesiredNoRebalanceRange from RequestedRange + config - - Purpose: Determine the target cache geometry - -4. **Stage 4: DesiredCacheRange vs CurrentCacheRange Equality** - - Component: `RebalanceDecisionEngine.Evaluate` (pre-scheduling analytical check) - - Check: Does computed DesiredCacheRange == CurrentCacheRange? - - Purpose: Avoid no-op mutations - - Result: Skip scheduling if cache already in optimal configuration - -5. **Stage 5: Schedule Execution** - - All previous stages passed — return execute decision with desired ranges - - Result: IntentController cancels previous pending execution and enqueues new one - -**Execution Rule**: Rebalance executes ONLY if ALL stages confirm necessity. - -### Component Responsibilities in Decision Model - -| Component | Role | Decision Authority | -|-----------------------------------|-----------------------------------------------------------|-------------------------| -| **UserRequestHandler** | Read-only; publishes intents with delivered data | No decision authority | -| **IntentController** | Manages intent lifecycle; runs background processing loop | No decision authority | -| **IRebalanceExecutionController** | Debounce + execution serialization | No decision authority | -| **RebalanceDecisionEngine** | **SOLE AUTHORITY** for necessity determination | **Yes - THE authority** | -| **NoRebalanceSatisfactionPolicy** | Stage 1 & 2 validation (NoRebalanceRange check) | Analytical input | -| **ProportionalRangePlanner** | Computes desired cache geometry | Analytical input | -| **RebalanceExecutor** | Mechanical execution; assumes validated necessity | No decision authority | - -### System Stability Principle - -The system prioritizes **decision correctness and work avoidance** over aggressive rebalance responsiveness, enabling **smart eventual consistency**. - -**Work Avoidance Mechanisms:** -- Stage 1: Avoid rebalance if current cache sufficient (NoRebalanceRange containment) -- Stage 2: Avoid redundant rebalance if pending execution covers request (anti-thrashing) -- Stage 4: Avoid no-op mutations if cache already optimal (Desired==Current) - -**Smart Eventual Consistency:** - -The cache converges to optimal configuration asynchronously through decision-driven execution: -- User always receives correct data immediately (from cache or IDataSource) -- Decision Engine validates necessity through multi-stage pipeline (THE authority) -- Work avoidance prevents unnecessary operations (thrashing, redundant I/O, oscillation) -- Cache state updates occur in background ONLY when validated as necessary -- System remains stable under rapidly changing access patterns - -**Trade-offs:** -- ✅ Prevents thrashing and oscillation (stability over aggressive responsiveness) -- ✅ Reduces redundant I/O operations (efficiency through validation) -- ✅ Improves system stability under rapid access pattern changes (work avoidance) -- ⚠️ May delay cache optimization by debounce period (acceptable for stability gains) - -**Related Documentation:** -- See [Architecture Model - Smart Eventual Consistency](architecture-model.md#smart-eventual-consistency-model) for detailed consistency semantics -- See [Invariants - Section D](invariants.md#d-rebalance-decision-path-invariants) for multi-stage validation pipeline specification - ---- - -## Invariant Implementation Mapping - -This section bridges architectural invariants (documented in [invariants.md](invariants.md)) to their concrete implementations in the codebase. Each invariant is enforced through specific component interactions, code patterns, or architectural constraints. - -**Purpose**: Provides implementation context for architectural invariants without duplicating specification details. See source code XML documentation for detailed implementation mechanics. - -### Single-Writer Architecture - -**Invariants**: A.-1, A.7, A.8, A.9, F.36 - -**Enforcement Mechanism**: -- **Component Design**: Only `RebalanceExecutor` has write access to `CacheState` internal setters -- **Access Control**: User Path components (UserRequestHandler) have read-only references to state -- **Type System**: Internal visibility modifiers prevent external mutations - -**Source References**: -- `src/SlidingWindowCache/Core/State/CacheState.cs` - Internal setters restrict write access -- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` - Exclusive mutation authority -- `src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs` - Read-only access pattern - -### Priority and Cancellation - -**Invariants**: A.0, A.0a, C.19, F.35a - -**Enforcement Mechanism**: -- **Cancellation Protocol**: CancellationTokenSource coordination between intent publishing and execution -- **Decision-Driven Cancellation**: RebalanceDecisionEngine validates necessity before triggering cancellation -- **Cooperative Cancellation**: Multiple checkpoints in execution pipeline check for cancellation - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Cancellation token lifecycle management -- `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` - Multi-stage validation gates cancellation -- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` - Cancellation checkpoints (ThrowIfCancellationRequested) - -### Intent Management and Cancellation - -**Invariants**: A.0a, C.17, C.20, C.21 - -**Enforcement Mechanism**: -- **Latest-Wins Semantics**: Interlocked.Exchange replaces previous intent atomically -- **Intent Singularity**: Single-writer architecture for intent state (IntentController) -- **Early Exit Validation**: Cancellation checked after debounce delay before execution starts - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Atomic intent replacement via Interlocked.Exchange -- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Intent processing loop with early exit on cancellation - -### UserRequestHandler Responsibilities - -**Invariants**: A.3, A.5 - -**Enforcement Mechanism**: -- **Encapsulation**: Only UserRequestHandler has access to IntentController.PublishIntent interface -- **Minimal Work Pattern**: UserRequestHandler scope limited to data assembly, no normalization logic - -**Source References**: -- `src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs` - Exclusive intent publisher, minimal work implementation -- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Intent publication interface (internal visibility) - -### Async Execution Model - -**Invariants**: A.4, G.44 - -**Enforcement Mechanism**: -- **Fire-and-Forget Pattern**: UserRequestHandler publishes intent and returns immediately -- **Background Task Scheduling**: IRebalanceExecutionController schedules execution via Task.Run or channels -- **Thread Context Separation**: User thread vs ThreadPool thread isolation - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - ProcessIntentsAsync loop runs on background thread -- `src/SlidingWindowCache/Infrastructure/Execution/TaskBasedRebalanceExecutionController.cs` - Task.Run scheduling -- `src/SlidingWindowCache/Infrastructure/Execution/ChannelBasedRebalanceExecutionController.cs` - Channel-based background execution - -### Atomic Cache Updates - -**Invariants**: B.12, B.13 - -**Enforcement Mechanism**: -- **Staging Buffer Pattern**: Storage strategies build new state before atomic swap -- **Volatile.Write**: Atomic publication of new cache state reference -- **All-or-Nothing Updates**: Rematerialize operation succeeds completely or not at all - -**Source References**: -- `src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs` - Array.Copy + Volatile.Write for atomic swap -- `src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs` - List replacement + Volatile.Write -- `src/SlidingWindowCache/Core/State/CacheState.cs` - Rematerialize method ensures atomicity - -### Consistency Under Cancellation - -**Invariants**: B.13, B.15, F.35b - -**Enforcement Mechanism**: -- **Cancellation Before Mutation**: Final cancellation check before applying cache updates -- **Atomic Application**: Results applied atomically or discarded entirely -- **Exception Safety**: Try-finally blocks ensure cleanup on cancellation - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` - ThrowIfCancellationRequested before Rematerialize call - -### Obsolete Result Prevention - -**Invariants**: B.16, C.20 - -**Enforcement Mechanism**: -- **Cancellation Token Identity Tracking**: Each intent has unique CancellationToken -- **Pre-Application Validation**: Execution checks if cancellation requested before applying results -- **Latest-Wins Semantics**: Only results from latest non-cancelled intent are applied - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` - Cancellation validation before cache mutation -- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Token lifecycle management - -### Intent Singularity - -**Invariant**: C.17 - -**Enforcement Mechanism**: -- **Atomic Replacement**: Interlocked.Exchange ensures exactly one active intent -- **Supersession Pattern**: New intent atomically replaces previous one -- **No Queue Buildup**: At most one pending intent at any time - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Interlocked.Exchange for atomic intent replacement - -### Cancellation Protocol - -**Invariant**: C.19 - -**Enforcement Mechanism**: -- **Cooperative Cancellation**: CancellationToken passed through entire pipeline -- **Multiple Checkpoints**: Checks before I/O, after I/O, before mutations -- **Result Discard**: Results from cancelled operations never applied - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` - Multiple ThrowIfCancellationRequested calls -- `src/SlidingWindowCache/Infrastructure/Services/CacheDataExtensionService.cs` - Cancellation token propagation to IDataSource - -### Early Exit Validation - -**Invariants**: C.20, D.29 - -**Enforcement Mechanism**: -- **Post-Debounce Check**: Cancellation verified after debounce delay, before execution -- **Multi-Stage Pipeline**: Each validation stage can exit early without execution -- **Decision Engine Authority**: All stages must pass for execution to proceed - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Cancellation check in ProcessIntentsAsync after debounce -- `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` - Multi-stage early exit logic - -### Serial Execution Guarantee - -**Invariant**: C.21 - -**Enforcement Mechanism**: -- **Cancellation Coordination**: Previous execution cancelled before starting new one -- **Single Execution Controller**: Only one IRebalanceExecutionController instance per cache -- **Sequential Processing**: Intent processing loop ensures serial execution - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Sequential intent processing loop -- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Cancellation of previous execution before scheduling new - -### Intent Data Contract - -**Invariant**: C.24e - -**Enforcement Mechanism**: -- **Interface Requirement**: PublishIntent method signature requires deliveredData parameter -- **Single Materialization**: UserRequestHandler materializes data once, passes to both user and intent -- **Type Safety**: Compiler enforces data presence - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - PublishIntent(requestedRange, deliveredData) signature -- `src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs` - Single data materialization shared between paths - -### Pure Decision Logic - -**Invariants**: D.25, D.26 - -**Enforcement Mechanism**: -- **Stateless Design**: RebalanceDecisionEngine has no mutable fields -- **Value-Type Policies**: Decision policies are structs with no side effects -- **No I/O**: Decision path never calls IDataSource or modifies state -- **Functional Architecture**: Pure function: (state, intent, config) → decision - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` - Pure evaluation logic -- `src/SlidingWindowCache/Core/Planning/NoRebalanceSatisfactionPolicy.cs` - Stateless struct -- `src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs` - Stateless struct - -### Decision-Execution Separation - -**Invariant**: D.26 - -**Enforcement Mechanism**: -- **Component Boundaries**: Decision components have no references to mutable state setters -- **Read-Only Access**: Decision Engine reads CacheState but cannot modify it -- **Interface Segregation**: Decision and Execution interfaces are distinct - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` - Read-only state access -- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` - Exclusive write access - -### Multi-Stage Decision Pipeline - -**Invariant**: D.29 - -**Enforcement Mechanism**: -- **Sequential Validation**: Five-stage pipeline with early exits -- **Stage 1**: Current NoRebalanceRange containment check (fast path) -- **Stage 2**: Pending NoRebalanceRange validation (thrashing prevention) -- **Stage 3**: DesiredCacheRange computation -- **Stage 4**: Equality check (DesiredCacheRange == CurrentCacheRange) -- **Stage 5**: Execution scheduling (only if all stages pass) - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` - Complete pipeline implementation with stage-by-stage validation - -### Desired Range Computation - -**Invariants**: E.30, E.31 - -**Enforcement Mechanism**: -- **Pure Function**: ProportionalRangePlanner.CalculateDesiredRange(requestedRange, config) → desiredRange -- **No State Access**: Range planner never reads CurrentCacheRange -- **Deterministic**: Same inputs always produce same output - -**Source References**: -- `src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs` - Pure range calculation logic - -### NoRebalanceRange Computation - -**Invariants**: E.34, E.35 - -**Enforcement Mechanism**: -- **Pure Function**: NoRebalanceRangePlanner.Plan(currentCacheRange) → noRebalanceRange or null -- **Range Shrinking**: Applies threshold percentages to current range boundaries (negative expansion) -- **Configuration-Driven**: Uses WindowCacheOptions threshold values -- **Prerequisite**: WindowCacheOptions constructor ensures threshold sum ≤ 1.0 at construction time -- **Defensive Check**: Returns null when individual thresholds ≥ 1.0 (no stability zone possible) - -**Source References**: -- `src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs` - NoRebalanceRange computation -- `src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs` - Threshold sum validation - -### Cancellation Checkpoints - -**Invariants**: F.35, F.35a - -**Enforcement Mechanism**: -- **Before I/O**: ThrowIfCancellationRequested before calling IDataSource.FetchAsync -- **After I/O**: ThrowIfCancellationRequested after data fetching completes -- **Before Mutations**: ThrowIfCancellationRequested before Rematerialize call -- **Cooperative Pattern**: OperationCanceledException propagates to cleanup handlers - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` - Multiple checkpoint locations (see XML comments for exact line references) - -### Cache Normalization Operations - -**Invariant**: F.37 - -**Enforcement Mechanism**: -- **Rematerialize Method**: CacheState.Rematerialize accepts arbitrary range and data -- **Full Replacement**: Can replace entire cache contents in single operation -- **Storage Abstraction**: ICacheStorage enables different normalization strategies - -**Source References**: -- `src/SlidingWindowCache/Core/State/CacheState.cs` - Rematerialize method -- `src/SlidingWindowCache/Infrastructure/Storage/` - Storage strategy implementations - -### Incremental Data Fetching - -**Invariant**: F.38 - -**Enforcement Mechanism**: -- **Gap Analysis**: CacheDataExtensionService.ExtendCacheDataAsync computes missing ranges -- **Range Subtraction**: Uses range algebra to identify gaps (DesiredRange \ CachedRange) -- **Batch Fetching**: Fetches only missing subranges via IDataSource - -**Source References**: -- `src/SlidingWindowCache/Infrastructure/Services/CacheDataExtensionService.cs` - ExtendCacheDataAsync implementation with range gap logic - -### Data Preservation During Expansion - -**Invariant**: F.39 - -**Enforcement Mechanism**: -- **Union Operation**: New data merged with existing data using range union -- **Storage Enumeration**: Existing data enumerated and preserved during rematerialization -- **No Overwrite**: New data only fills gaps, doesn't replace existing - -**Source References**: -- `src/SlidingWindowCache/Infrastructure/Services/CacheDataExtensionService.cs` - Union logic in ExtendCacheDataAsync -- `src/SlidingWindowCache/Infrastructure/Storage/` - Storage strategies preserve existing data during enumeration - -### I/O Isolation - -**Invariant**: G.45 - -**Enforcement Mechanism**: -- **User Path Returns Early**: UserRequestHandler completes before any IDataSource.FetchAsync calls -- **Background I/O**: All IDataSource interactions happen in RebalanceExecutor on background thread -- **Fire-and-Forget**: Intent published without awaiting I/O completion - -**Source References**: -- `src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs` - No IDataSource calls in user path -- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` - IDataSource calls only in background execution - -### Activity Counter Ordering - -**Invariant**: H.47 - -**Enforcement Mechanism**: -- **Increment-Before-Publish**: Activity counter incremented BEFORE semaphore signal, channel write, or volatile write -- **Ordering Discipline**: All publication sites follow strict ordering pattern -- **Documentation**: XML comments verify ordering at each publication site - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Increment before semaphore.Release -- `src/SlidingWindowCache/Infrastructure/Execution/` - Increment before channel.Writer.WriteAsync or Task.Run - -### Activity Counter Cleanup - -**Invariant**: H.48 - -**Enforcement Mechanism**: -- **Finally Blocks**: Decrement in finally blocks ensures unconditional execution -- **Exception Safety**: Decrement occurs regardless of success, failure, or cancellation -- **Catch Blocks**: Manual decrement in catch blocks for pre-execution failures - -**Source References**: -- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - Finally block in ProcessIntentsAsync loop -- `src/SlidingWindowCache/Infrastructure/Execution/` - Finally blocks in execution controllers - ---- - -## Detailed Component Catalog - -### 1. Configuration & Data Transfer Types - -#### 🟨 WindowCacheOptions -```csharp -public record WindowCacheOptions -``` - -**File**: `src/SlidingWindowCache/Configuration/WindowCacheOptions.cs` - -**Type**: Record (reference type with value semantics) - -**Configuration Aspects**: -- Cache size coefficients for left and right windows -- Rebalance threshold percentages (optional) -- **Threshold sum validation**: Enforces leftThreshold + rightThreshold ≤ 1.0 when both specified -- Debounce delay for rebalance timing -- Cache read strategy selection (see UserCacheReadMode) -- Rebalance execution queue capacity (optional, selects serialization strategy) - -**Validations Enforced** (at construction time): -- Cache sizes ≥ 0 -- Individual thresholds ≥ 0 (when specified) -- **Threshold sum ≤ 1.0** (when both thresholds specified) - prevents overlapping shrinkage zones -- RebalanceQueueCapacity > 0 or null - -> **See**: `src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs` for property details. - -**Ownership**: Created by user, passed to WindowCache constructor - -**Mutability**: Immutable (init-only properties) - -**Lifetime**: Lives as long as cache instance - -**Used by**: -- WindowCache (constructor) -- NoRebalanceSatisfactionPolicy (threshold configuration) -- ProportionalRangePlanner (size configuration) - ---- - -#### 🟪 UserCacheReadMode -```csharp -public enum UserCacheReadMode -``` - -**File**: `src/SlidingWindowCache/Public/Configuration/UserCacheReadMode.cs` - -**Type**: Enum (value type) - -**Values**: -- `Snapshot` - Zero-allocation reads, expensive rebalance (uses array) -- `CopyOnRead` - Allocation on reads, cheap rebalance (uses List) - -**Ownership**: Part of WindowCacheOptions - -**Mutability**: Immutable - -**Used by**: -- WindowCacheOptions -- ICacheStorage implementations (determines storage strategy) - -**Trade-offs**: -- **Snapshot**: Fast reads, slow rebalance, LOH pressure for large caches -- **CopyOnRead**: Slow reads, fast rebalance, better memory pressure - ---- - -#### 🟧 IDataSource -```csharp -public interface IDataSource - where TRangeType : IComparable -``` - -**File**: `src/SlidingWindowCache/IDataSource.cs` - -**Type**: Interface (contract) - -**Contract**: -- Single range fetch (required) -- Batch range fetch (optional, with default parallel implementation) -- CancellationToken support for cooperative cancellation - -> **See**: `src/SlidingWindowCache/IDataSource.cs` for method signatures. - -**Ownership**: User provides implementation - -**Used by**: CacheDataExtensionService (calls to fetch external data) - -**Operations**: Read-only (fetches external data) - -**Characteristics**: -- User-implemented -- May perform I/O (network, disk, database) -- Should respect CancellationToken -- Default batch implementation uses parallel fetch - ---- - -#### 🟨 RangeChunk -```csharp -public record RangeChunk(Range Range, IEnumerable Data) - where TRangeType : IComparable -``` - -**File**: `src/SlidingWindowCache/DTO/RangeChunk.cs` - -**Type**: Record (reference type, immutable) - -**Properties**: -- `Range Range` - The range covered by this chunk -- `IEnumerable Data` - The data for this range - -**Ownership**: Created by IDataSource, consumed by CacheDataExtensionService - -**Mutability**: Immutable - -**Lifetime**: Temporary (method return value) - -**Purpose**: Encapsulates data fetched for a particular range (batch fetch result) - ---- - -### 2. Storage Layer - -#### 🟧 ICacheStorage -```csharp -internal interface ICacheStorage - where TRange : IComparable - where TDomain : IRangeDomain -``` - -**File**: `src/SlidingWindowCache/Storage/ICacheStorage.cs` - -**Type**: Interface (internal) - -**Properties**: -- `UserCacheReadMode Mode { get; }` - The read mode this strategy implements -- `Range Range { get; }` - Current range of cached data - -**Methods**: -- `void Rematerialize(RangeData rangeData)` ⊲ **WRITE** - - Replaces internal storage with new range data - - Called during cache initialization and rebalancing -- `ReadOnlyMemory Read(Range range)` ⊳ **READ** - - Returns data for the specified range - - Behavior varies by implementation (zero-copy vs. copy) -- `RangeData ToRangeData()` ⊳ **READ** - - Converts current state to RangeData representation - -**Implementations**: -- `SnapshotReadStorage` -- `CopyOnReadStorage` - -**Owned by**: CacheState - -**Writers**: UserRequestHandler, RebalanceExecutor (via CacheState) - -**Readers**: UserRequestHandler, RebalanceExecutor - ---- - -#### 🟦 SnapshotReadStorage -```csharp -internal sealed class SnapshotReadStorage : ICacheStorage -``` - -**File**: `src/SlidingWindowCache/Storage/SnapshotReadStorage.cs` - -**Type**: Class (sealed) - -**Storage Strategy**: Array-based with atomic replacement - -**Operations**: -- **Rematerialize**: Allocates new array, replaces storage completely -- **Read**: Returns zero-allocation view over internal array (ReadOnlyMemory) -- **ToRangeData**: Creates snapshot from current array - -**Characteristics**: -- ✅ Zero-allocation reads (fast) -- ❌ Expensive rebalance (always allocates new array) -- ⚠️ Large arrays may end up on LOH (≥85KB) - -**Ownership**: Owned by CacheState (single instance) - -**Internal State**: `TData[]` array (mutable, replaced atomically) - -**Thread Safety**: Not thread-safe (single consumer model) - -**Best for**: Read-heavy workloads, predictable memory patterns - ---- - -#### 🟦 CopyOnReadStorage -```csharp -internal sealed class CopyOnReadStorage : ICacheStorage -``` - -**File**: `src/SlidingWindowCache/Storage/CopyOnReadStorage.cs` - -**Type**: Class (sealed) - -**Storage Strategy**: Dual-buffer pattern (active storage + staging buffer) - -**Staging Buffer Pattern**: -- Active storage: Never mutated during enumeration (immutable reads) -- Staging buffer: Used for building new state during rematerialization -- Swap mechanism: Staging becomes active after rematerialization completes -- Capacity reuse: Buffers grow but never shrink (amortized performance) - -**Operations**: -- **Rematerialize**: Clears staging, fills with new data, swaps with active, updates range -- **Read**: Allocates and copies from active storage (returns ReadOnlyMemory) -- **ToRangeData**: Returns lazy enumerable over active storage - -**Characteristics**: -- ✅ Cheap rematerialization (amortized O(1) when capacity sufficient) -- ❌ Expensive reads (acquires lock + allocates + copies) -- ✅ Correct enumeration (staging buffer prevents corruption during LINQ-derived expansion) -- ✅ No LOH pressure (List growth strategy) -- ✅ Satisfies Invariants A.3.8, A.3.9a, B.11-12 -- ✅ Read/Rematerialize synchronized via `_lock` (mid-swap observation impossible) -- ⚠️ Small lock contention cost on each `Read()` (bounded to swap duration) - -**Ownership**: Owned by CacheState (single instance) - -**Internal State**: Two `List` (swapped under `_lock`); `_lock` object - -**Thread Safety**: `Read()` and `Rematerialize()` are synchronized via `_lock`; `ToRangeData()` is unsynchronized and must only be called from the rebalance path (conditionally compliant with Invariant A.2 — see storage-strategies.md) - -**Best for**: Rematerialization-heavy workloads, large sliding windows, background cache layers - -**See**: [Storage Strategies Guide](storage-strategies.md) for detailed comparison and usage scenarios - ---- - -### 3. Diagnostics (Public) - -#### 🟧 ICacheDiagnostics -```csharp -public interface ICacheDiagnostics -``` - -**File**: `src/SlidingWindowCache/Public/Instrumentation/ICacheDiagnostics.cs` - -**Type**: Interface (public) - -**Purpose**: Optional observability and instrumentation for cache behavioral events - -**Methods** (15 event recording methods): - -**User Path Events:** -- `void UserRequestServed()` - Records completed user request -- `void CacheExpanded()` - Records cache expansion (partial hit optimization) -- `void CacheReplaced()` - Records cache replacement (non-intersecting jump) -- `void UserRequestFullCacheHit()` - Records full cache hit (optimal path) -- `void UserRequestPartialCacheHit()` - Records partial cache hit with extension -- `void UserRequestFullCacheMiss()` - Records full cache miss (cold start or jump) - -**Data Source Access Events:** -- `void DataSourceFetchSingleRange()` - Records single-range fetch from IDataSource -- `void DataSourceFetchMissingSegments()` - Records multi-segment fetch (gap filling) - -**Rebalance Intent Lifecycle Events:** -- `void RebalanceIntentPublished()` - Records intent publication by User Path -- `void RebalanceIntentCancelled()` - Records intent cancellation before/during execution - -**Rebalance Execution Lifecycle Events:** -- `void RebalanceExecutionStarted()` - Records execution start after decision approval -- `void RebalanceExecutionCompleted()` - Records successful execution completion -- `void RebalanceExecutionCancelled()` - Records execution cancellation mid-flight - -**Rebalance Skip Optimization Events:** -- `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**: -- `EventCounterCacheDiagnostics` - Default counter-based implementation -- `NoOpDiagnostics` - Zero-cost no-op implementation (default) - -**Usage**: Passed to WindowCache constructor as optional parameter - -**Ownership**: User creates instance (optional), passed by reference to all actors - -**Integration Points**: -- All actors receive diagnostics instance via constructor injection -- Events recorded at key behavioral points throughout cache lifecycle - -**Zero-Cost Design**: When not provided, `NoOpDiagnostics` is used with empty methods that JIT optimizes away - -**See**: [Diagnostics Guide](diagnostics.md) for comprehensive usage documentation - ---- - -#### 🟦 EventCounterCacheDiagnostics -```csharp -public class EventCounterCacheDiagnostics : ICacheDiagnostics -``` - -**File**: `src/SlidingWindowCache/Public/Instrumentation/EventCounterCacheDiagnostics.cs` - -**Type**: Class (public, thread-safe) - -**Purpose**: Default thread-safe implementation using atomic counters - -**Fields** (18 private int counters): -- `_userRequestServed`, `_cacheExpanded`, `_cacheReplaced` -- `_userRequestFullCacheHit`, `_userRequestPartialCacheHit`, `_userRequestFullCacheMiss` -- `_dataSourceFetchSingleRange`, `_dataSourceFetchMissingSegments` -- `_rebalanceIntentPublished`, `_rebalanceIntentCancelled` -- `_rebalanceExecutionStarted`, `_rebalanceExecutionCompleted`, `_rebalanceExecutionCancelled` -- `_rebalanceSkippedCurrentNoRebalanceRange`, `_rebalanceSkippedPendingNoRebalanceRange`, `_rebalanceSkippedSameRange` -- `_rebalanceScheduled` -- `_rebalanceExecutionFailed` - -**Properties**: 18 read-only properties exposing counter values - -**Methods**: -- 18 event recording methods (explicit interface implementation) -- Thread-safe atomic counter updates -- `void Reset()` - Resets all counters (for test isolation) - -**Characteristics**: -- ✅ Thread-safe (atomic operations, no locks) -- ✅ Low overhead (~72 bytes memory, <5ns per event) -- ✅ Instance-based (multiple caches can have separate diagnostics) -- ✅ Observable state for testing and monitoring - -**Use Cases**: -- Testing and validation (primary use case) -- Development debugging -- Production monitoring (optional) - -**Thread Safety**: Thread-safe via `Interlocked.Increment` - -**Lifetime**: Typically matches cache lifetime - -**See**: [Diagnostics Guide](diagnostics.md) for complete API reference and examples - ---- - -#### 🟦 NoOpDiagnostics -```csharp -public class NoOpDiagnostics : ICacheDiagnostics -``` - -**File**: `src/SlidingWindowCache/Public/Instrumentation/NoOpDiagnostics.cs` - -**Type**: Class (public, singleton-compatible) - -**Purpose**: Zero-overhead no-op implementation for production use - -**Methods**: All 18 interface methods implemented as empty method bodies - -**Characteristics**: -- ✅ **Absolute zero overhead** - empty methods inlined/eliminated by JIT -- ✅ No state (0 bytes memory) -- ✅ No allocations -- ✅ No performance impact - -**Usage**: Automatically used when `cacheDiagnostics` parameter is `null` (default) - -**Design Rationale**: -- Enables diagnostics API without forcing overhead when not needed -- JIT compiler optimizes away empty method calls completely -- Maintains clean API without conditional logic in hot paths - -**Thread Safety**: Stateless, inherently thread-safe - -**Lifetime**: Can be singleton or per-cache (doesn't matter - no state) - ---- - -### 4. State Management - -#### 🟦 CacheState -```csharp -internal sealed class CacheState - where TRange : IComparable - where TDomain : IRangeDomain -``` - -**File**: `src/SlidingWindowCache/Core/State/CacheState.cs` - -**Type**: Class (sealed) - -**State Components**: -- Cache storage instance (ICacheStorage implementation) -- IsInitialized flag (tracks whether cache has been initialized) -- No-rebalance range (stable region where rebalancing is suppressed) -- Domain instance (for range calculations) - -**Ownership**: -- Created by WindowCache constructor -- **Shared by reference** across multiple components - -**Shared with** (read/write): -- **UserRequestHandler** ⊳ (READ-ONLY) - - Reads: `Cache.Range`, `Cache.Read()`, `Cache.ToRangeData()`, `IsInitialized` - - ❌ Does NOT write to CacheState -- **RebalanceExecutor** ⊲⊳ (SOLE WRITER) - - Reads: `Cache.Range`, `Cache.ToRangeData()` - - Writes: `Cache.Rematerialize()`, `NoRebalanceRange`, `IsInitialized` -- **RebalanceDecisionEngine** ⊳ (via IntentController.ProcessIntentsAsync) - - Reads: `NoRebalanceRange`, `Cache.Range` - -**Characteristics**: -- ⚠️ **Mutable shared state** (central coordination point) -- ❌ **No internal locking** (single-writer architecture by design) -- ✅ **Atomic operations** (Rematerialize replaces storage atomically) - -**Thread Safety**: -- Single-writer architecture: only RebalanceExecutor writes to CacheState -- User Path is strictly read-only — no coordination mechanism needed for reads -- Write-write races prevented by single-writer invariant (not by locks) - -**Role**: Central point for cache data and metadata - ---- - -### 5. User Path (Fast Path) - -#### 🟦 UserRequestHandler -```csharp -internal sealed class UserRequestHandler -``` - -**File**: `src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs` - -**Type**: Class (sealed) - -**Dependencies**: -- CacheState (shared state, read-only access) -- CacheDataExtensionService (data fetching) -- IntentController (intent publishing) -- IDataSource (external data access) -- ICacheDiagnostics (instrumentation) - -**Main Method**: -```csharp -public async ValueTask> HandleRequestAsync( - Range requestedRange, - CancellationToken cancellationToken) -``` - -**Operation Flow**: -1. **Check cold start** - `!_state.IsInitialized` -2. **Serve from cache or data source** - varies by scenario (cold start / full hit / partial hit / full miss) -3. **Publish rebalance intent** - `_intentController.PublishIntent(intent)` with assembled data (fire-and-forget) -4. **Return data** - return assembled `ReadOnlyMemory` - -**Reads from**: -- ⊳ `_state.Cache` (Range, Read, ToRangeData) -- ⊳ `_state.IsInitialized` (cold-start detection) -- ⊳ `_state.Domain` - -**Writes to**: -- ❌ Does NOT write to CacheState (read-only with respect to cache state) - -**Uses**: -- ◇ `_cacheExtensionService` (to fetch missing data on partial/full miss) -- ◇ `_dataSource` (for cold start and full miss scenarios) -- ◇ `_intentController.PublishIntent()` (fire-and-forget, triggers background rebalance) - -**Characteristics**: -- ✅ Executes in **User Thread** -- ✅ Always serves user requests (never waits for rebalance) -- ✅ **READ-ONLY with respect to CacheState** (never writes Cache, IsInitialized, or NoRebalanceRange) -- ✅ Always triggers rebalance intent after serving -- ❌ **Never** trims or normalizes cache -- ❌ **Never** invokes decision logic -- ❌ **Never** blocks on rebalance -- ❌ **Never** calls `Cache.Rematerialize()` - -**Ownership**: Owned by WindowCache - -**Execution Context**: User Thread - -**Responsibilities**: Serve user requests fast, trigger rebalance intents - -**Invariants Enforced**: -- 1: Always serves user requests -- 2: Never waits for rebalance execution -- 3: Sole source of rebalance intent -- 10: Always returns exactly RequestedRange - ---- - -### 5. Rebalance System - Intent Management - -#### 🟦 IntentController -```csharp -internal sealed class IntentController -``` - -**File**: `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` - -**Type**: Class (sealed) - -**Role**: Intent Controller — manages intent lifecycle and background intent processing loop - -**Dependencies**: -- Execution controller (rebalance execution serialization) -- Decision engine (rebalance decision logic) -- CacheState (shared state reference) -- AsyncActivityCounter (idle tracking) -- ICacheDiagnostics (instrumentation) - -**Internal State**: -- Pending intent (latest unprocessed intent from user thread) -- Intent signal (synchronization primitive for processing loop) -- Background processing loop task -- Loop cancellation token - -**Key Methods**: - -**PublishIntent** (executes in User Thread): -- Atomically replaces pending intent (latest wins semantics) -- Increments activity counter -- Signals processing loop to wake up -- Records diagnostic event -- Returns immediately (fire-and-forget) - // Returns immediately — decision happens in background loop -} -``` - -**ProcessIntentsAsync** (background processing loop): -- Evaluates DecisionEngine for each intent -- Cancels previous execution if needed -- Enqueues new execution via execution controller - -**DisposeAsync**: -- Marks as disposed (idempotent) -- Cancels background loop -- Awaits processing loop completion -- Disposes execution controller and synchronization primitives - -**Characteristics**: -- ✅ PublishIntent is minimal — atomic intent store + semaphore signal only -- ✅ Decision evaluation happens in background loop (NOT in user thread) -- ✅ "Latest intent wins" — rapid bursts naturally collapse -- ✅ Single-flight enforcement through cancellation -- ⚠️ **Intent does not guarantee execution** — execution is opportunistic -- ❌ **Does NOT**: Perform debounce delay, execute cache mutations - -**Concurrency Model**: -- User thread writes intent atomically (no locks) -- Background loop reads intent atomically (also clears it) -- Semaphore prevents CPU spinning in the background loop -- AsyncActivityCounter tracks active operations for WaitForIdleAsync - -**Ownership**: -- Owned by UserRequestHandler (via WindowCache) -- Composes with `IRebalanceExecutionController` - -**Execution Context**: -- **`PublishIntent()` executes in User Thread** (minimal: atomic store + semaphore signal) -- **`ProcessIntentsAsync()` executes in Background Thread** (decision, cancellation, execution enqueue) - -**State**: -- Pending intent (mutable, nullable, written by user thread, cleared by background loop) - -**Responsibilities**: -- Intent lifecycle management -- Burst resistance (latest-intent-wins) -- Background loop orchestration (decision → cancel → enqueue) -- Idle synchronization (delegates to `AsyncActivityCounter`) - -**Invariants Enforced**: -- C.17: At most one active intent (latest wins) -- C.18: Previous intents become obsolete -- C.24: Intent does not guarantee execution - ---- - ---- - -#### IntentController — ProcessIntentsAsync (background loop) - -The `RebalanceScheduler` class described in older documentation **does not exist**. The scheduling, debounce, and pipeline orchestration responsibilities are distributed between `IntentController.ProcessIntentsAsync` (decision + cancellation) and `IRebalanceExecutionController` implementations (debounce + execution). - -See `IRebalanceExecutionController`, `TaskBasedRebalanceExecutionController`, and `ChannelBasedRebalanceExecutionController` in Section 7 for the execution side. - -**ProcessIntentsAsync** (private background loop inside IntentController): - -**Loop Structure**: -1. Wait on semaphore (blocks without CPU spinning) -2. Atomically read and clear pending intent (latest wins) -3. Evaluate DecisionEngine (CPU-only, lightweight, 5-stage validation) -4. Record decision reason; skip if decision says no rebalance needed -5. Cancel previous execution if new rebalance is needed -6. Enqueue execution request to execution controller - -> **See**: `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` for implementation details. - -**Characteristics**: -- ✅ Runs in **Background Thread** (single dedicated loop task) -- ✅ Handles burst resistance via "latest intent wins" semantics -- ✅ Decision evaluation happens here (NOT in user thread) -- ✅ Cancels previous execution before enqueuing new one -- ✅ Semaphore prevents CPU spinning -- ❌ Does NOT perform debounce (handled by IRebalanceExecutionController implementations) - -**Execution Context**: Background / ThreadPool (loop task started in constructor) - -**Responsibilities**: -- Wait for intent signals -- Evaluate DecisionEngine (5-stage validation) -- Cancel previous execution if new rebalance needed -- Enqueue execution requests via `IRebalanceExecutionController` -- Signal idle state after each intent processed - -**Invariants Enforced**: -- C.20: Obsolete intents don't start new executions (latest wins + cancellation) -- C.21: At most one active rebalance scheduled at a time (cancellation before enqueue) - ---- - -### 6. Rebalance System - Decision & Policy - -#### 🟦 RebalanceDecisionEngine -```csharp -internal sealed class RebalanceDecisionEngine -``` - -**File**: `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` - -**Type**: Class (sealed) - -**Role**: Pure Decision Logic - **SOLE AUTHORITY for Rebalance Necessity Determination** - -**Dependencies** (all readonly, value types): -- NoRebalanceSatisfactionPolicy (threshold validation logic) -- ProportionalRangePlanner (cache range planning) -- NoRebalanceRangePlanner (no-rebalance range planning) - -**Key Method - Evaluate**: - -**Five-Stage Decision Pipeline**: -1. **Current Cache Stability Check** (fast path): Skip if requested range within current NoRebalanceRange -2. **Pending Rebalance Stability Check** (anti-thrashing): Skip if requested range within pending NoRebalanceRange -3. **Desired Range Computation**: Calculate desired cache range and desired no-rebalance range -4. **Equality Short Circuit**: Skip if desired range equals current range -5. **Rebalance Required**: Return execute decision with desired ranges - -> **See**: `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` for implementation details. - -**Characteristics**: -- ✅ **Pure function** (no side effects, CPU-only, no I/O) -- ✅ **Deterministic** (same inputs → same outputs) -- ✅ **Stateless** (composes value-type policies) -- ✅ **THE authority** for rebalance necessity determination -- ✅ Invoked only in background (inside `IntentController.ProcessIntentsAsync`) -- ❌ Not visible to User Path - -**Decision Authority**: -- **This component is the SOLE AUTHORITY** for determining whether rebalance is necessary -- All execution decisions flow from this component's analytical validation -- No other component may override or bypass these decisions -- Executor assumes necessity already validated when invoked - -**Uses**: -- ◇ `_policy.ShouldRebalance()` - Stage 1 & 2: NoRebalanceRange containment checks -- ◇ `_planner.Plan()` - Stage 3: Compute DesiredCacheRange -- ◇ `_noRebalancePlanner.Plan()` - Stage 3: Compute DesiredNoRebalanceRange - -**Returns**: `RebalanceDecision` (struct with `IsExecutionRequired`, `DesiredRange`, `DesiredNoRebalanceRange`, `Reason`) - -**Ownership**: Owned by IntentController, invoked exclusively in `IntentController.ProcessIntentsAsync` - -**Execution Context**: Background Thread (intent processing loop) - -**Responsibilities**: -- **THE authority** for rebalance necessity determination -- 5-stage validation pipeline (stages 1–4 are guard/short-circuit stages; stage 5 is execute) -- Stage 1: Current NoRebalanceRange containment (fast path) -- Stage 2: Pending NoRebalanceRange containment (anti-thrashing) -- Stage 3: Compute DesiredCacheRange and DesiredNoRebalanceRange -- Stage 4: DesiredRange == CurrentRange equality short-circuit -- Stage 5: Return Schedule decision - -**Invariants Enforced**: -- D.25: Decision path is purely analytical (CPU-only, no I/O) -- D.26: Never mutates cache state -- D.27: No rebalance if inside NoRebalanceRange (Stage 1 & 2 validation) -- D.28: No rebalance if DesiredCacheRange == CurrentCacheRange (Stage 4 validation) -- D.29: Rebalance executes ONLY if ALL stages confirm necessity - ---- - -#### 🟩 NoRebalanceSatisfactionPolicy -```csharp -internal readonly struct NoRebalanceSatisfactionPolicy -``` - -**File**: `src/SlidingWindowCache/Core/Rebalance/Decision/NoRebalanceSatisfactionPolicy.cs` - -**Type**: Struct (readonly value type) - -**Role**: Cache Geometry Policy - Threshold Rules (component 1 of 2) - -**Key Methods**: -- **ShouldRebalance**: Determines if requested range is outside no-rebalance range - -**Characteristics**: -- ✅ **Value type** (struct, passed by value) -- ✅ **Pure functions** (no state mutation) -- ✅ **Configuration-driven** (uses WindowCacheOptions) -- ✅ **Stateless** (readonly fields) - -**Ownership**: Value type, copied into RebalanceDecisionEngine - -**Execution Context**: Background Thread (invoked by RebalanceDecisionEngine within intent processing loop - see IntentController.ProcessIntentsAsync) - -**Responsibilities**: -- Check if requested range falls outside no-rebalance zone (Stages 1 & 2) -- Answers: **"When to rebalance"** (decision evaluation only; planning delegated to `NoRebalanceRangePlanner`) - -**Invariants Enforced**: -- 26: No rebalance if inside NoRebalanceRange -- 33: NoRebalanceRange derived from CurrentCacheRange + config - ---- - -#### 🟩 ProportionalRangePlanner -```csharp -internal readonly struct ProportionalRangePlanner -``` - -**File**: `src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs` - -**Type**: Struct (readonly value type) - -**Role**: Cache Geometry Policy - Shape Planning (component 2 of 2) - -**Key Method - Plan**: -- Computes desired cache range by expanding requested range -- Uses left and right cache size coefficients from configuration -- Pure function: same input → same output - -**Characteristics**: -- ✅ **Value type** (struct, passed by value) -- ✅ **Pure function** (no state) -- ✅ **Configuration-driven** (uses WindowCacheOptions) -- ✅ **Independent of current cache contents** -- ✅ **Stateless** (readonly fields) - -**Ownership**: Value type, copied into RebalanceDecisionEngine - -**Execution Context**: Background Thread (invoked by RebalanceDecisionEngine within intent processing loop - see IntentController.ProcessIntentsAsync) - -**Responsibilities**: -- Compute DesiredCacheRange (expands requested by left/right coefficients) -- Define canonical cache geometry -- Answers: **"What shape to target"** - -**Invariants Enforced**: -- 29: DesiredCacheRange computed from RequestedRange + config -- 30: Independent of current cache contents -- 31: Canonical target cache state -- 32: Sliding window geometry defined by configuration - ---- - -#### 🟩 RebalanceDecision -```csharp -internal readonly struct RebalanceDecision - where TRange : IComparable -``` - -**File**: `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecision.cs` - -**Type**: Struct (readonly value type) - -**Properties** (all readonly): -- `bool ShouldSchedule` - Whether rebalance should be scheduled -- `Range? DesiredRange` - Target cache range (if scheduling) -- `Range? DesiredNoRebalanceRange` - Target no-rebalance zone (if scheduling) -- `RebalanceReason Reason` - Explicit reason for the decision outcome - -**Factory Methods**: -- `static Skip(RebalanceReason reason)` → Returns decision to skip rebalance with reason -- `static Execute(Range desiredRange, Range? desiredNoRebalanceRange)` → Returns decision to schedule with target ranges (sets `Reason = RebalanceRequired`) - -**Characteristics**: -- ✅ **Value type** (struct) -- ✅ **Immutable** -- ✅ Represents decision outcome - -**Ownership**: Created by RebalanceDecisionEngine, consumed by IntentController.ProcessIntentsAsync - -**Mutability**: Immutable - -**Lifetime**: Temporary (local variable in intent processing loop) - -**Purpose**: Encapsulates decision result (skip or schedule with target ranges and reason) - ---- - -### 7. Rebalance System - Execution - -#### 🟦 RebalanceExecutor -```csharp -internal sealed class RebalanceExecutor -``` - -**File**: `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` - -**Type**: Class (sealed) - -**Role**: Mutating Actor (sole component responsible for cache normalization) - -**Dependencies** (all readonly): -- CacheState (shared state - ONLY component that writes to it) -- CacheDataExtensionService (data fetching) -- ICacheDiagnostics (instrumentation) - -**Execution Serialization**: -- Provided by the active `IRebalanceExecutionController` implementation (NOT by RebalanceExecutor itself) -- **TaskBasedRebalanceExecutionController** (default): Lock-free task chaining ensures sequential execution -- **ChannelBasedRebalanceExecutionController** (optional): Bounded channel with single reader loop ensures sequential execution -- CancellationToken provides early exit signaling throughout execution phases -- WebAssembly-compatible, async, zero User Path blocking - -**Key Method** (high-level description - see source code for implementation details): -- **ExecuteAsync**: Normalizes cache to desired range using delivered data from intent - - Phase 1: Extend delivered data to cover desired range (may fetch missing segments via IDataSource) - - Phase 2: Trim to desired range (discard excess data outside target) - - Phase 3: Update cache state atomically (sole writer - single-writer architecture) - - Multiple cancellation checks between phases (cancellation-safe) - - Uses delivered data from intent as authoritative source (avoids redundant fetches) - -**Reads from**: -- ⊳ `intent.AvailableRangeData` (delivered data from User Path) - -**Writes to**: -- ⊲ `_state.Cache` (via Rematerialize - normalizes to DesiredCacheRange) -- ⊲ `_state.IsInitialized` -- ⊲ `_state.NoRebalanceRange` - -**Uses**: -- ◇ `_cacheExtensionService.ExtendCacheAsync()` (fetch missing data) -- ◇ `_rebalancePolicy.GetNoRebalanceRange()` (compute new threshold zone) - -**Characteristics**: -- ✅ Executes in **Background / ThreadPool** -- ✅ **Asynchronous** (performs I/O operations) -- ✅ **Cancellable** (checks token at multiple points) -- ✅ **Sole component** responsible for cache normalization -- ✅ Expands to DesiredCacheRange -- ✅ Trims excess data -- ✅ Updates NoRebalanceRange - -**Ownership**: Owned by WindowCache, used by `IRebalanceExecutionController` implementations - -**Execution Context**: Background / ThreadPool - -**Operations**: Mutates cache atomically (expand, trim, update metadata) - -**Invariants Enforced**: -- 4: Rebalance is asynchronous -- 34: Supports cancellation at all stages -- 34a: Yields to User Path immediately upon cancellation -- 34b: Cancelled execution doesn't corrupt state -- 35: Only path responsible for cache normalization -- 35a: Mutates only for normalization (expand, trim, recompute NoRebalanceRange) -- 39-41: Upon completion, cache matches DesiredCacheRange - ---- - -#### 🟧 IRebalanceExecutionController -```csharp -internal interface IRebalanceExecutionController : IAsyncDisposable -``` - -**File**: `src/SlidingWindowCache/Core/Rebalance/Execution/IRebalanceExecutionController.cs` - -**Type**: Interface - -**Role**: Abstraction for rebalance execution serialization strategies - -**Purpose**: Defines the contract for serializing rebalance execution requests. Implementations guarantee single-writer architecture by ensuring only one rebalance executes at a time. - -**Methods**: -- **PublishExecutionRequest**: Enqueues execution request with intent, desired range, and no-rebalance range -- **LastExecutionRequest**: Property exposing most recent execution request (for decision engine validation) -- **DisposeAsync**: Async disposal for graceful shutdown - -**Implementations**: -- `TaskBasedRebalanceExecutionController` - Unbounded task chaining (default, minimal overhead) -- `ChannelBasedRebalanceExecutionController` - Bounded channel with backpressure - -**Strategy Selection**: Configured via `WindowCacheOptions.RebalanceQueueCapacity` -- `null` → Task-based strategy (recommended) -- `>= 1` → Channel-based strategy - -**Characteristics**: -- ✅ Single-writer guarantee (both implementations) -- ✅ Cancellation support -- ✅ Async disposal for graceful shutdown -- ✅ Strategy pattern for execution serialization - ---- - -#### 🟦 TaskBasedRebalanceExecutionController -```csharp -internal sealed class TaskBasedRebalanceExecutionController : - IRebalanceExecutionController -``` - -**File**: `src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs` - -**Type**: Class (sealed) - -**Role**: Unbounded execution serialization using lock-free task chaining (default strategy) - -**Dependencies**: -- RebalanceExecutor (execution logic) -- Debounce delay (from configuration) -- AsyncActivityCounter (idle tracking) -- ICacheDiagnostics (instrumentation) - -**Internal State**: -- Current execution task (for task chaining) -- Last execution request (for decision engine validation) -- Dispose state (for idempotent disposal) - -**Serialization Mechanism**: -- Task chaining ensures sequential execution (previous task must complete before next starts) -- Lock-free coordination using atomic operations -- Unbounded queue (no backpressure, requests always accepted) - -> **See**: `src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs` for implementation details. - -**Characteristics**: -- ✅ **Unbounded** - no queue capacity limit -- ✅ **Fire-and-forget** - returns completed ValueTask immediately -- ✅ **Minimal overhead** - single Task reference coordination -- ✅ **Sequential execution** - task chaining ensures one at a time -- ✅ **Cancellation** - integrated via CancellationToken -- ✅ **Graceful disposal** - awaits final task completion - -**Use Cases**: -- Normal operation with typical rebalance frequencies -- Maximum performance with minimal overhead -- Default/recommended strategy - -**Ownership**: Created by WindowCache factory method - -**Execution Context**: Background / ThreadPool - ---- - -#### 🟦 ChannelBasedRebalanceExecutionController -```csharp -internal sealed class ChannelBasedRebalanceExecutionController : - IRebalanceExecutionController -``` - -**File**: `src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs` - -**Type**: Class (sealed) - -**Role**: Bounded execution serialization using System.Threading.Channels (optional strategy) - -**Dependencies**: -- Bounded channel (execution queue) -- RebalanceExecutor (execution logic) -- Debounce delay (from configuration) -- AsyncActivityCounter (idle tracking) -- ICacheDiagnostics (instrumentation) - -**Internal State**: -- Execution channel (bounded capacity queue) -- Background execution loop task -- Last execution request (for decision engine validation) -- Dispose state (for idempotent disposal) - -**Serialization Mechanism**: -- Channel provides natural serialization (single reader loop) -- Bounded capacity creates backpressure when queue is full -- Async write blocks when capacity reached (backpressure signal) - -> **See**: `src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs` for implementation details. - -**Characteristics**: -- ✅ **Bounded capacity** - strict limit on pending operations -- ✅ **Backpressure** - async await blocks intent processing when full -- ✅ **Background loop** - processes requests sequentially -- ✅ **Cancellation** - superseded operations cancelled before queueing -- ✅ **Graceful disposal** - completes writer and drains remaining operations - -**Use Cases**: -- High-frequency rebalance scenarios -- Memory-constrained environments -- Testing scenarios requiring deterministic queue behavior - -**Ownership**: Created by WindowCache factory method - -**Execution Context**: Background / ThreadPool - ---- - -#### 🟦 CacheDataExtensionService -```csharp -internal sealed class CacheDataExtensionService -``` - -**File**: `src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs` - -**Type**: Class (sealed) - -**Role**: Data Fetcher (used by both User Path and Rebalance Path) - -**Dependencies** (all readonly): -- IDataSource (user-provided external data access) -- Domain instance (for range calculations) - -**Key Method - ExtendCacheAsync**: -1. Calculate missing ranges (gaps between current and requested) -2. Fetch missing data from data source -3. Union fetched data with current cache (merge without trimming) - -> **See**: `src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs` for implementation details. - -**Uses**: -- ◇ `_dataSource.FetchAsync()` - external I/O to fetch data - -**Characteristics**: -- ✅ Calls external IDataSource -- ✅ Performs I/O operations -- ✅ Merges data **without trimming** -- ✅ Optimizes partial cache hits (only fetches missing ranges) -- ✅ **Shared by both paths** - -**Ownership**: Owned by WindowCache, shared by UserRequestHandler and RebalanceExecutor - -**Execution Context**: -- User Thread (when called by UserRequestHandler) -- Background / ThreadPool (when called by RebalanceExecutor) - -**External Dependencies**: IDataSource (user-provided) - -**Operations**: -- Fetches missing data -- Merges with existing cache -- **Never trims** - -**Shared by**: -- UserRequestHandler (expand to cover requested range) -- RebalanceExecutor (expand to cover desired range) - ---- - -### 8. Public Facade - -#### 🟦 WindowCache -```csharp -public sealed class WindowCache : IWindowCache, IAsyncDisposable -``` - -**File**: `src/SlidingWindowCache/WindowCache.cs` - -**Type**: Class (sealed, public) - -**Role**: Public Facade, Composition Root, Resource Manager - -**Internal State**: -- UserRequestHandler (delegates user requests) -- AsyncActivityCounter (idle tracking) -- Dispose state (for idempotent disposal) - -**Constructor - Composition Root**: -Creates and wires all internal components in dependency order: -1. Creates cache storage strategy (based on configuration) -2. Creates CacheState with storage and domain -3. Creates decision policies (threshold, range planner, no-rebalance planner) -4. Creates data fetcher (CacheDataExtensionService) -5. Creates decision engine (composes policies) -6. Creates rebalance executor -7. Selects and creates execution controller strategy (task-based or channel-based) -8. Creates intent controller (composes decision engine and execution controller) -9. Creates user request handler (composes state, fetcher, intent controller) - -> **See**: `src/SlidingWindowCache/WindowCache.cs` constructor for wiring details. - -**Public API**: - -**GetDataAsync** (primary domain API): -- Validates cache not disposed -- Delegates to UserRequestHandler.HandleRequestAsync -- Throws ObjectDisposedException if disposed - -**WaitForIdleAsync** (infrastructure API for synchronization): -- Validates cache not disposed -- Delegates to AsyncActivityCounter -- Completes when system was idle at some point -- Throws ObjectDisposedException if disposed - -**DisposeAsync** (resource management): -- Three-state disposal pattern for concurrent safety (active → disposing → disposed) -- Idempotent (safe to call multiple times) -- Cascades disposal to all internal components -- Graceful shutdown (doesn't force-terminate tasks) - -> **See**: `src/SlidingWindowCache/WindowCache.cs` for public API implementation details. - -**Characteristics**: -- ✅ **Pure facade** (no business logic) -- ✅ **Composition root** (wires all components) -- ✅ **Public API** (single entry point) -- ✅ **Resource manager** (owns disposal lifecycle) -- ✅ **Delegates everything** to UserRequestHandler -- ✅ **Idempotent disposal** (safe to call multiple times) - -**Ownership**: -- Owns all internal components -- Created by user -- Should be disposed when no longer needed -- Disposal cascades: WindowCache → UserRequestHandler → IntentController → IRebalanceExecutionController (Task-based or Channel-based) - -**Execution Context**: Neutral (just delegates) - -**Disposal Responsibilities**: -- Mark cache as disposed (blocks new operations) -- Dispose UserRequestHandler (cascades to all internal components) -- Use three-state pattern for concurrent disposal safety -- Ensure exactly-once disposal execution - -**Public Operations**: -- `GetDataAsync`: Retrieve data for range (throws ObjectDisposedException if disposed) -- `WaitForIdleAsync`: Wait for background activity to complete (throws ObjectDisposedException if disposed) -- `DisposeAsync`: Release all resources and stop background processing (idempotent) - -**Does NOT**: -- Implement business logic -- Directly access cache state -- Perform decision logic -- Force-terminate background tasks (disposal is graceful) - ---- - -## Ownership & Data Flow Diagram - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ USER (Consumer) │ -└─────────────────────────────────────────────────────────────────────┘ - │ - │ GetDataAsync(range, ct) - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ WindowCache [Public Facade] │ -│ 🟦 CLASS (sealed, public) │ -│ │ -│ Constructor creates and wires: │ -│ ├─ 🟦 CacheState ──────────────────────────┐ (shared mutable) │ -│ ├─ 🟦 UserRequestHandler ──────────────────┼───┐ │ -│ ├─ 🟦 CacheDataExtensionService ───────────┼───┼───┐ │ -│ ├─ 🟦 IntentController ────────────────────┼───┼───┼───┐ │ -│ │ └─ 🟧 IRebalanceExecutionController ───┼───┼───┼───┼───┐ │ -│ ├─ 🟦 RebalanceDecisionEngine ─────────────┼───┼───┼───┼───┼───┐ │ -│ │ ├─ 🟩 NoRebalanceSatisfactionPolicy │ │ │ │ │ │ │ -│ │ └─ 🟩 ProportionalRangePlanner │ │ │ │ │ │ │ -│ └─ 🟦 RebalanceExecutor ───────────────────┼───┼───┼───┼───┼───┤ │ -│ │ │ │ │ │ │ │ -│ GetDataAsync() → delegates to UserRequestHandler│ │ │ │ │ │ -└──────────────────────────────────────────────┼───┼───┼───┼───┼───┼──┘ - │ │ │ │ │ │ - ══════════════════════════════════════════════╪═══╪═══╪═══╪═══╪═══╪═ - USER THREAD │ │ │ │ │ │ - ══════════════════════════════════════════════╪═══╪═══╪═══╪═══╪═══╪═ - │ │ │ │ │ │ -┌──────────────────────────────────────────────▼───┼───┼───┼───┼───┐ -│ UserRequestHandler [Fast Path Actor — READ-ONLY]│ │ │ │ │ -│ 🟦 CLASS (sealed) │ │ │ │ │ -│ │ │ │ │ │ -│ HandleRequestAsync(range, ct): │ │ │ │ │ -│ 1. Check cold start / cache coverage ──────────┼───┤ │ │ │ -│ 2. Fetch missing via _cacheExtensionService ───┼───┼───┤ │ │ -│ or _dataSource (cold start / full miss) │ │ │ │ │ -│ 3. Publish intent with assembled data ─────────┼───┼───┼───┼───┤ -│ 4. Return ReadOnlyMemory to user │ │ │ │ │ -│ │ │ │ │ │ -│ ❌ NEVER writes to CacheState │ │ │ │ │ -│ ❌ NEVER calls Cache.Rematerialize() │ │ │ │ │ -│ ❌ NEVER writes IsInitialized/NoRebalanceRange │ │ │ │ │ -└──────────────────────────────────────────────────┼───┼───┼───┼───┘ - │ │ │ │ -═══════════════════════════════════════════════════╪═══╪═══╪═══╪═══ -BACKGROUND / THREADPOOL │ │ │ │ -═══════════════════════════════════════════════════╪═══╪═══╪═══╪═══ - │ │ │ │ -┌──────────────────────────────────────────────────▼───┼───┼───┼───┐ -│ IntentController [Lifecycle + Background Loop] │ │ │ │ │ -│ 🟦 CLASS (sealed) │ │ │ │ │ -│ │ │ │ │ │ -│ Fields: │ │ │ │ │ -│ ├─ RebalanceDecisionEngine _decisionEngine │ │ -│ ├─ CacheState _state │ │ -│ ├─ IRebalanceExecutionController _executionController ─────▼───┤ -│ └─ Intent? _pendingIntent (Interlocked.Exchange) │ │ -│ │ │ -│ PublishIntent(intent) [User Thread]: │ │ -│ 1. Interlocked.Exchange(_pendingIntent, intent) │ │ -│ 2. _activityCounter.IncrementActivity() │ │ -│ 3. _intentSignal.Release() → wakes ProcessIntentsAsync │ │ -│ │ │ -│ ProcessIntentsAsync() [Background Loop]: │ │ -│ 1. await _intentSignal.WaitAsync() │ │ -│ 2. intent = Interlocked.Exchange(_pendingIntent, null) │ │ -│ 3. decision = _decisionEngine.Evaluate(intent, ...) ───────┼───┤ -│ 4. if (!decision.IsExecutionRequired) → skip │ │ -│ 5. lastRequest?.Cancel() │ │ -│ 6. await _executionController.PublishExecutionRequest() ───┼───┤ -└──────────────────────────────────────────────────────────────┼───┘ - │ │ -┌──────────────────────────────────────────────────────────────▼────┼───┐ -│ RebalanceDecisionEngine [Pure Decision Logic] │ │ -│ 🟦 CLASS (sealed) │ │ -│ │ │ -│ Fields (value types): │ │ -│ ├─ 🟩 NoRebalanceSatisfactionPolicy _policy │ │ -│ ├─ 🟩 ProportionalRangePlanner _planner │ │ -│ └─ 🟩 NoRebalanceRangePlanner _noRebalancePlanner │ │ -│ │ │ -│ Evaluate(requested, cacheState, lastRequest): │ │ -│ 1. Stage 1: _policy.ShouldRebalance(noRebalanceRange) → skip │ │ -│ 2. Stage 2: _policy.ShouldRebalance(pendingNRR) → skip │ │ -│ 3. Stage 3: desiredRange = _planner.Plan(requested) │ │ -│ 4. Stage 4: desiredRange == currentRange → skip │ │ -│ 5. Stage 5: return Schedule(desiredRange, desiredNRR) │ │ -│ │ │ -│ Returns: 🟩 RebalanceDecision │ │ -│ (IsExecutionRequired, DesiredRange, │ │ -│ DesiredNoRebalanceRange, Reason) │ │ -└───────────────────────────────────────────────────────────────────┼───┘ - │ -┌───────────────────────────────────────────────────────────────────▼───┐ -│ IRebalanceExecutionController [Execution Serialization] │ -│ 🟧 INTERFACE │ -│ │ -│ Implementations: │ -│ ├─ 🟦 TaskBasedRebalanceExecutionController (default) │ -│ │ • Lock-free task chaining (Volatile.Write for single-writer) │ -│ │ • Debounce via Task.Delay before executing │ -│ │ • PublishExecutionRequest returns ValueTask.CompletedTask │ -│ └─ 🟦 ChannelBasedRebalanceExecutionController │ -│ • Bounded Channel with backpressure │ -│ • Single reader loop processes requests sequentially │ -│ │ -│ ChainExecutionAsync / channel read loop: │ -│ 1. await Task.Delay(debounceDelay, ct) (cancellable) │ -│ 2. await _executor.ExecuteAsync(desiredRange, ct) ─────────────┐ │ -└──────────────────────────────────────────────────────────────────┼────┘ - │ -┌──────────────────────────────────────────────────────────────────▼────┐ -│ RebalanceExecutor [Mutating Actor — SOLE WRITER] │ -│ 🟦 CLASS (sealed) │ -│ │ -│ ExecuteAsync(intent, desiredRange, desiredNRR, ct): │ -│ 1. baseRangeData = intent.AssembledRangeData │ -│ 2. ct.ThrowIfCancellationRequested() │ -│ 3. extended = await _cacheExtensionService.ExtendCacheAsync() ────┐ │ -│ 4. ct.ThrowIfCancellationRequested() │ │ -│ 5. normalizedData = extended[desiredRange] (trim) │ │ -│ 6. ct.ThrowIfCancellationRequested() │ │ -│ 7. _state.UpdateCacheState(normalizedData, desiredNRR) │ │ -│ └─ _state.Storage.Rematerialize(normalizedData) ───────────┐ │ │ -│ └─ _state.NoRebalanceRange = desiredNRR ───────────────────┼───┤ │ -│ └─ _state.IsInitialized = true ────────────────────────────┼───┤ │ -└─────────────────────────────────────────────────────────────────┼───┼─┘ - │ │ -┌─────────────────────────────────────────────────────────────────▼───┼───┐ -│ CacheState [Shared Mutable State] │ │ -│ 🟦 CLASS (sealed) ⚠️ SHARED │ │ -│ │ │ -│ Properties: │ │ -│ ├─ ICacheStorage Storage ◄─ RebalanceExecutor (SOLE WRITER) ───────┤ │ -│ ├─ bool IsInitialized ◄─ RebalanceExecutor │ │ -│ ├─ Range? NoRebalanceRange ◄─ RebalanceExecutor │ │ -│ └─ TDomain Domain (readonly) │ │ -│ │ │ -│ Read by: │ │ -│ ├─ UserRequestHandler (Storage.Range, Storage.Read, │ │ -│ │ Storage.ToRangeData, IsInitialized) │ │ -│ ├─ RebalanceDecisionEngine (NoRebalanceRange, Storage.Range) │ │ -│ └─ IntentController (Storage.Range, NoRebalanceRange) │ │ -└──────────────────────────────────────────────────────────────────────┼──┘ - │ -┌──────────────────────────────────────────────────────────────────────▼───┐ -│ ICacheStorage │ -│ 🟧 INTERFACE │ -│ │ -│ Implementations: │ -│ ├─ 🟦 SnapshotReadStorage (TData[] array) │ -│ │ • Read: zero allocation (memory view) │ -│ │ • Write: expensive (allocates new array) │ -│ │ │ -│ └─ 🟦 CopyOnReadStorage (List) │ -│ • Read: allocates (copies to new array) │ -│ • Write: cheap (list operations) │ -│ │ -│ Methods: │ -│ ├─ void Rematerialize(RangeData) ⊲ WRITE │ -│ ├─ ReadOnlyMemory Read(Range) ⊳ READ │ -│ └─ RangeData ToRangeData() ⊳ READ │ -└──────────────────────────────────────────────────────────────────────────┘ - │ -┌──────────────────────────────────────────────────────────────────────────▼───┐ -│ CacheDataExtensionService [Data Fetcher] │ -│ 🟦 CLASS (sealed) │ -│ │ -│ ExtendCacheAsync(current, requested, ct): │ -│ 1. missingRanges = CalculateMissingRanges() │ -│ 2. fetched = await _dataSource.FetchAsync(missingRanges, ct) ◄──────────┐ │ -│ 3. return UnionAll(current, fetched) (merge, no trim) │ │ -│ │ │ -│ Shared by: │ │ -│ ├─ UserRequestHandler (extend to cover requested range — no mutation) │ │ -│ └─ RebalanceExecutor (extend to desired range — feeds mutation) │ │ -└───────────────────────────────────────────────────────────────────────────┼──┘ - │ -┌────────────────────────────────────────────────────────────────────────────▼──┐ -│ IDataSource [External Data Source] │ -│ 🟧 INTERFACE (user-implemented) │ -│ │ -│ Methods: │ -│ ├─ FetchAsync(Range, CT) → Task> │ -│ └─ FetchAsync(IEnumerable, CT) → Task> │ -│ │ -│ Characteristics: │ -│ ├─ User-provided implementation │ -│ ├─ May perform I/O (network, disk, database) │ -│ ├─ Read-only (fetches data) │ -│ └─ Should respect CancellationToken │ -└───────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Read/Write Patterns - -### CacheState (⚠️ Shared Mutable State) - -#### Writers - -**RebalanceExecutor** (SOLE WRITER - single-writer architecture): -- ✏️ Writes `Cache` (via `Rematerialize()`) - - **Purpose**: Normalize cache to DesiredCacheRange using delivered data from intent - - **When**: Rebalance execution completes (background) - - **Scope**: Expands, trims, or replaces cache as needed -- ✏️ Writes `IsInitialized` property - - **Purpose**: Mark cache as initialized after first successful rebalance - - **When**: After successful rebalance execution -- ✏️ Writes `NoRebalanceRange` property - - **Purpose**: Update threshold zone after normalization - - **When**: After successful rebalance execution - -**UserRequestHandler** (READ-ONLY): -- ❌ Does NOT write to CacheState -- ❌ Does NOT call `Cache.Rematerialize()` -- ❌ Does NOT write to `IsInitialized` or `NoRebalanceRange` -- ✅ Only reads from cache and IDataSource -- ✅ Publishes intent with delivered data for Rebalance Execution to process - -#### Readers - -**UserRequestHandler**: -- 👁️ Reads `Cache.Range` - Check if cache covers requested range -- 👁️ Reads `Cache.Read(range)` - Return data to user -- 👁️ Reads `Cache.ToRangeData()` - Get snapshot before extending - -**RebalanceDecisionEngine** (via IntentController.ProcessIntentsAsync): -- 👁️ Reads `NoRebalanceRange` - Decision logic (check if rebalance needed) - -**RebalanceExecutor**: -- 👁️ Reads `Cache.Range` - Check if already at desired range -- 👁️ Reads `Cache.ToRangeData()` - Get snapshot before normalizing - -#### Coordination - -**No locks** (by design): -- **Single-writer architecture** - User Path is read-only, only Rebalance Execution writes -- **Single consumer model** - one logical user per cache instance -- Coordination via **validation-driven cancellation** (DecisionEngine confirms necessity, triggers cancellation) -- Rebalance **always checks** cancellation before mutations (yields to new rebalance if needed) - -**Thread-Safety Through Architecture:** -- No write-write races (only one writer exists) -- Reference reads are atomic (User Path safely reads while Rebalance may execute) -- `Rematerialize()` performs atomic reference swaps (array/List assignment) -- `internal set` on CacheState properties restricts write access to internal components - -**Atomic operations**: -- `Rematerialize()` replaces storage atomically (array/list assignment) -- Property writes are atomic (reference assignment) - ---- - -### CancellationTokenSource (Execution Cancellation) - -#### Owner: IntentController.ProcessIntentsAsync (via ExecutionRequest) - -**Creates**: -- In `ProcessIntentsAsync()` — new `CancellationTokenSource` for each `ExecutionRequest` enqueued - -**Cancels**: -- In `ProcessIntentsAsync()` — cancels `lastExecutionRequest` before enqueuing a new one -- Prevents stale execution from completing after a newer execution has been scheduled - -**Disposes**: -- `ExecutionRequest` lifetime — disposed when execution completes or is superseded - -#### Users - -**IRebalanceExecutionController implementations**: -- 👁️ Receive `CancellationToken` via `ExecutionRequest` -- 👁️ Pass token to `Task.Delay()` (cancellable debounce) -- 👁️ Pass token to `RebalanceExecutor.ExecuteAsync()` - -**RebalanceExecutor**: -- 👁️ Receives token from `IRebalanceExecutionController` (via `ExecutionRequest`) -- 👁️ Calls `ThrowIfCancellationRequested()` at multiple points: - 1. After acquiring semaphore, before I/O - 2. After `ExtendCacheAsync()`, before trim - 3. Before `Rematerialize()` (prevent applying obsolete results) - -**CacheDataExtensionService**: -- 👁️ Receives token from caller (UserRequestHandler or RebalanceExecutor) -- 👁️ Passes token to `IDataSource.FetchAsync()` (cancellable I/O) - ---- - -## Thread Safety Model - -### Concurrency Philosophy - -The Sliding Window Cache follows a **single consumer model** as documented in `docs/architecture-model.md`: - -> "A cache instance is **not thread-safe**, is **not designed for concurrent access**, and assumes a single, coherent access pattern. This is an **ideological requirement**, not merely an architectural or technical limitation." - -### Key Principles - -1. **Single Logical Consumer** - - One cache instance = one user - - One access trajectory - - One temporal sequence of requests - -2. **Execution Serialization** - - ✅ Uses `SemaphoreSlim(1, 1)` in `RebalanceExecutor` for execution serialization - - ❌ No locks (`lock`, `Monitor`) - - ❌ No concurrent collections - - ✅ `CancellationToken` for coordination and signaling - - ✅ `Interlocked.Exchange` for atomic pending rebalance cancellation - -3. **Coordination Mechanism** - - **Single-Writer Architecture** - User Path is read-only, only Rebalance Execution writes to CacheState - - **Validation-driven cancellation** - DecisionEngine confirms necessity, then triggers cancellation of pending rebalance - - **Atomic updates** - `Rematerialize()` performs atomic array/List reference swaps - - **Execution serialization** - `SemaphoreSlim` ensures only one rebalance writes to cache at a time - - **Atomic cancellation** - `Interlocked.Exchange` prevents race conditions during pending rebalance cancellation - -### Thread Contexts - -| Component | Thread Context | Notes | -|------------------------------------------------------------------------------|-------------------|-----------------------------------------------------------------------| -| **WindowCache** | Neutral | Just delegates | -| **UserRequestHandler** | ⚡ **User Thread** | Synchronous, fast path (user request handling) | -| **IntentController.PublishIntent()** | ⚡ **User Thread** | Atomic intent storage + semaphore signal (fire-and-forget) | -| **IntentController.ProcessIntentsAsync()** | 🔄 **Background** | Intent processing loop, invokes DecisionEngine | -| **RebalanceDecisionEngine** | 🔄 **Background** | Invoked in intent processing loop, CPU-only logic | -| **ProportionalRangePlanner** | 🔄 **Background** | Invoked by DecisionEngine in intent processing loop | -| **NoRebalanceRangePlanner** | 🔄 **Background** | Invoked by DecisionEngine in intent processing loop | -| **NoRebalanceSatisfactionPolicy** | 🔄 **Background** | Invoked by DecisionEngine in intent processing loop | -| **IRebalanceExecutionController.PublishExecutionRequest()** | 🔄 **Background** | Invoked by intent loop (task-based: sync, channel-based: async await) | -| **TaskBasedRebalanceExecutionController.ChainExecutionAsync()** | 🔄 **Background** | Task chain execution (sequential) | -| **ChannelBasedRebalanceExecutionController.ProcessExecutionRequestsAsync()** | 🔄 **Background** | Channel loop execution | -| **RebalanceExecutor** | 🔄 **Background** | ThreadPool, async, I/O | -| **CacheDataExtensionService** | Both ⚡🔄 | User Thread OR Background | -| **CacheState** | Both ⚡🔄 | Shared mutable (no locks!) | -| **Storage (Snapshot/CopyOnRead)** | Both ⚡🔄 | Owned by CacheState | - -**Critical:** PublishIntent() is a **synchronous operation in user thread** (atomic ops only, no decision logic). Decision logic (DecisionEngine, Planners, Policy) executes in **background intent processing loop**. Rebalance execution (I/O) happens in **separate background execution loop**. - -### Concurrency Invariants (from `docs/invariants.md`) - -**A.1 Concurrency & Priority**: -- **-1**: User Path and Rebalance Execution **never write to cache concurrently** (User Path is read-only, single-writer architecture) -- **0**: User Path **always has higher priority** than Rebalance Execution (enforced via validation-driven cancellation) -- **0a**: User Request **MAY cancel** ongoing/pending Rebalance **ONLY when DecisionEngine validation confirms new rebalance is necessary** - -**C. Rebalance Intent & Temporal Invariants**: -- **17**: At most **one active rebalance intent** -- **18**: Previous intents may become **logically superseded** when validation confirms new rebalance necessary -- **21**: At most **one rebalance execution** active at any time - -**Key Correction:** User Path does NOT cancel before its own mutations. User Path is **read-only** - it never mutates cache. Cancellation is triggered by validation confirming necessity, not automatically by user requests. - -### How It Works - ---- - -### Threading Model - Complete Flow Diagram - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ PHASE 1: USER THREAD (Synchronous - Fast Path) │ -├─────────────────────────────────────────────────────────────────────────┤ -│ Component │ Operation │ -├────────────────────────────┼────────────────────────────────────────────┤ -│ WindowCache.GetDataAsync() │ Entry point (user-facing API) │ -│ ↓ │ │ -│ UserRequestHandler │ • Read cache state (read-only) │ -│ .HandleRequestAsync() │ • Fetch missing data from IDataSource │ -│ │ • Assemble result data │ -│ │ • Call IntentController.PublishIntent() │ -│ ↓ │ │ -│ IntentController │ • Interlocked.Exchange(_pendingIntent) │ -│ .PublishIntent() │ • _intentSignal.Release() (signal) │ -│ │ • Return immediately (fire-and-forget) │ -│ ↓ │ │ -│ Return data to user │ ← USER THREAD BOUNDARY ENDS HERE │ -└─────────────────────────────────────────────────────────────────────────┘ - ↓ (semaphore signal) -┌─────────────────────────────────────────────────────────────────────────┐ -│ PHASE 2: BACKGROUND THREAD #1 (Intent Processing Loop) │ -├─────────────────────────────────────────────────────────────────────────┤ -│ Component │ Operation │ -├──────────────────────────────────┼──────────────────────────────────────┤ -│ IntentController │ • await _intentSignal.WaitAsync() │ -│ .ProcessIntentsAsync() │ • Interlocked.Exchange(_pendingIntent│ -│ (infinite background loop) │ • Read intent atomically │ -│ ↓ │ │ -│ RebalanceDecisionEngine │ Stage 1: Current NoRebalanceRange chk│ -│ .Evaluate() │ Stage 2: Pending NoRebalanceRange chk│ -│ ├─ Stage 3 ────────────────→ │ • ProportionalRangePlanner.Plan() │ -│ │ │ • NoRebalanceRangePlanner.Plan() │ -│ ├─ NoRebalanceSatisfactionPolicy│ Stage 4: Equality check │ -│ └─ Return Decision │ Stage 5: Return decision │ -│ ↓ │ │ -│ If Skip: continue loop │ • Diagnostics event │ -│ If Execute: ↓ │ │ -│ ↓ │ │ -│ Cancel previous execution │ • lastExecutionRequest?.Cancel() │ -│ ↓ │ │ -│ IRebalanceExecutionController │ • Create ExecutionRequest │ -│ .PublishExecutionRequest() │ • Task-based: Volatile.Write (sync) │ -│ │ • Channel-based: await WriteAsync() │ -└─────────────────────────────────────────────────────────────────────────┘ - ↓ (strategy-specific) -┌─────────────────────────────────────────────────────────────────────────┐ -│ PHASE 3: BACKGROUND EXECUTION (Strategy-Specific) │ -├─────────────────────────────────────────────────────────────────────────┤ -│ Component │ Operation │ -├──────────────────────────────────┼──────────────────────────────────────┤ -│ TASK-BASED STRATEGY: │ │ -│ ChainExecutionAsync() │ • await previousTask │ -│ (chained async method) │ • await ExecuteRequestAsync() │ -│ ↓ │ │ -│ OR CHANNEL-BASED STRATEGY: │ │ -│ ProcessExecutionRequestsAsync() │ • await foreach (channel read) │ -│ (infinite background loop) │ • Sequential processing │ -│ ↓ │ │ -│ ExecuteRequestAsync() │ • await Task.Delay(debounce) │ -│ (both strategies) │ • Cancellation check │ -│ ↓ │ │ -│ RebalanceExecutor │ • Extend cache data (I/O) │ -│ .ExecuteAsync() │ • Trim to desired range │ -│ │ • ┌──────────────────────────┐ │ -│ │ │ CACHE MUTATION │ │ -│ │ │ (SINGLE WRITER) │ │ -│ │ │ • Cache.Rematerialize() │ │ -│ │ │ • IsInitialized = true │ │ -│ │ │ • NoRebalanceRange = ... │ │ -│ │ └──────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -**Key Threading Boundaries:** - -1. **User Thread Boundary**: Ends at `PublishIntent()` return - - Everything before: Synchronous, blocking user request - - `PublishIntent()`: Atomic ops only (microseconds), returns immediately - -2. **Background Thread #1**: Intent processing loop - - Single dedicated thread via semaphore wait loop - - Processes intents sequentially (one at a time) - - CPU-only decision logic (microseconds) - - No I/O operations - -3. **Background Execution**: Strategy-specific serialization - - **Task-based**: Chained async methods on ThreadPool (await previousTask pattern) - - **Channel-based**: Single dedicated loop via channel reader (sequential processing) - - Both: Process execution requests sequentially (one at a time) - - I/O operations (milliseconds to seconds) - - SOLE writer to cache state (single-writer architecture) - -**Concurrency Guarantees:** - -- ✅ User requests NEVER block on decision evaluation -- ✅ User requests NEVER block on rebalance execution -- ✅ At most ONE decision evaluation active at a time (sequential loop processing) -- ✅ At most ONE rebalance execution active at a time (sequential loop processing) -- ✅ Cache mutations are SERIALIZED (single-writer via sequential processing) -- ✅ No race conditions on cache state (read-only user path + single writer) - ---- - - -#### User Request Flow (User Thread — until PublishIntent returns) -``` -1. UserRequestHandler.HandleRequestAsync() called -2. Read from cache or fetch missing data from IDataSource (READ-ONLY) -3. Assemble data to return to user (NO cache mutation) -4. PublishIntent(intent) in user thread: - └─> IntentController.PublishIntent(intent) ⚡ USER THREAD - ├─> Interlocked.Exchange(_pendingIntent, intent) (atomic, O(1)) - ├─> _activityCounter.IncrementActivity() - └─> _intentSignal.Release() → wakes background loop - └─> Returns immediately -5. Return assembled data to user - ---- BACKGROUND LOOP (ProcessIntentsAsync) --- - -6. _intentSignal.WaitAsync() unblocks 🔄 BACKGROUND -7. Interlocked.Exchange(_pendingIntent, null) → reads intent -8. DecisionEngine.Evaluate() 🔄 BACKGROUND - └─> 5-stage validation (CPU-only, side-effect free) - - Stage 1: CurrentNoRebalanceRange check - - Stage 2: PendingNoRebalanceRange check - - Stage 3: Compute DesiredRange + DesiredNoRebalanceRange - - Stage 4: DesiredRange == CurrentRange check - - Stage 5: Schedule -9. If validation rejects: continue loop (work avoidance) -10. If schedule: lastRequest?.Cancel() + PublishExecutionRequest() - ---- EXECUTION (IRebalanceExecutionController) --- - -11. Debounce delay (Task.Delay) 🔄 BACKGROUND -12. RebalanceExecutor.ExecuteAsync() 🔄 BACKGROUND - └─> I/O operations + atomic cache mutations -``` - -**Key:** Decision evaluation happens in **background loop** (NOT in user thread). -User thread only does atomic store + semaphore signal and returns immediately. - -**Why This Matters:** -- User request burst → latest intent wins via `Interlocked.Exchange` → burst resistance -- Decision loop processes serially → no concurrent thrashing -- User thread is never blocked by decision evaluation or I/O - -#### Rebalance Flow (Background Thread) -``` -1. RebalanceScheduler.ScheduleRebalance() in Task.Run() -2. await Task.Delay() - cancellable debounce -3. Check IsCancellationRequested - early exit if cancelled -4. DecisionEngine.ShouldExecuteRebalance() - pure logic -5. RebalanceExecutor.ExecuteAsync() - ├─ ThrowIfCancellationRequested() before I/O - ├─ await _dataSource.FetchAsync() - cancellable I/O - ├─ ThrowIfCancellationRequested() after I/O - ├─ Trim data - ├─ ThrowIfCancellationRequested() before mutation - └─ Rematerialize() - atomic cache update -``` - -### Multi-User Scenarios - -**✅ Correct Approach**: -```csharp -// Create one cache instance per user -var userCache1 = new WindowCache(...); -var userCache2 = new WindowCache(...); -``` - -**❌ Incorrect Approach**: -```csharp -// DO NOT share cache across threads/users -var sharedCache = new WindowCache(...); -// Thread 1: sharedCache.GetDataAsync() - UNSAFE -// Thread 2: sharedCache.GetDataAsync() - UNSAFE -``` - -### Safety Guarantees - -**Provided**: -- ✅ User Path never waits for rebalance -- ✅ User Path always has priority (cancels rebalance) -- ✅ At most one rebalance execution active -- ✅ Obsolete rebalance results are discarded -- ✅ Cache state remains consistent (atomic Rematerialize) - -**Not Provided**: -- ❌ Thread-safe concurrent access (by design) -- ❌ Multiple consumers per cache (model violation) -- ❌ Cross-user sliding window arbitration (nonsensical) - ---- - -## Type Summary Tables - -### Reference Types (Classes) - -| Component | Mutability | Shared State | Ownership | Lifetime | -|---------------------------|----------------------------------------------|--------------|--------------------------|----------------| -| WindowCache | Immutable (after ctor) | No | User creates | App lifetime | -| UserRequestHandler | Immutable | No | WindowCache owns | Cache lifetime | -| CacheState | **Mutable** | **Yes** ⚠️ | WindowCache owns, shared | Cache lifetime | -| IntentController | Mutable (_pendingRebalance) | No | WindowCache owns | Cache lifetime | -| RebalanceScheduler | Immutable | No | IntentController owns | Cache lifetime | -| RebalanceDecisionEngine | Immutable | No | WindowCache owns | Cache lifetime | -| RebalanceExecutor | Immutable | No | WindowCache owns | Cache lifetime | -| CacheDataExtensionService | Immutable | No | WindowCache owns | Cache lifetime | -| SnapshotReadStorage | **Mutable** (_storage array) | No | CacheState owns | Cache lifetime | -| CopyOnReadStorage | **Mutable** (_activeStorage, _stagingBuffer) | No | CacheState owns | Cache lifetime | - -### Value Types (Structs) - -| Component | Mutability | Ownership | Lifetime | -|-------------------------------|------------|------------------------|--------------------| -| NoRebalanceSatisfactionPolicy | Readonly | Copied into components | Component lifetime | -| ProportionalRangePlanner | Readonly | Copied into components | Component lifetime | -| NoRebalanceRangePlanner | Readonly | Copied into components | Component lifetime | -| RebalanceDecision | Readonly | Local variable | Method scope | - -### Other Types - -| Component | Type | Purpose | Mutability | -|--------------------|--------------|------------------------|------------| -| WindowCacheOptions | 🟨 Record | Configuration | Immutable | -| RangeChunk | 🟨 Record | Data transfer | Immutable | -| Intent | 🟨 Record | Intent data container | Immutable | -| UserCacheReadMode | 🟪 Enum | Configuration option | Immutable | -| ICacheStorage | 🟧 Interface | Storage abstraction | - | -| IDataSource | 🟧 Interface | External data contract | - | - ---- - -## Component Responsibilities Summary - -### By Execution Context - -**User Thread (Synchronous, Fast)**: -- WindowCache - Facade, delegates to UserRequestHandler -- UserRequestHandler - Serves user requests, publishes intents (fire-and-forget) -- IntentController.PublishIntent - Intent publishing (fire-and-forget, returns immediately) - -**Background Thread (Intent Processing Loop)**: -- IntentController.ProcessIntentsAsync - Intent processing loop, decision orchestration -- RebalanceDecisionEngine - Pure decision logic (CPU-only, deterministic) -- NoRebalanceSatisfactionPolicy - Threshold validation (value type, inline) -- ProportionalRangePlanner - Cache geometry planning (value type, inline) - -**Background ThreadPool (Execution)**: -- RebalanceExecutor - Cache normalization, I/O operations - -**Both Contexts**: -- CacheDataExtensionService - Data fetching (called by both paths) -- CacheState - Shared mutable state (accessed by both) - -### By Responsibility - -**Data Serving**: -- WindowCache (facade) -- UserRequestHandler (implementation) -- CacheState (storage) -- ICacheStorage implementations (actual data) - -**Intent Management**: -- IntentController (lifecycle) -- RebalanceScheduler (execution) - -**Decision Making**: -- RebalanceDecisionEngine (orchestrator) -- NoRebalanceSatisfactionPolicy (thresholds) -- ProportionalRangePlanner (geometry) - -**Mutation**: -- UserRequestHandler (expand only) -- RebalanceExecutor (normalize: expand + trim) - -**Data Fetching**: -- CacheDataExtensionService (internal) -- IDataSource (external, user-provided) - ---- - -## Architectural Patterns Used - -### 1. Facade Pattern -**WindowCache** acts as a facade that hides internal complexity and provides a simple public API. - -### 2. Composition Root -**WindowCache** constructor wires all components together in one place. - -### 3. Actor Model (Conceptual) -Components follow actor-like patterns with clear responsibilities and message passing (method calls). - -### 4. Intent Controller Pattern -**IntentController** manages versioned, cancellable operations through CancellationTokenSource identity. - -### 5. Strategy Pattern -**ICacheStorage** with two implementations (SnapshotReadStorage, CopyOnReadStorage) allows runtime selection of storage strategy. - -### 6. Value Object Pattern -**NoRebalanceSatisfactionPolicy**, **ProportionalRangePlanner**, **RebalanceDecision** are immutable value types with pure behavior. - -### 7. Shared Mutable State (Controlled) -**CacheState** is intentionally shared mutable state, coordinated via CancellationToken (not locks). - -### 8. Single Consumer Model -Entire architecture assumes one logical consumer, avoiding traditional concurrency primitives. - ---- - -## Related Documentation - -- **Architecture Overview**: `docs/actors-to-components-mapping.md` -- **Responsibilities**: `docs/actors-and-responsibilities.md` -- **Invariants**: `docs/invariants.md` -- **Scenarios**: `docs/scenario-model.md` -- **State Machine**: `docs/cache-state-machine.md` -- **Architecture Model**: `docs/architecture-model.md` -- **Storage Strategies**: `docs/storage-strategies.md` -- **Cache Hit/Miss Tracking**: `docs/cache-hit-miss-tracking-implementation.md` - ---- - -## Conclusion - -The Sliding Window Cache is composed of **19 components** working together to provide fast, cache-aware data access with automatic rebalancing: - -- **10 classes** (reference types) provide the runtime behavior -- **4 structs** (value types) provide pure, stateless logic -- **2 interfaces** define contracts for extensibility -- **3 records** provide immutable configuration and data transfer -- **1 enum** defines storage strategy options - -The architecture follows a **single consumer model** with **no traditional synchronization primitives**, relying instead on **CancellationToken** for coordination between the fast User Path and the async Rebalance Path. - -All components are designed with **clear ownership**, **explicit read/write patterns**, and **well-defined responsibilities**, making the system predictable, testable, and maintainable. diff --git a/docs/components/decision.md b/docs/components/decision.md new file mode 100644 index 0000000..b432a09 --- /dev/null +++ b/docs/components/decision.md @@ -0,0 +1,80 @@ +# Components: Decision + +## Overview + +The decision subsystem determines whether a rebalance execution is necessary. It is analytical (CPU-only), deterministic, and has no side effects. + +## Key Components + +- `RebalanceDecisionEngine` — sole authority for rebalance necessity +- `RebalanceDecision` / `RebalanceReason` — decision result value types +- `ProportionalRangePlanner` — computes `DesiredCacheRange` +- `NoRebalanceSatisfactionPolicy` — checks `NoRebalanceRange` containment +- `NoRebalanceRangePlanner` — computes `NoRebalanceRange` + +## Responsibilities + +- Sole authority for rebalance necessity determination. +- Compute desired cache geometry from the request and configuration. +- Apply work-avoidance checks (stability zone, pending coverage, no-op geometry). + +## Non-Responsibilities + +- Does not schedule execution (publishes only a decision). +- Does not mutate cache state. +- Does not call `IDataSource`. + +--- + +## Multi-Stage Validation Pipeline + +`RebalanceDecisionEngine` runs a five-stage pipeline. All stages must pass for execution to be scheduled. Any stage may return an early-exit skip decision. + +| Stage | Component | Check | Skip Condition | +|-------|--------------------------------------------------------|--------------------------------------------------------------------------|-----------------------------------------------------------------------| +| **1** | `NoRebalanceSatisfactionPolicy` | Is `RequestedRange` inside `NoRebalanceRange(CurrentCacheRange)`? | Yes → skip (current cache provides sufficient buffer) | +| **2** | `NoRebalanceSatisfactionPolicy` | Is `RequestedRange` inside `NoRebalanceRange(PendingDesiredCacheRange)`? | Yes → skip (pending execution will cover request; prevents thrashing) | +| **3** | `ProportionalRangePlanner` + `NoRebalanceRangePlanner` | Compute `DesiredCacheRange` and `DesiredNoRebalanceRange` | — | +| **4** | `RebalanceDecisionEngine` | Is `DesiredCacheRange == CurrentCacheRange`? | Yes → skip (cache already optimal; no mutation needed) | +| **5** | — | All stages passed | Return `Schedule(desiredRange, desiredNRR)` | + +**Execution rule**: Rebalance executes ONLY if all five stages confirm necessity. + +## Component Responsibilities in Decision Model + +| Component | Role | Decision Authority | +|---------------------------------|-----------------------------------------------------------|-------------------------| +| `UserRequestHandler` | Read-only; publishes intents with delivered data | None | +| `IntentController` | Manages intent lifecycle; runs background processing loop | None | +| `IRebalanceExecutionController` | Debounce + execution serialization | None | +| `RebalanceDecisionEngine` | **SOLE AUTHORITY** for necessity determination | **Yes — THE authority** | +| `NoRebalanceSatisfactionPolicy` | Stages 1 & 2 validation (NoRebalanceRange check) | Analytical input | +| `ProportionalRangePlanner` | Stage 3: computes desired cache geometry | Analytical input | +| `RebalanceExecutor` | Mechanical execution; assumes validated necessity | None | + +## System Stability Principle + +The system prioritizes **decision correctness and work avoidance** over aggressive rebalance responsiveness, enabling smart eventual consistency. + +**Work avoidance mechanisms:** +- Stage 1: Avoid rebalance if current cache provides sufficient buffer (NoRebalanceRange containment) +- Stage 2: Avoid redundant rebalance if pending execution will cover the request (anti-thrashing) +- Stage 4: Avoid no-op mutations if cache already in optimal configuration (Desired == Current) + +**Trade-offs:** +- ✅ Prevents thrashing and oscillation (stability over aggressive responsiveness) +- ✅ Reduces redundant I/O operations (efficiency through validation) +- ✅ System remains stable under rapidly changing access patterns +- ⚠️ May delay cache optimization by debounce period (acceptable for stability) + +**Characteristics of all decision components:** +- Stateless (both planners and the policy are `readonly struct` value types) +- Pure functions: same inputs → same output, no side effects +- CPU-only: no I/O, no state mutation +- Fully synchronous: no async operations + +## See Also + +- `docs/invariants.md` — formal Decision Path invariant specifications (D.24–D.29) +- `docs/architecture.md` — Decision-Driven Execution section +- `docs/components/overview.md` — Invariant Implementation Mapping (Decision subsection) diff --git a/docs/components/execution.md b/docs/components/execution.md new file mode 100644 index 0000000..3127ce4 --- /dev/null +++ b/docs/components/execution.md @@ -0,0 +1,117 @@ +# Components: Execution + +## Overview + +The execution subsystem performs debounced, cancellable background work and is the **only path allowed to mutate shared cache state** (single-writer invariant). It receives validated execution requests from `IntentController` and ensures single-flight, eventually-consistent cache updates. + +## Key Components + +| Component | File | Role | +|--------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|--------------------------------------------------------------------| +| `IRebalanceExecutionController` | `src/SlidingWindowCache/Infrastructure/Execution/IRebalanceExecutionController.cs` | Execution serialization contract | +| `TaskBasedRebalanceExecutionController` | `src/SlidingWindowCache/Infrastructure/Execution/TaskBasedRebalanceExecutionController.cs` | Default: Task.Run-based debounce + cancellation | +| `ChannelBasedRebalanceExecutionController` | `src/SlidingWindowCache/Infrastructure/Execution/ChannelBasedRebalanceExecutionController.cs` | Optional: Channel-based bounded execution queue | +| `RebalanceExecutor` | `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` | Sole writer; performs `Rematerialize`; the single-writer authority | +| `CacheDataExtensionService` | `src/SlidingWindowCache/Infrastructure/Services/CacheDataExtensionService.cs` | Incremental data fetching; range gap analysis | + +## Execution Controllers + +### TaskBasedRebalanceExecutionController (default) + +- Uses `Task.Run` with debounce delay and `CancellationTokenSource` +- On each new execution request: cancels previous task, starts new task after debounce +- Selected when `WindowCacheOptions.RebalanceQueueCapacity` is `null` + +### ChannelBasedRebalanceExecutionController (optional) + +- Uses `System.Threading.Channels.Channel` with bounded capacity +- Provides backpressure semantics; oldest unprocessed request may be dropped on overflow +- Selected when `WindowCacheOptions.RebalanceQueueCapacity` is set + +**Strategy comparison:** + +| Aspect | TaskBased | ChannelBased | +|--------------|----------------------------|------------------------| +| Debounce | Per-request delay | Channel draining | +| Backpressure | None | Bounded capacity | +| Cancellation | CancellationToken per task | Token per channel item | +| Default | ✅ Yes | No | + +## RebalanceExecutor — Single Writer + +`RebalanceExecutor` is the **sole authority** for cache mutations. All other components are read-only with respect to `CacheState`. + +**Execution flow:** + +1. `ThrowIfCancellationRequested` — before any I/O (pre-I/O checkpoint) +2. Compute desired range gaps: `DesiredRange \ CurrentCacheRange` +3. Call `CacheDataExtensionService.ExtendCacheDataAsync` — fetches only missing subranges +4. `ThrowIfCancellationRequested` — after I/O, before mutations (pre-mutation checkpoint) +5. Call `CacheState.Rematerialize(newRangeData)` — atomic cache update +6. Update `CacheState.NoRebalanceRange` — new stability zone +7. Set `CacheState.IsInitialized = true` (if first execution) + +**Cancellation checkpoints** (Invariant F.35): +- Before I/O: avoids unnecessary fetches +- After I/O: discards fetched data if superseded +- Before mutation: guarantees only latest validated execution applies changes + +## CacheDataExtensionService — Incremental Fetching + +**File**: `src/SlidingWindowCache/Infrastructure/Services/CacheDataExtensionService.cs` + +- Computes missing ranges via range algebra: `DesiredRange \ CachedRange` +- Fetches only the gaps (not the full desired range) +- Merges new data with preserved existing data (union operation) +- Propagates `CancellationToken` to `IDataSource.FetchAsync` + +**Invariants**: F.38 (incremental fetching), F.39 (data preservation during expansion). + +## Responsibilities + +- Debounce validated execution requests (burst resistance via delay or channel) +- Ensure single-flight rebalance execution (cancel obsolete work; serialize new work) +- Fetch missing data incrementally from `IDataSource` (gaps only) +- Apply atomic cache update (`Rematerialize`) +- Maintain cancellation checkpoints to preserve cache consistency + +## Non-Responsibilities + +- Does **not** decide whether to rebalance — decision is validated upstream by `RebalanceDecisionEngine` before this subsystem is invoked. +- Does **not** publish intents. +- Does **not** serve user requests. + +## Exception Handling + +Exceptions in `RebalanceExecutor` are caught by `IntentController.ProcessIntentsAsync` and reported via `ICacheDiagnostics.RebalanceExecutionFailed`. They are **never propagated to the user thread**. + +> ⚠️ Always wire `RebalanceExecutionFailed` in production — it is the only signal for background execution failures. See `docs/diagnostics.md`. + +## Invariants + +| Invariant | Description | +|-----------|---------------------------------------------------------------------------| +| A.7 | Only `RebalanceExecutor` writes to `CacheState` (single-writer) | +| A.8 | User path never blocks waiting for rebalance | +| B.12 | Cache updates are atomic (all-or-nothing via `Rematerialize`) | +| B.13 | Consistency under cancellation: mutations discarded if cancelled | +| B.15 | Cache contiguity maintained after every `Rematerialize` | +| B.16 | Obsolete results never applied (cancellation token identity check) | +| C.21 | Serial execution: at most one active rebalance at a time | +| F.35 | Multiple cancellation checkpoints: before I/O, after I/O, before mutation | +| F.35a | Cancellation-before-mutation guarantee | +| F.37 | `Rematerialize` accepts arbitrary range and data (full replacement) | +| F.38 | Incremental fetching: only missing subranges fetched | +| F.39 | Data preservation: existing cached data merged during expansion | +| G.45 | I/O isolation: `IDataSource` only called on background thread | +| H.47 | Activity counter incremented before channel write / Task.Run | +| H.48 | Activity counter decremented in `finally` blocks | + +See `docs/invariants.md` (Sections A, B, C, F, G, H) for full specification. + +## See Also + +- `docs/components/state-and-storage.md` — `CacheState` and storage strategy internals +- `docs/components/decision.md` — what validation happens before execution is enqueued +- `docs/invariants.md` — Sections B (state invariants) and F (execution invariants) +- `docs/diagnostics.md` — observing execution lifecycle events diff --git a/docs/components/infrastructure.md b/docs/components/infrastructure.md new file mode 100644 index 0000000..5cbdcf5 --- /dev/null +++ b/docs/components/infrastructure.md @@ -0,0 +1,216 @@ +# Components: Infrastructure + +## Overview + +Infrastructure components support storage, state publication, diagnostics, and coordination. + +## Motivation + +Cross-cutting concerns must be explicit so that core logic stays simple and invariants remain enforceable. + +## Design + +### Key Components + +- `CacheState` (shared mutable state; mutated only by execution) +- `Cache` / storage strategy implementations +- `WindowCacheOptions` (public configuration) +- `ICacheDiagnostics` (optional instrumentation) +- `AsyncActivityCounter` (idle detection powering `WaitForIdleAsync`) + +### Storage Strategies + +Storage strategy trade-offs are documented in `docs/storage-strategies.md`. Component docs here only describe where storage plugs into the system. + +### Diagnostics + +Diagnostics are specified in `docs/diagnostics.md`. Component docs here only describe how diagnostics is wired and when events are emitted. + +--- + +## Thread Safety Model + +### Concurrency Philosophy + +The Sliding Window Cache follows a **single consumer model** (see `docs/architecture.md`): + +> A cache instance is designed for one logical consumer — one user, one access trajectory, one temporal sequence of requests. This is an ideological requirement, not merely a technical limitation. + +### Key Principles + +1. **Single Logical Consumer**: One cache instance = one user, one coherent access pattern +2. **Execution Serialization**: `SemaphoreSlim(1, 1)` in `RebalanceExecutor` for execution mutual exclusion; `Interlocked.Exchange` for atomic pending rebalance cancellation; no `lock` or `Monitor` +3. **Coordination Mechanism**: Single-writer architecture (User Path is read-only, only Rebalance Execution writes to `CacheState`); validation-driven cancellation (`DecisionEngine` confirms necessity then triggers cancellation); atomic updates via `Rematerialize()` (atomic array/List reference swap) + +### Thread Contexts + +| Component | Thread Context | Notes | +|----------------------------------------------------------------------------|----------------|------------------------------------------------------------| +| `WindowCache` | Neutral | Just delegates | +| `UserRequestHandler` | ⚡ User Thread | Synchronous, fast path | +| `IntentController.PublishIntent()` | ⚡ User Thread | Atomic intent storage + semaphore signal (fire-and-forget) | +| `IntentController.ProcessIntentsAsync()` | 🔄 Background | Intent processing loop; invokes `DecisionEngine` | +| `RebalanceDecisionEngine` | 🔄 Background | CPU-only; runs in intent processing loop | +| `ProportionalRangePlanner` | 🔄 Background | Invoked by `DecisionEngine` | +| `NoRebalanceRangePlanner` | 🔄 Background | Invoked by `DecisionEngine` | +| `NoRebalanceSatisfactionPolicy` | 🔄 Background | Invoked by `DecisionEngine` | +| `IRebalanceExecutionController.PublishExecutionRequest()` | 🔄 Background | Task-based: sync; channel-based: async await | +| `TaskBasedRebalanceExecutionController.ChainExecutionAsync()` | 🔄 Background | Task chain execution (sequential) | +| `ChannelBasedRebalanceExecutionController.ProcessExecutionRequestsAsync()` | 🔄 Background | Channel loop execution | +| `RebalanceExecutor` | 🔄 Background | ThreadPool, async, I/O | +| `CacheDataExtensionService` | Both ⚡🔄 | User Thread OR Background | +| `CacheState` | Both ⚡🔄 | Shared mutable (no locks; single-writer) | +| Storage (`Snapshot`/`CopyOnRead`) | Both ⚡🔄 | Owned by `CacheState` | + +**Critical:** `PublishIntent()` is a synchronous user-thread operation (atomic ops only, no decision logic). Decision logic (`DecisionEngine`, planners, policy) executes in the **background intent processing loop**. Rebalance execution (I/O) happens in a **separate background execution loop**. + +### Complete Flow Diagram + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ PHASE 1: USER THREAD (Synchronous — Fast Path) │ +├──────────────────────────────────────────────────────────────────────┤ +│ WindowCache.GetDataAsync() — entry point (user-facing API) │ +│ ↓ │ +│ UserRequestHandler.HandleRequestAsync() │ +│ • Read cache state (read-only) │ +│ • Fetch missing data from IDataSource (if needed) │ +│ • Assemble result data │ +│ • Call IntentController.PublishIntent() │ +│ ↓ │ +│ IntentController.PublishIntent() │ +│ • Interlocked.Exchange(_pendingIntent, intent) (O(1)) │ +│ • _activityCounter.IncrementActivity() │ +│ • _intentSignal.Release() (signal background loop) │ +│ • Return immediately │ +│ ↓ │ +│ Return data to user ← USER THREAD BOUNDARY ENDS HERE │ +└──────────────────────────────────────────────────────────────────────┘ + ↓ (semaphore signal) +┌──────────────────────────────────────────────────────────────────────┐ +│ PHASE 2: BACKGROUND THREAD #1 (Intent Processing Loop) │ +├──────────────────────────────────────────────────────────────────────┤ +│ IntentController.ProcessIntentsAsync() (infinite loop) │ +│ • await _intentSignal.WaitAsync() │ +│ • Interlocked.Exchange(_pendingIntent, null) → read intent │ +│ ↓ │ +│ RebalanceDecisionEngine.Evaluate() │ +│ Stage 1: Current NoRebalanceRange check (fast-path skip) │ +│ Stage 2: Pending NoRebalanceRange check (thrashing prevention) │ +│ Stage 3: ProportionalRangePlanner.Plan() + NoRebalanceRangePlanner│ +│ Stage 4: DesiredCacheRange == CurrentCacheRange? (no-op skip) │ +│ Stage 5: Return Schedule decision │ +│ ↓ │ +│ If skip: continue loop (work avoidance, diagnostics event) │ +│ If execute: │ +│ • lastExecutionRequest?.Cancel() │ +│ • IRebalanceExecutionController.PublishExecutionRequest() │ +│ └─ Task-based: Volatile.Write (synchronous) │ +│ └─ Channel-based: await WriteAsync() │ +└──────────────────────────────────────────────────────────────────────┘ + ↓ (strategy-specific) +┌──────────────────────────────────────────────────────────────────────┐ +│ PHASE 3: BACKGROUND EXECUTION (Strategy-Specific) │ +├──────────────────────────────────────────────────────────────────────┤ +│ TASK-BASED: ChainExecutionAsync() (chained async method) │ +│ • await previousTask (serial ordering) │ +│ • await ExecuteRequestAsync() │ +│ OR CHANNEL-BASED: ProcessExecutionRequestsAsync() (infinite loop) │ +│ • await foreach (channel read) (sequential processing) │ +│ ↓ │ +│ ExecuteRequestAsync() (both strategies) │ +│ • await Task.Delay(debounce) (cancellable) │ +│ • Cancellation check │ +│ ↓ │ +│ RebalanceExecutor.ExecuteAsync() │ +│ • ct.ThrowIfCancellationRequested() (before I/O) │ +│ • Extend cache data via IDataSource (async I/O) │ +│ • ct.ThrowIfCancellationRequested() (after I/O) │ +│ • Trim to desired range │ +│ • ct.ThrowIfCancellationRequested() (before mutation) │ +│ ┌──────────────────────────────────────┐ │ +│ │ CACHE MUTATION (SINGLE WRITER) │ │ +│ │ • Cache.Rematerialize() │ │ +│ │ • IsInitialized = true │ │ +│ │ • NoRebalanceRange = desiredNRR │ │ +│ └──────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +**Threading boundaries:** + +- **User Thread Boundary**: Ends at `PublishIntent()` return. Everything before: synchronous, blocking user request. `PublishIntent()`: atomic ops only (microseconds), returns immediately. +- **Background Thread #1**: Intent processing loop. Single dedicated thread via semaphore wait. Processes intents sequentially (one at a time). CPU-only decision logic (microseconds). No I/O. +- **Background Execution**: Strategy-specific serialization. Task-based: chained async methods on ThreadPool. Channel-based: single dedicated loop via channel reader. Both: sequential (one at a time). I/O operations. SOLE writer to cache state. + +### User Request Flow (step-by-step) + +``` +1. UserRequestHandler.HandleRequestAsync() called +2. Read from cache or fetch missing data via IDataSource (READ-ONLY — no mutation) +3. Assemble data to return to user +4. IntentController.PublishIntent(intent) [user thread] + ├─ Interlocked.Exchange(_pendingIntent, intent) — atomic, O(1) + ├─ _activityCounter.IncrementActivity() + └─ _intentSignal.Release() → wakes background loop; returns immediately +5. Return assembled data to user + +--- BACKGROUND (ProcessIntentsAsync) --- + +6. _intentSignal.WaitAsync() unblocks +7. Interlocked.Exchange(_pendingIntent, null) → reads latest intent +8. RebalanceDecisionEngine.Evaluate() [CPU-only, side-effect free] + Stage 1: CurrentNoRebalanceRange check + Stage 2: PendingNoRebalanceRange check + Stage 3: Compute DesiredRange + DesiredNoRebalanceRange + Stage 4: DesiredRange == CurrentRange check + Stage 5: Schedule +9. If validation rejects: continue loop (work avoidance) +10. If schedule: lastRequest?.Cancel() + PublishExecutionRequest() + +--- BACKGROUND EXECUTION --- + +11. Debounce delay (Task.Delay) +12. RebalanceExecutor.ExecuteAsync() + └─ I/O operations + atomic cache mutations +``` + +Key: Decision evaluation happens in the **background loop**, not in the user thread. The user thread only does atomic store + semaphore signal then returns immediately. This means user request bursts are handled gracefully: latest intent wins via `Interlocked.Exchange`; the decision loop processes serially with no concurrent thrashing. + +### Concurrency Guarantees + +- ✅ User requests NEVER block on decision evaluation +- ✅ User requests NEVER block on rebalance execution +- ✅ At most ONE decision evaluation active at a time (sequential loop) +- ✅ At most ONE rebalance execution active at a time (sequential loop + `SemaphoreSlim`) +- ✅ Cache mutations are SERIALIZED (single-writer via sequential execution) +- ✅ No race conditions on cache state (read-only User Path + single writer) +- ✅ No locks in hot path (Volatile/Interlocked only) + +--- + +## Invariants + +- Atomic cache mutation and state consistency: `docs/invariants.md` (Cache state and execution invariants). +- Activity tracking and "was idle" semantics: `docs/invariants.md` (Activity tracking invariants). + +## Usage + +For contributors: + +- If you touch cache state publication, re-check single-writer and atomicity invariants. +- If you touch idle detection, re-check activity tracking invariants and tests. +- If you touch the intent loop or execution controllers, re-check the threading boundary described above. + +## Examples + +See `docs/diagnostics.md` for production instrumentation patterns. + +## Edge Cases + +- Storage strategy may use short critical sections internally; see `docs/storage-strategies.md`. + +## Limitations + +- Diagnostics should remain optional and low-overhead. +- Thread safety is guaranteed for the single-consumer model only; see `docs/architecture.md`. diff --git a/docs/components/intent-management.md b/docs/components/intent-management.md new file mode 100644 index 0000000..6693f6b --- /dev/null +++ b/docs/components/intent-management.md @@ -0,0 +1,100 @@ +# Components: Intent Management + +## Overview + +Intent management bridges the user path and background work. It receives access signals (intents) from the user thread, applies "latest intent wins" semantics, and runs the background intent processing loop that drives the decision and execution pipeline. + +## Key Components + +| Component | File | Role | +|--------------------------------------------|--------------------------------------------------------------------|------------------------------------------------------------------| +| `IntentController` | `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` | Manages intent lifecycle; runs background processing loop | +| `Intent` | `src/SlidingWindowCache/Core/Rebalance/Intent/Intent.cs` | Carries `RequestedRange` + `DeliveredData` + `CancellationToken` | + +## Execution Contexts + +| Method | Context | Description | +|-------------------------|----------------------|-------------------------------------------------------------| +| `PublishIntent()` | ⚡ User Thread | Atomic intent store + semaphore signal; returns immediately | +| `ProcessIntentsAsync()` | 🔄 Background Thread | Decision evaluation, cancellation, execution enqueue | +| `DisposeAsync()` | Caller thread | Cancels loop, awaits completion, disposes resources | + +## PublishIntent (User Thread) + +Called by `UserRequestHandler` after serving a request: + +1. Atomically replaces pending intent via `Interlocked.Exchange` (latest wins; previous intent superseded) +2. Increments `AsyncActivityCounter` (before signalling — ordering required by Invariant H.47) +3. Releases semaphore (wakes up `ProcessIntentsAsync` if sleeping) +4. Records `RebalanceIntentPublished` diagnostic event +5. Returns immediately (fire-and-forget) + +**Intent does not guarantee execution.** It is a signal, not a command. Execution is decided by `RebalanceDecisionEngine` in the background loop. + +## ProcessIntentsAsync (Background Loop) + +Runs for the lifetime of the cache on a dedicated background task: + +1. Wait on semaphore (no CPU spinning) +2. Atomically read and clear pending intent +3. Check cancellation (post-debounce early exit) +4. Invoke `RebalanceDecisionEngine.Evaluate()` (5-stage pipeline, CPU-only) +5. If no execution required: record skip diagnostic, decrement activity counter, continue +6. If execution required: cancel previous `CancellationTokenSource`, enqueue to `IRebalanceExecutionController` +7. Decrement activity counter in `finally` block (unconditional cleanup) + +## Intent Supersession + +Rapid user access bursts naturally collapse: each new `PublishIntent` call atomically replaces the pending intent. The background loop always processes the **most recent** intent, discarding any intermediate ones. This is the primary burst-resistance mechanism. + +``` +User burst: intent₁ → intent₂ → intent₃ + ↓ (loop wakes up once) + Processes intent₃ only; intent₁ and intent₂ are gone +``` + +## Responsibilities + +- Accept access signals from the user thread +- Maintain "latest intent wins" supersession semantics +- Run the background loop: decision evaluation → cancellation → execution enqueue +- Track activity via `AsyncActivityCounter` for `WaitForIdleAsync` support + +## Non-Responsibilities + +- Does **not** perform cache mutations. +- Does **not** perform I/O. +- Does **not** perform debounce delay (handled by `IRebalanceExecutionController` implementations). +- Does **not** decide rebalance necessity (delegated to `RebalanceDecisionEngine`). + +## Internal State + +| Field | Type | Description | +|--------------------|---------------------------|--------------------------------------------------------------------| +| `_pendingIntent` | `Intent?` (volatile) | Latest unprocessed intent; written by user thread, cleared by loop | +| `_semaphore` | `SemaphoreSlim` | Wakes background loop when new intent arrives | +| `_loopCts` | `CancellationTokenSource` | Cancels the background loop on disposal | +| `_activityCounter` | `AsyncActivityCounter` | Tracks in-flight operations for `WaitForIdleAsync` | + +## Invariants + +| Invariant | Description | +|-----------|--------------------------------------------------------------------------| +| C.17 | At most one pending intent at any time (atomic replacement) | +| C.18 | Previous intents become obsolete when superseded | +| C.19 | Cancellation is cooperative via `CancellationToken` | +| C.20 | Cancellation checked after debounce before execution starts | +| C.21 | At most one active rebalance scheduled at a time | +| C.24 | Intent does not guarantee execution | +| C.24e | Intent carries `deliveredData` (the data the user actually received) | +| H.47 | Activity counter incremented before semaphore signal (ordering) | +| H.48 | Activity counter decremented in `finally` blocks (unconditional cleanup) | + +See `docs/invariants.md` (Section C: Intent invariants, Section H: Activity counter invariants) for full specification. + +## See Also + +- `docs/components/decision.md` — what `RebalanceDecisionEngine` does with the intent +- `docs/components/execution.md` — what `IRebalanceExecutionController` does after enqueue +- `docs/components/infrastructure.md` — `AsyncActivityCounter` and `WaitForIdleAsync` semantics +- `docs/invariants.md` — Sections C and H diff --git a/docs/components/overview.md b/docs/components/overview.md new file mode 100644 index 0000000..e2d8e21 --- /dev/null +++ b/docs/components/overview.md @@ -0,0 +1,449 @@ +# Components: Overview + +## Overview + +This folder documents the internal component set of SlidingWindowCache. It is intentionally split by responsibility and execution context to avoid a single mega-document. + +## Motivation + +The system is easier to reason about when components are grouped by: + +- execution context (User Path, Decision Path, Execution Path) +- ownership boundaries (who creates/owns what) +- mutation authority (single-writer) + +## Design + +### Top-Level Component Roles + +- Public facade: `WindowCache` +- User Path: assembles requested data and publishes intent +- Intent loop: observes latest intent and runs analytical validation +- Execution: performs debounced, cancellable rebalance work and mutates cache state + +### Component Index + +- `docs/components/public-api.md` +- `docs/components/user-path.md` +- `docs/components/intent-management.md` +- `docs/components/decision.md` +- `docs/components/execution.md` +- `docs/components/state-and-storage.md` +- `docs/components/infrastructure.md` + +### Ownership (Conceptual) + +`WindowCache` is the composition root. Internals are constructed once and live for the cache lifetime. Disposal cascades through owned components. + +## Component Hierarchy + +``` +🟦 WindowCache [Public Facade] +│ +├── owns → 🟦 UserRequestHandler +│ +└── composes (at construction): + ├── 🟦 CacheState ⚠️ Shared Mutable + ├── 🟦 IntentController + │ └── uses → 🟧 IRebalanceExecutionController + │ ├── implements → 🟦 TaskBasedRebalanceExecutionController (default) + │ └── implements → 🟦 ChannelBasedRebalanceExecutionController (optional) + ├── 🟦 RebalanceDecisionEngine + │ ├── owns → 🟩 NoRebalanceSatisfactionPolicy + │ └── owns → 🟩 ProportionalRangePlanner + ├── 🟦 RebalanceExecutor + └── 🟦 CacheDataExtensionService + └── uses → 🟧 IDataSource (user-provided) +``` + +**Component Type Legend:** +- 🟦 CLASS = Reference type (heap-allocated) +- 🟩 STRUCT = Value type (stack-allocated or inline) +- 🟧 INTERFACE = Contract definition +- 🟪 ENUM = Value type enumeration +- 🟨 RECORD = Reference type with value semantics + +## Ownership & Data Flow Diagram + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ USER (Consumer) │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + │ GetDataAsync(range, ct) + ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ WindowCache [Public Facade] │ +│ sealed, public │ +│ │ +│ Constructor wires: │ +│ • CacheState (shared mutable) │ +│ • UserRequestHandler │ +│ • CacheDataExtensionService │ +│ • IntentController │ +│ └─ IRebalanceExecutionController │ +│ • RebalanceDecisionEngine │ +│ ├─ NoRebalanceSatisfactionPolicy │ +│ └─ ProportionalRangePlanner │ +│ • RebalanceExecutor │ +│ │ +│ GetDataAsync() → delegates to UserRequestHandler │ +└────────────────────────────────────────────────────────────────────────────┘ + + +════════════════════════════════ USER THREAD ════════════════════════════════ + + + │ + ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ UserRequestHandler [FAST PATH — READ ONLY] │ +│ │ +│ HandleRequestAsync(range, ct): │ +│ 1. Check cold start / cache coverage │ +│ 2. Fetch missing via CacheDataExtensionService │ +│ 3. Publish intent with assembled data │ +│ 4. Return ReadOnlyMemory │ +│ │ +│ ✖ NEVER writes to CacheState │ +│ ✖ NEVER calls Rematerialize() │ +│ ✖ NEVER modifies IsInitialized / NoRebalanceRange │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + │ PublishIntent() + ▼ + + +════════════════════════════ BACKGROUND / THREADPOOL ════════════════════════ + + +┌────────────────────────────────────────────────────────────────────────────┐ +│ IntentController [Lifecycle + Background Loop] │ +│ │ +│ PublishIntent() (User Thread) │ +│ 1. Interlocked.Exchange(_pendingIntent) │ +│ 2. Increment activity counter │ +│ 3. Signal background loop │ +│ │ +│ ProcessIntentsAsync() (Background Loop) │ +│ 1. Wait for signal │ +│ 2. Drain pending intent │ +│ 3. decision = RebalanceDecisionEngine.Evaluate(...) │ +│ 4. If !ShouldSchedule → skip (work avoidance) │ +│ 5. Cancel previous request │ +│ 6. Publish execution request │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ RebalanceDecisionEngine [PURE DECISION LOGIC] │ +│ │ +│ Evaluate(requested, cacheState, lastRequest): │ +│ Stage 1: Policy.ShouldRebalance(noRebalanceRange) → maybe skip │ +│ Stage 2: Policy.ShouldRebalance(pendingNRR) → maybe skip │ +│ Stage 3: desiredRange = Planner.Plan(requested) │ +│ Stage 4: If desiredRange == currentRange → skip │ +│ Stage 5: Return Schedule(desiredRange, desiredNRR) │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ IRebalanceExecutionController [EXECUTION SERIALIZATION] │ +│ │ +│ Strategies: │ +│ • Task chaining (lock-free) │ +│ • Channel (bounded) │ +│ │ +│ Execution flow: │ +│ 1. Debounce delay (cancellable) │ +│ 2. Call RebalanceExecutor.ExecuteAsync(...) │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ RebalanceExecutor [MUTATING ACTOR — SOLE WRITER] │ +│ │ +│ ExecuteAsync(intent, desiredRange, desiredNRR, ct): │ +│ 1. Validate cancellation │ +│ 2. Extend cache via CacheDataExtensionService │ +│ 3. Trim to desiredRange │ +│ 4. Update NoRebalanceRange │ +│ 5. Set IsInitialized = true │ +│ 6. Storage.Rematerialize(normalizedData) │ +│ │ +│ ✔ ONLY component allowed to mutate CacheState │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ CacheState [SHARED MUTABLE STATE] │ +│ │ +│ Written by: RebalanceExecutor (sole writer) │ +│ Read by: UserRequestHandler, DecisionEngine, IntentController │ +│ │ +│ ICacheStorage implementations: │ +│ • SnapshotReadStorage (array — zero-alloc reads) │ +│ • CopyOnReadStorage (List — cheap writes) │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +## Invariant Implementation Mapping + +This section bridges architectural invariants (in `docs/invariants.md`) to their concrete implementations. Each invariant is enforced through specific component interactions, code patterns, or architectural constraints. + +### Single-Writer Architecture +**Invariants**: A.-1, A.7, A.8, A.9, F.36 + +Only `RebalanceExecutor` has write access to `CacheState` internal setters. User Path components have read-only references. Internal visibility modifiers prevent external mutations. + +- `src/SlidingWindowCache/Core/State/CacheState.cs` — internal setters restrict write access +- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` — exclusive mutation authority +- `src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs` — read-only access pattern + +### Priority and Cancellation +**Invariants**: A.0, A.0a, C.19, F.35a + +`CancellationTokenSource` coordination between intent publishing and execution. `RebalanceDecisionEngine` validates necessity before triggering cancellation. Multiple checkpoints in execution pipeline check for cancellation. + +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` — cancellation token lifecycle +- `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` — validation gates cancellation +- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` — `ThrowIfCancellationRequested` checkpoints + +### Intent Management and Cancellation +**Invariants**: A.0a, C.17, C.20, C.21 + +`Interlocked.Exchange` replaces previous intent atomically (latest-wins). Single-writer architecture for intent state. Cancellation checked after debounce delay before execution starts. + +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` — atomic intent replacement + +### UserRequestHandler Responsibilities +**Invariants**: A.3, A.5 + +Only `UserRequestHandler` has access to `IntentController.PublishIntent`. Its scope is limited to data assembly; no normalization logic. + +- `src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs` — exclusive intent publisher +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` — internal visibility on publication interface + +### Async Execution Model +**Invariants**: A.4, G.44 + +`UserRequestHandler` publishes intent and returns immediately (fire-and-forget). `IRebalanceExecutionController` schedules execution via `Task.Run` or channels. User thread and ThreadPool thread contexts are separated. + +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` — `ProcessIntentsAsync` runs on background thread +- `src/SlidingWindowCache/Infrastructure/Execution/TaskBasedRebalanceExecutionController.cs` — `Task.Run` scheduling +- `src/SlidingWindowCache/Infrastructure/Execution/ChannelBasedRebalanceExecutionController.cs` — channel-based background execution + +### Atomic Cache Updates +**Invariants**: B.12, B.13 + +Storage strategies build new state before atomic swap. `Volatile.Write` atomically publishes new cache state reference. `Rematerialize` succeeds completely or not at all. + +- `src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs` — `Array.Copy` + `Volatile.Write` +- `src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs` — list replacement + `Volatile.Write` +- `src/SlidingWindowCache/Core/State/CacheState.cs` — `Rematerialize` ensures atomicity + +### Consistency Under Cancellation +**Invariants**: B.13, B.15, F.35b + +Final cancellation check before applying cache updates. Results applied atomically or discarded entirely. `try-finally` blocks ensure cleanup on cancellation. + +- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` — `ThrowIfCancellationRequested` before `Rematerialize` + +### Obsolete Result Prevention +**Invariants**: B.16, C.20 + +Each intent has a unique `CancellationToken`. Execution checks if cancellation is requested before applying results. Only results from the latest non-cancelled intent are applied. + +- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` — cancellation validation before mutation +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` — token lifecycle management + +### Intent Singularity +**Invariant**: C.17 + +`Interlocked.Exchange` ensures exactly one active intent. New intent atomically replaces previous one. At most one pending intent at any time (no queue buildup). + +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` — `Interlocked.Exchange` for atomic intent replacement + +### Cancellation Protocol +**Invariant**: C.19 + +`CancellationToken` passed through the entire pipeline. Multiple checkpoints: before I/O, after I/O, before mutations. Results from cancelled operations are never applied. + +- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` — multiple `ThrowIfCancellationRequested` calls +- `src/SlidingWindowCache/Infrastructure/Services/CacheDataExtensionService.cs` — cancellation token propagated to `IDataSource` + +### Early Exit Validation +**Invariants**: C.20, D.29 + +Post-debounce cancellation check before execution. Each validation stage can exit early. All stages must pass for execution to proceed. + +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` — cancellation check after debounce +- `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` — multi-stage early exit + +### Serial Execution Guarantee +**Invariant**: C.21 + +Previous execution cancelled before starting new one. Single `IRebalanceExecutionController` instance per cache. Intent processing loop ensures serial execution. + +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` — sequential intent loop + cancellation of prior execution + +### Intent Data Contract +**Invariant**: C.24e + +`PublishIntent` signature requires `deliveredData` parameter. `UserRequestHandler` materializes data once, passes it to both user and intent. Compiler enforces data presence. + +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` — `PublishIntent(requestedRange, deliveredData)` signature +- `src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs` — single data materialization shared between paths + +### Pure Decision Logic +**Invariants**: D.25, D.26 + +`RebalanceDecisionEngine` has no mutable fields. Decision policies are structs with no side effects. No I/O in decision path. Pure function: `(state, intent, config) → decision`. + +- `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` — pure evaluation logic +- `src/SlidingWindowCache/Core/Planning/NoRebalanceSatisfactionPolicy.cs` — stateless struct +- `src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs` — stateless struct + +### Decision-Execution Separation +**Invariant**: D.26 + +Decision components have no references to mutable state setters. Decision Engine reads `CacheState` but cannot modify it. Decision and Execution interfaces are distinct. + +- `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` — read-only state access +- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` — exclusive write access + +### Multi-Stage Decision Pipeline +**Invariant**: D.29 + +Five-stage pipeline with early exits. Stage 1: current `NoRebalanceRange` containment (fast path). Stage 2: pending `NoRebalanceRange` validation (thrashing prevention). Stage 3: `DesiredCacheRange` computation. Stage 4: equality check (`DesiredCacheRange == CurrentCacheRange`). Stage 5: execution scheduling (only if all stages pass). + +- `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` — complete pipeline implementation + +### Desired Range Computation +**Invariants**: E.30, E.31 + +`ProportionalRangePlanner.Plan(requestedRange, config)` is a pure function — same inputs always produce same output. Never reads `CurrentCacheRange`. + +- `src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs` — pure range calculation + +### NoRebalanceRange Computation +**Invariants**: E.34, E.35 + +`NoRebalanceRangePlanner.Plan(currentCacheRange)` — pure function of current range + config. Applies threshold percentages as negative expansion. Returns `null` when individual thresholds ≥ 1.0 (no stability zone possible). `WindowCacheOptions` constructor ensures threshold sum ≤ 1.0 at construction time. + +- `src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs` — NoRebalanceRange computation +- `src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs` — threshold sum validation + +### Cancellation Checkpoints +**Invariants**: F.35, F.35a + +Three checkpoints: before `IDataSource.FetchAsync`, after data fetching, before `Rematerialize`. `OperationCanceledException` propagates to cleanup handlers. + +- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` — multiple checkpoint locations + +### Cache Normalization Operations +**Invariant**: F.37 + +`CacheState.Rematerialize` accepts arbitrary range and data (full replacement). `ICacheStorage` abstraction enables different normalization strategies. + +- `src/SlidingWindowCache/Core/State/CacheState.cs` — `Rematerialize` method +- `src/SlidingWindowCache/Infrastructure/Storage/` — storage strategy implementations + +### Incremental Data Fetching +**Invariant**: F.38 + +`CacheDataExtensionService.ExtendCacheDataAsync` computes missing ranges via range subtraction (`DesiredRange \ CachedRange`). Fetches only missing subranges via `IDataSource`. + +- `src/SlidingWindowCache/Infrastructure/Services/CacheDataExtensionService.cs` — range gap logic in `ExtendCacheDataAsync` + +### Data Preservation During Expansion +**Invariant**: F.39 + +New data merged with existing via range union. Existing data enumerated and preserved during rematerialization. New data only fills gaps; does not replace existing. + +- `src/SlidingWindowCache/Infrastructure/Services/CacheDataExtensionService.cs` — union logic in `ExtendCacheDataAsync` + +### I/O Isolation +**Invariant**: G.45 + +`UserRequestHandler` completes before any `IDataSource.FetchAsync` calls in rebalance path. All `IDataSource` interactions happen in `RebalanceExecutor` on a background thread. + +- `src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs` — no rebalance-path `IDataSource` calls +- `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` — `IDataSource` calls only in background execution + +### Activity Counter Ordering +**Invariant**: H.47 + +Activity counter incremented **before** semaphore signal, channel write, or volatile write (strict ordering discipline at all publication sites). + +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` — increment before `semaphore.Release` +- `src/SlidingWindowCache/Infrastructure/Execution/` — increment before channel write or `Task.Run` + +### Activity Counter Cleanup +**Invariant**: H.48 + +Decrement in `finally` blocks — unconditional execution regardless of success, failure, or cancellation. + +- `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` — `finally` block in `ProcessIntentsAsync` +- `src/SlidingWindowCache/Infrastructure/Execution/` — `finally` blocks in execution controllers + +--- + +## Architectural Patterns Used + +### 1. Facade Pattern +`WindowCache` acts as a facade that hides internal complexity and provides a simple public API. Contains no business logic; all behavioral logic is delegated to internal actors. + +### 2. Composition Root +`WindowCache` constructor wires all components together in one place. + +### 3. Actor Model (Conceptual) +Components follow actor-like patterns with clear responsibilities and message passing (method calls). Each actor has a defined execution context and responsibility boundary. + +### 4. Intent Controller Pattern +`IntentController` manages versioned, cancellable operations through `CancellationTokenSource` identity and `Interlocked.Exchange` latest-wins semantics. + +### 5. Strategy Pattern +`ICacheStorage` with two implementations (`SnapshotReadStorage`, `CopyOnReadStorage`) allows runtime selection of storage strategy based on read/write trade-offs. + +### 6. Value Object Pattern +`NoRebalanceSatisfactionPolicy`, `ProportionalRangePlanner`, and `RebalanceDecision` are immutable value types with pure behavior (no side effects, deterministic). + +### 7. Shared Mutable State (Controlled) +`CacheState` is intentionally shared mutable state, coordinated via single-writer architecture (not locks). The single writer (`RebalanceExecutor`) is the sole authority for mutations. + +### 8. Single Consumer Model +The entire architecture assumes one logical consumer, avoiding traditional synchronization primitives in favor of architectural constraints (single-writer, read-only User Path). + +--- + +## Invariants + +Canonical invariants live in `docs/invariants.md`. Component-level details in this folder focus on "what exists" and "who does what"; they link back to the formal rules. + +## Usage + +Contributors should read in this order: + +1. `docs/components/public-api.md` +2. `docs/components/user-path.md` +3. `docs/components/intent-management.md` +4. `docs/components/decision.md` +5. `docs/components/execution.md` +6. `docs/components/state-and-storage.md` +7. `docs/components/infrastructure.md` + +## See Also + +- `docs/scenarios.md` — step-by-step temporal walkthroughs +- `docs/actors.md` — actor responsibilities and invariant ownership +- `docs/architecture.md` — threading model and concurrency details +- `docs/invariants.md` — formal invariant specifications + +## Edge Cases + +- "Latest intent wins" means intermediate intents can be skipped; this is by design. + +## Limitations + +- Component docs are descriptive; algorithm-level detail is in source XML docs. diff --git a/docs/components/public-api.md b/docs/components/public-api.md new file mode 100644 index 0000000..d49f777 --- /dev/null +++ b/docs/components/public-api.md @@ -0,0 +1,115 @@ +# Components: Public API + +## Overview + +This page documents the public surface area of SlidingWindowCache: the cache facade, configuration, data source contract, diagnostics, and public DTOs. + +## Facade + +- `WindowCache`: primary entry point and composition root. + - **File**: `src/SlidingWindowCache/Public/WindowCache.cs` + - Constructs and wires all internal components. + - Delegates user requests to `UserRequestHandler`. + - Exposes `WaitForIdleAsync()` for infrastructure/testing synchronization. +- `IWindowCache`: interface for the facade (for testing/mocking). + +## Configuration + +### WindowCacheOptions + +**File**: `src/SlidingWindowCache/Public/Configuration/WindowCacheOptions.cs` + +**Type**: `record` (immutable, value semantics) + +Configuration parameters: + +| Parameter | Description | +|-----------------------------|----------------------------------------------------| +| `LeftCacheSize` | Left window coefficient (≥ 0) | +| `RightCacheSize` | Right window coefficient (≥ 0) | +| `LeftNoRebalanceThreshold` | Left stability zone threshold (optional, ≥ 0) | +| `RightNoRebalanceThreshold` | Right stability zone threshold (optional, ≥ 0) | +| `RebalanceDebounceDelay` | Delay before executing a validated rebalance | +| `UserCacheReadMode` | Storage strategy (`Snapshot` or `CopyOnRead`) | +| `RebalanceQueueCapacity` | Optional; selects channel-based execution when set | + +**Validation enforced at construction time:** +- Cache sizes ≥ 0 +- Individual thresholds ≥ 0 (when specified) +- `LeftNoRebalanceThreshold + RightNoRebalanceThreshold ≤ 1.0` (prevents overlapping shrinkage zones) +- `RebalanceQueueCapacity > 0` (when specified) + +**Invariants**: E.34, E.35 (NoRebalanceRange computation and threshold sum constraint). + +### UserCacheReadMode + +**File**: `src/SlidingWindowCache/Public/Configuration/UserCacheReadMode.cs` + +**Type**: `enum` + +| Value | Description | Trade-off | +|--------------|-----------------------------------------------------------------|-------------------------------------------| +| `Snapshot` | Array-based; zero-allocation reads, expensive rematerialization | Fast reads, LOH pressure for large caches | +| `CopyOnRead` | List-based; cheap rematerialization, copy-per-read | Fast rebalance, allocation on each read | + +**See**: `docs/storage-strategies.md` for detailed comparison and usage scenarios. + +## Data Source + +### IDataSource\ + +**File**: `src/SlidingWindowCache/Public/IDataSource.cs` + +**Type**: Interface (user-implemented) + +- Single-range fetch (required): `FetchAsync(Range, CancellationToken)` +- Batch fetch (optional): default implementation uses parallel single-range fetches +- Cancellation is cooperative; implementations must respect `CancellationToken` + +**Used by**: `CacheDataExtensionService` (background execution path only — never called on the user thread). + +**Invariant**: G.45 (I/O isolation — IDataSource is never called from the user path). + +## DTOs + +### RangeResult\ + +**File**: `src/SlidingWindowCache/Public/DTO/RangeResult.cs` + +Returned by `GetDataAsync`. `Range` may be null for physical boundary misses (when `IDataSource` returns null for the requested range). + +### RangeChunk\ + +**File**: `src/SlidingWindowCache/Public/DTO/RangeChunk.cs` + +Batch fetch result from `IDataSource`. Contains: +- `Range Range` — the range covered by this chunk +- `IEnumerable Data` — the data for this range + +## Diagnostics + +### ICacheDiagnostics + +**File**: `src/SlidingWindowCache/Public/Instrumentation/ICacheDiagnostics.cs` + +Optional observability interface with 18 event recording methods covering: +- User request outcomes (full hit, partial hit, full miss) +- Data source access events +- Rebalance intent lifecycle (published, cancelled) +- Rebalance execution lifecycle (started, completed, cancelled) +- Rebalance skip optimizations (NoRebalanceRange stage 1 & 2, same-range short-circuit) + +**Implementations**: +- `EventCounterCacheDiagnostics` — thread-safe atomic counter implementation (use for testing and monitoring) +- `NoOpDiagnostics` — zero-overhead default when no diagnostics provided (JIT eliminates all calls) + +**See**: `docs/diagnostics.md` for comprehensive usage documentation. + +> ⚠️ **Critical**: `RebalanceExecutionFailed` is the only event that signals a background exception. Always wire this in production code. + +## See Also + +- `docs/boundary-handling.md` +- `docs/diagnostics.md` +- `docs/invariants.md` +- `docs/storage-strategies.md` diff --git a/docs/components/rebalance-path.md b/docs/components/rebalance-path.md new file mode 100644 index 0000000..4969296 --- /dev/null +++ b/docs/components/rebalance-path.md @@ -0,0 +1,120 @@ +# Components: Rebalance Path + +## Overview + +The Rebalance Path is responsible for decision-making and cache mutation. It runs entirely in the background, enforces execution serialization, and is the only subsystem permitted to mutate shared cache state. + +## Motivation + +Rebalancing is expensive: it involves debounce delays, optional I/O, and atomic cache mutations. The system avoids unnecessary work by running a multi-stage validation pipeline before scheduling execution. Only when all stages confirm necessity does rebalance proceed. + +## Key Components + +| Component | File | Role | +|---------------------------------------------------------|------------------------------------------------------------------------------------|--------------------------------------------------------------| +| `IntentController` | `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` | Background loop; decision orchestration; cancellation | +| `RebalanceDecisionEngine` | `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` | **Sole authority** for rebalance necessity; 5-stage pipeline | +| `NoRebalanceSatisfactionPolicy` | `src/SlidingWindowCache/Core/Planning/NoRebalanceSatisfactionPolicy.cs` | Stages 1 & 2: NoRebalanceRange containment checks | +| `ProportionalRangePlanner` | `src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs` | Stage 3: desired cache range computation | +| `NoRebalanceRangePlanner` | `src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs` | Stage 3: desired NoRebalanceRange computation | +| `IRebalanceExecutionController` | `src/SlidingWindowCache/Infrastructure/Execution/IRebalanceExecutionController.cs` | Debounce + single-flight execution contract | +| `RebalanceExecutor` | `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` | Sole writer; performs `Rematerialize` | + +See also the split component pages for deeper detail: + +- `docs/components/intent-management.md` — intent lifecycle, `PublishIntent`, background loop +- `docs/components/decision.md` — 5-stage validation pipeline specification +- `docs/components/execution.md` — execution controllers, `RebalanceExecutor`, cancellation checkpoints + +## Decision vs Execution + +These are distinct concerns with separate components: + +| Aspect | Decision | Execution | +|------------------|----------------------------------|------------------------------------| +| **Authority** | `RebalanceDecisionEngine` (sole) | `RebalanceExecutor` (sole writer) | +| **Nature** | CPU-only, pure, deterministic | Debounced, cancellable, may do I/O | +| **State access** | Read-only | Write (sole) | +| **I/O** | Never | Yes (`IDataSource.FetchAsync`) | +| **Invariants** | D.25, D.26, D.27, D.28, D.29 | A.7, B.12, B.13, F.35, F.37–F.39 | + +The formal 5-stage validation pipeline is specified in `docs/invariants.md` (Section D). + +## End-to-End Flow + +``` +[User Thread] [Background: Intent Loop] [Background: Execution] + │ │ │ + │ PublishIntent() │ │ + │─────────────────────────▶│ │ + │ │ DecisionEngine.Evaluate() │ + │ │ (5-stage pipeline) │ + │ │ │ + │ │ [Skip? → discard] │ + │ │ │ + │ │ Cancel previous CTS │ + │ │──────────────────────────────▶ │ + │ │ Enqueue execution request │ + │ │──────────────────────────────▶ │ + │ │ │ Debounce + │ │ │ FetchAsync (gaps only) + │ │ │ ThrowIfCancelled + │ │ │ Rematerialize (atomic) + │ │ │ Update NoRebalanceRange +``` + +## Cancellation + +Cancellation is **mechanical coordination**, not a decision mechanism: + +- `IntentController` cancels the previous `CancellationTokenSource` when a new validated execution is needed. +- `RebalanceExecutor` checks cancellation at multiple checkpoints (before I/O, after I/O, before mutation). +- Cancelled results are **always discarded** — partial mutations never occur. + +The decision about *whether* to cancel is made by `RebalanceDecisionEngine` (via the 5-stage pipeline), not by cancellation itself. + +## Invariants + +| Invariant | Description | +|-----------|----------------------------------------------------------------| +| A.7 | Only `RebalanceExecutor` writes `CacheState` | +| B.12 | Atomic cache updates via `Rematerialize` | +| B.13 | Consistency under cancellation (discard, never partial-apply) | +| B.15 | Cache contiguity maintained after every rematerialization | +| C.19 | Cooperative cancellation via `CancellationToken` | +| C.20 | Cancellation checked after debounce, before execution | +| C.21 | At most one active rebalance scheduled at a time | +| D.25 | Decision path is purely analytical (no I/O, no state mutation) | +| D.26 | Decision never mutates cache state | +| D.27 | No rebalance if inside current NoRebalanceRange (Stage 1) | +| D.28 | No rebalance if DesiredRange == CurrentRange (Stage 4) | +| D.29 | Execution proceeds only if ALL 5 stages pass | +| F.35 | Multiple cancellation checkpoints in execution | +| F.35a | Cancellation-before-mutation guarantee | +| F.37–F.39 | Correct atomic rematerialization with data preservation | + +See `docs/invariants.md` (Sections B, C, D, F) for full specification. + +## Usage + +When debugging a rebalance: + +1. Find the scenario in `docs/scenarios.md` (Decision/Execution sections). +2. Confirm the 5-stage decision pipeline via `docs/invariants.md` Section D. +3. Inspect `IntentController`, `RebalanceDecisionEngine`, `IRebalanceExecutionController`, `RebalanceExecutor` XML docs. + +## Edge Cases + +- **Bursty access**: multiple intents may collapse into one execution (latest-intent-wins semantics). +- **Cancellation checkpoints**: execution must yield at each checkpoint without leaving cache in an inconsistent state. Rematerialization is all-or-nothing. +- **Same-range short-circuit**: if `DesiredCacheRange == CurrentCacheRange` (Stage 4), execution is skipped even if it passed Stages 1–3. + +## Limitations + +- Not optimized for concurrent independent consumers; use one cache instance per consumer. + +## See Also + +- `docs/diagnostics.md` — observing decisions and executions via `ICacheDiagnostics` events +- `docs/invariants.md` — Sections C (intent), D (decision), F (execution) +- `docs/architecture.md` — single-writer architecture and execution serialization model diff --git a/docs/components/state-and-storage.md b/docs/components/state-and-storage.md new file mode 100644 index 0000000..7772c27 --- /dev/null +++ b/docs/components/state-and-storage.md @@ -0,0 +1,131 @@ +# Components: State and Storage + +## Overview + +State and storage define how cached data is held, read, and published. `CacheState` is the central shared mutable state of the system — written exclusively by `RebalanceExecutor`, and read by `UserRequestHandler` and `RebalanceDecisionEngine`. + +## Key Components + +| Component | File | Role | +|-----------------------------------------------|------------------------------------------------------------------------|-----------------------------------------------------| +| `CacheState` | `src/SlidingWindowCache/Core/State/CacheState.cs` | Shared mutable state; the single coordination point | +| `ICacheStorage` | `src/SlidingWindowCache/Infrastructure/Storage/ICacheStorage.cs` | Internal storage contract | +| `SnapshotReadStorage` | `src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs` | Array-based; zero-allocation reads | +| `CopyOnReadStorage` | `src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs` | List-based; cheap rematerialization | + +## CacheState + +**File**: `src/SlidingWindowCache/Core/State/CacheState.cs` + +`CacheState` is shared by reference across `UserRequestHandler`, `RebalanceDecisionEngine`, and `RebalanceExecutor`. It holds: + +| Field | Type | Written by | Read by | +|--------------------|-----------------|--------------------------|----------------------------------------| +| `Cache` (storage) | `ICacheStorage` | `RebalanceExecutor` only | `UserRequestHandler`, `DecisionEngine` | +| `IsInitialized` | `bool` | `RebalanceExecutor` only | `UserRequestHandler` | +| `NoRebalanceRange` | `Range?` | `RebalanceExecutor` only | `DecisionEngine` | + +**Single-Writer Rule (Invariant A.7):** Only `RebalanceExecutor` writes any field of `CacheState`. User path components are read-only. This is enforced by internal visibility modifiers (setters are `internal`), not by locks. + +**No internal locking:** The single-writer constraint makes locks unnecessary. `Volatile.Write` / `Volatile.Read` patterns ensure visibility across threads where needed. + +**Atomic updates via `Rematerialize`:** The `Rematerialize` method replaces the storage contents in a single atomic operation. No intermediate states are visible to readers. + +## Storage Strategies + +### SnapshotReadStorage + +**Type**: `internal sealed class` + +**Strategy**: Array-based with atomic replacement on rematerialization. + +| Operation | Behavior | +|-----------------|-------------------------------------------------------------------------------| +| `Rematerialize` | Allocates new `TData[]`, performs `Array.Copy`, atomically replaces reference | +| `Read` | Returns zero-allocation `ReadOnlyMemory` view over internal array | +| `ToRangeData` | Creates snapshot from current array | + +**Characteristics**: +- ✅ Zero-allocation reads (fastest user path) +- ❌ Expensive rematerialization (always allocates new array) +- ⚠️ Large arrays (≥ 85 KB) may end up on the LOH +- Best for: read-heavy workloads, predictable memory patterns + +### CopyOnReadStorage + +**Type**: `internal sealed class` + +**Strategy**: Dual-buffer pattern — active storage is never mutated during enumeration. + +| Operation | Behavior | +|-----------------|-----------------------------------------------------------------------------------------| +| `Rematerialize` | Clears staging buffer, fills with new data, atomically swaps with active | +| `Read` | Acquires lock, allocates `TData[]`, copies from active buffer, returns `ReadOnlyMemory` | +| `ToRangeData` | Returns lazy enumerable over active storage (unsynchronized; rebalance path only) | + +**Staging Buffer Pattern:** +``` +Active buffer: [existing data] ← user reads here (immutable during enumeration) +Staging buffer: [new data] ← rematerialization builds here + ↓ swap (under lock) +Active buffer: [new data] ← now visible to reads +Staging buffer: [old data] ← reused next rematerialization (capacity preserved) +``` + +**Characteristics**: +- ✅ Cheap rematerialization (amortized O(1) when capacity sufficient) +- ✅ No LOH pressure (List growth strategy) +- ✅ Correct enumeration during LINQ-derived expansion +- ❌ Allocation on every read (lock + array copy) +- Best for: rematerialization-heavy workloads, large sliding windows + +> **Note**: `ToRangeData()` is unsynchronized and must only be called from the rebalance path. See `docs/storage-strategies.md`. + +### Strategy Selection + +Controlled by `WindowCacheOptions.UserCacheReadMode`: +- `UserCacheReadMode.Snapshot` → `SnapshotReadStorage` +- `UserCacheReadMode.CopyOnRead` → `CopyOnReadStorage` + +## Read/Write Pattern Summary + +``` +UserRequestHandler ──reads───▶ CacheState.Cache.Read() + CacheState.Cache.ToRangeData() + CacheState.IsInitialized + +DecisionEngine ──reads───▶ CacheState.NoRebalanceRange + CacheState.Cache.Range + +RebalanceExecutor ──writes──▶ CacheState.Cache.Rematerialize() ← SOLE WRITER + CacheState.NoRebalanceRange ← SOLE WRITER + CacheState.IsInitialized ← SOLE WRITER +``` + +## Invariants + +| Invariant | Description | +|-----------|----------------------------------------------------------------------| +| A.7 | Only `RebalanceExecutor` writes `CacheState` | +| A.9 | `IsInitialized` only transitions false → true (monotonic) | +| B.11 | Cache is always contiguous (no gaps in cached range) | +| B.12 | Cache updates are atomic via `Rematerialize` | +| B.13 | Consistency under cancellation: partial results discarded | +| B.15 | Cache contiguity invariant maintained after every rematerialization | +| E.34 | `NoRebalanceRange` is computed correctly from thresholds | +| E.35 | `NoRebalanceRange` is always contained within `CacheRange` | +| F.37 | `Rematerialize` accepts arbitrary range and replaces entire contents | + +See `docs/invariants.md` (Sections A, B, E, F) for full specification. + +## Notes + +- "Single logical consumer" is a **usage model** constraint; internal concurrency (user thread + background loops) is fully supported by design. +- Multiple threads from the **same** logical consumer can call `GetDataAsync` safely — the user path is read-only. +- Multiple **independent** consumers should use separate cache instances; sharing violates the coherent access pattern assumption. + +## See Also + +- `docs/storage-strategies.md` — detailed strategy comparison, performance characteristics, and selection guide +- `docs/invariants.md` — Sections A (write authority), B (state invariants), E (range planning) +- `docs/components/execution.md` — how `RebalanceExecutor` performs writes diff --git a/docs/components/user-path.md b/docs/components/user-path.md new file mode 100644 index 0000000..259fbc6 --- /dev/null +++ b/docs/components/user-path.md @@ -0,0 +1,72 @@ +# Components: User Path + +## Overview + +The User Path serves `GetDataAsync` calls and publishes rebalance intents. It is latency-sensitive and strictly **read-only** with respect to shared cache state. + +## Motivation + +User requests must not block on background optimization. The user path does the minimum necessary work to return the requested range: read from cache (if available), fetch missing data from `IDataSource` (if needed), then immediately signal background work via an intent and return. + +## Key Components + +| Component | File | Role | +|-----------------------------------------------------|-------------------------------------------------------------------------------|-----------------------------------------------------| +| `WindowCache` | `src/SlidingWindowCache/Public/WindowCache.cs` | Public facade; delegates to `UserRequestHandler` | +| `UserRequestHandler` | `src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs` | Internal user-path logic; sole publisher of intents | +| `CacheDataExtensionService` | `src/SlidingWindowCache/Infrastructure/Services/CacheDataExtensionService.cs` | Assembles requested range from cache + IDataSource | +| `IntentController` | `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` | Publish-side only from user path | + +## Execution Context + +All user-path code executes on the **⚡ User Thread** (the caller's thread). No blocking on background operations. + +## Operation Flow + +1. **Cold-start check** — `!state.IsInitialized`: fetch full range from `IDataSource` and serve directly. +2. **Full cache hit** — `RequestedRange ⊆ Cache.Range`: read directly from storage (zero allocation for Snapshot mode). +3. **Partial cache hit** — intersection exists: serve cached portion + fetch missing segments via `CacheDataExtensionService`. +4. **Full cache miss** — no intersection: fetch full range from `IDataSource` directly. +5. **Publish intent** — fire-and-forget; passes `deliveredData` to `IntentController.PublishIntent` and returns immediately. + +## Responsibilities + +- Assemble `RequestedRange` from cache and/or `IDataSource`. +- Return data immediately without awaiting rebalance. +- Publish a rebalance intent containing the delivered data (what the caller actually received). + +## Non-Responsibilities + +- Does **not** decide whether to rebalance. +- Does **not** mutate shared cache state (never calls `Cache.Rematerialize()`, never writes `IsInitialized` or `NoRebalanceRange`). +- Does **not** perform debounce or cancellation. +- Does **not** trim or normalize cache geometry. + +## Invariants + +| Invariant | Description | +|-----------|--------------------------------------------------------------------------------------------------------------------------| +| A.0 | User requests always served immediately (never blocked by rebalance) | +| A.3 | `UserRequestHandler` is the sole publisher of rebalance intents | +| A.4 | Intent publication is fire-and-forget (background only) | +| A.5 | User path is strictly read-only w.r.t. `CacheState` | +| A.10 | Returns exactly `RequestedRange` data | +| G.45 | I/O isolation: `IDataSource` never called from background on user's behalf (extension service runs on background thread) | + +See `docs/invariants.md` (Section A: User Path invariants) for full specification. + +## Edge Cases + +- If `IDataSource` returns null (physical boundary miss), no intent is published for the missing region. +- Cold-start fetches data directly; the first intent triggers background initialization of cache geometry. + +## Limitations + +- User path is optimized for a **single logical consumer** pattern. Multiple independent consumers should use separate cache instances. + +## See Also + +- `docs/boundary-handling.md` — boundary semantics and null return behavior +- `docs/scenarios.md` — step-by-step walkthroughs of hit/miss/partial scenarios +- `docs/invariants.md` — Section A (User Path invariants), Section C (Intent invariants) +- `docs/components/intent-management.md` — intent lifecycle after publication diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 3865082..93df597 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -765,6 +765,6 @@ public class PrometheusMetricsDiagnostics : ICacheDiagnostics ## See Also - **[Invariants](invariants.md)** - System invariants tracked by diagnostics -- **[Scenario Model](scenario-model.md)** - User/Decision/Rebalance scenarios referenced in event descriptions +- **[Scenarios](scenarios.md)** - User/Decision/Rebalance scenarios referenced in event descriptions - **[Invariant Test Suite](../tests/SlidingWindowCache.Invariants.Tests/README.md)** - Examples of diagnostic usage in tests -- **[Component Map](component-map.md)** - Component locations where events are recorded +- **[Components](components/overview.md)** - Component locations where events are recorded diff --git a/docs/glossary.md b/docs/glossary.md index ccfc586..085d8c4 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -1,166 +1,164 @@ # Glossary -This document provides canonical definitions for technical terms used throughout the SlidingWindowCache project. All documentation should reference these definitions to maintain consistency. +Canonical definitions for SlidingWindowCache terms. This is a reference, not a tutorial. ---- +Recommended reading order: -## Before You Read +1. `README.md` +2. `docs/architecture.md` +3. `docs/invariants.md` +4. `docs/components/overview.md` -**This glossary is a reference, not a tutorial.** Definitions are intentionally concise and assume you've read foundational documentation. +## Core Terms -**Recommended Learning Path:** +Cache +- The in-memory representation of a contiguous `Range` of data, stored using a chosen storage strategy. +- Cache contiguity (no gaps) is a core invariant; see `docs/invariants.md`. -1. **Start here** → [README.md](../README.md) - Overview, quick start, basic examples -2. **Architecture fundamentals** → [Architecture Model](architecture-model.md) - Threading, single-writer, decision-driven execution -3. **Dive deeper** → [Invariants](invariants.md) - System guarantees and constraints -4. **Implementation details** → [Component Map](component-map.md) - Component catalog with source references +Range +- A value interval (e.g., `[100..200]`) represented by `Intervals.NET`. -**Using this glossary:** -- Terms link to detailed docs where applicable (click through for full context) -- Grouped by category for faster lookup -- Cross-referenced heavily - follow links for related concepts +Domain +- The mathematical rules for stepping/comparing `TRange` values (e.g., integer fixed-step, DateTime). In code this is the `TDomain` type. ---- +Window +- The cached range maintained around the most recently accessed region, typically larger than the user’s requested range. -## Core Concepts +## Range Vocabulary -### Cache -In-memory storage of contiguous range data. No gaps allowed ([Invariants B](invariants.md#b-cache-state--consistency-invariants)). +Requested Range +- The `Range` passed into `GetDataAsync`. -### Range -Interval with start/end boundaries. Uses `Intervals.NET` library. +Delivered Range +- The range the data source actually provided (may be smaller than requested for bounded sources). This is surfaced via `RangeResult.Range`. +- See `docs/boundary-handling.md`. -### Range Domain -Mathematical domain for range operations. Must implement `IRangeDomain`. Examples: `IntegerRangeDomain`, `DateTimeRangeDomain`. +Current Cache Range +- The range currently held in the cache state. ---- +Desired Cache Range +- The target range the cache would like to converge to based on configuration and the latest intent. -## Range Types +Available Range +- `Requested ∩ Current` (data that can be served immediately from the cache). -**Requested Range**: User requests in `GetDataAsync()`. -**Current Cache Range**: Currently stored (`CacheState.Cache.Range`). -**Desired Cache Range**: Target computed by `ProportionalRangePlanner`. See [Component Map](component-map.md#desired-range-computation). -**Available Range**: Intersection of Requested ∩ Current (immediately returnable). -**Missing Range**: Requested \ Current (must fetch). -**NoRebalanceRange**: Stability zone. Requests within skip rebalancing. See [Architecture Model](architecture-model.md#smart-eventual-consistency-model). +Missing Range +- `Requested \ Current` (data that must be fetched from `IDataSource`). ---- +RangeChunk +- A data source return value representing a contiguous chunk: a `Range?` plus associated data. `Range == null` means “no data available”. +- See `docs/boundary-handling.md`. -## Architectural Patterns +RangeResult +- The public API return from `GetDataAsync`: the delivered `Range?` and the materialized data. +- See `docs/boundary-handling.md`. -### Single-Writer Architecture -Only ONE component (`RebalanceExecutor`) mutates shared state (Cache, IsInitialized, NoRebalanceRange). All others read-only. Eliminates write-write conflicts. See [Architecture Model](architecture-model.md#single-writer-architecture) | [Component Map - Implementation](component-map.md#single-writer-architecture). +## Architectural Concepts -### Decision-Driven Execution -Multi-stage validation pipeline separating decisions from execution. `RebalanceDecisionEngine` is sole authority for rebalance necessity. Execution proceeds only if all stages pass. Prevents thrashing. See [Architecture Model](architecture-model.md#rebalance-validation-vs-cancellation) | [Invariants D.29](invariants.md#d-rebalance-decision-path-invariants). +User Path +- The user-facing call path (`GetDataAsync`) that serves data immediately and publishes an intent. +- Read-only with respect to shared cache state; see `docs/architecture.md` and `docs/invariants.md`. -### Smart Eventual Consistency -Cache converges to optimal state without blocking user requests. May temporarily serve from non-optimal range, rebalancing in background. See [Architecture Model - Consistency](architecture-model.md#smart-eventual-consistency-model). +Rebalance Path +- Background processing that decides whether to rebalance and, if needed, executes the rebalance and mutates cache state. -### Burst Resistance -Handles rapid request sequences without thrashing. Achieved via "latest intent wins" and NoRebalanceRange stability zones. See [Architecture Model](architecture-model.md#smart-eventual-consistency-model). +Single-Writer Architecture +- Only rebalance execution mutates shared cache state (cache contents, initialization flags, NoRebalanceRange, etc.). +- The User Path does not mutate that shared state. +- Canonical description: `docs/architecture.md`; formal rules: `docs/invariants.md`. ---- +Single Logical Consumer Model +- One cache instance is intended for one coherent access stream (e.g., one viewport/scroll position). Multiple threads may call the cache, as long as they represent the same logical consumer. -## Components & Actors +Intent +- A signal published by the User Path after serving a request. It describes what was delivered and what was requested so the system can evaluate whether rebalance is worthwhile. +- Intents are signals, not commands: the system may legitimately skip work. -### WindowCache -Public API facade. Exposes `GetDataAsync()`. +Latest Intent Wins +- The newest published intent supersedes older intents; intermediate intents may never be processed. -### UserRequestHandler -Handles user requests on user thread. Assembles data, publishes intents. Never mutates cache ([Invariants A.7-A.8](invariants.md#a-user-path--fast-user-access-invariants)). +Decision-Driven Execution +- Rebalance work is gated by a multi-stage validation pipeline. Decisions are fast (CPU-only) and may skip execution entirely. +- Formal definition: `docs/invariants.md` (Decision Path invariants). -### IntentController -Manages rebalance intent lifecycle. Evaluates `RebalanceDecisionEngine`, coordinates execution. Single-threaded background loop. See [Component Map](component-map.md#5-rebalance-system---intent-management). +Work Avoidance +- The system prefers skipping rebalance when analysis shows it is unnecessary (e.g., request within NoRebalanceRange, pending work already covers it, desired range already satisfied). -### RebalanceDecisionEngine -Sole authority for rebalance necessity. 5-stage validation pipeline. Pure, deterministic, side-effect free. See [Invariants D.25-D.29](invariants.md#d-rebalance-decision-path-invariants). +NoRebalanceRange +- A stability zone around the current cache geometry. If the request is inside this zone, the decision engine skips scheduling a rebalance. -### RebalanceExecutionController -Serializes/debounces executions. Implementations: `TaskBasedRebalanceExecutionController` (default), `ChannelBasedRebalanceExecutionController`. See [Component Map](component-map.md#7-rebalance-system---execution). +Debounce +- A deliberate delay before executing rebalance so bursts can settle and only the last relevant rebalance runs. -### RebalanceExecutor -Performs cache mutations. Fetches, merges, trims, updates state. Only mutator ([Invariant F.36](invariants.md#f-rebalance-execution-invariants)). +Normalization +- The process of converging cached data and cached range to the desired state (fetch missing data, trim, merge, then publish new cache state atomically). -### CacheDataExtensionService -Extends cache by fetching missing ranges, merging. See [Component Map - Incremental Fetching](component-map.md#incremental-data-fetching). +Rematerialization +- Rebuilding the stored representation of cached data (e.g., allocating a new array in Snapshot mode) to apply a new cache range. -### AsyncActivityCounter -Lock-free activity counter. Awaitable idle state. Tracks operations, signals "was idle". See [Invariants H.47-H.48](invariants.md#h-activity-tracking--idle-detection-invariants). +## Concurrency And Coordination ---- +Cancellation +- A coordination mechanism to stop obsolete background work; it is not the “decision”. The decision engine remains the sole authority for whether rebalance is necessary. -## Operations & Processes +AsyncActivityCounter +- Tracks ongoing internal operations and supports waiting for “idle” transitions. -### Intent -Signal containing requested range + delivered data. Published by `UserRequestHandler` for rebalance evaluation. Signals, not commands (may be skipped). "Latest wins" - newer replaces older atomically. See [Invariants C.17-C.24](invariants.md#c-rebalance-intent--temporal-invariants). +WaitForIdleAsync (“Was Idle” Semantics) +- Completes when the system was idle at some point, which is appropriate for tests and convergence checks. +- It does not guarantee the system is still idle after the task completes. -### Rebalance -Background process adjusting cache to desired range. Phases: (1) Decision (5-stage), (2) Execution (fetch/merge/trim), (3) Mutation (atomic). See [Architecture Model](architecture-model.md#smart-eventual-consistency-model). +## Storage And Materialization -### User Path -Handles user requests. Runs on user thread until intent published. Read-only. See [Invariants A.7-A.9](invariants.md#a-user-path--fast-user-access-invariants). +UserCacheReadMode +- Controls how data is stored and served (materialization strategy). See `docs/storage-strategies.md`. -### Background Path -Rebalance processing. Runs on background threads (IntentController, RebalanceExecutionController, RebalanceExecutor). See [Architecture Model](architecture-model.md#deterministic-background-job-synchronization). +Snapshot Mode +- Stores data in an immutable contiguous array and serves `ReadOnlyMemory` without per-read allocations. -### Debouncing -Delays execution (e.g., 100ms) to let bursts settle. Cancels previous if new scheduled during window. Prevents thrashing. +CopyOnRead Mode +- Stores data in a growable structure and copies on read (allocates per read) to reduce rebalance costs/LOH pressure in some scenarios. ---- +Staging Buffer +- A temporary buffer used during rebalance to assemble a new contiguous representation before atomic publication. +- See `docs/storage-strategies.md`. -## Concurrency & State +## Diagnostics -**Activity**: Operation tracked by `AsyncActivityCounter`. System idle when count = 0. -**Idle State**: No intents/rebalances executing. **"Was Idle" NOT "Is Idle"** - `WaitForIdleAsync()` = was idle at some point. See [Invariants H.49](invariants.md#h-activity-tracking--idle-detection-invariants). -**Stabilization**: Reaching stable state (rebalances done, cache = desired, no pending intents). Not persistent. -**Cache State**: Mutable container (`Cache`, `IsInitialized`, `NoRebalanceRange`). Only mutated by `RebalanceExecutor`. See [Invariant F.36](invariants.md#f-rebalance-execution-invariants). -**Execution Request**: Rebalance request from `IntentController` → `RebalanceExecutionController`. Contains desired ranges, intent data, cancellation token. +ICacheDiagnostics +- Optional instrumentation surface for observing user requests, decisions, rebalance execution, and failures. +- See `docs/diagnostics.md`. ---- +NoOpDiagnostics +- The default diagnostics implementation that does nothing (intended to be effectively zero overhead). -## Concurrency Primitives - -**Volatile Read/Write**: Memory barriers. `Write` = release fence, `Read` = acquire fence. Lock-free publishing. -**Interlocked Ops**: Atomic operations (`Increment`, `Decrement`, `Exchange`, `CompareExchange`). -**Acquire-Release**: Memory ordering. Writes before "release" visible after "acquire". See [Architecture Model](architecture-model.md#lock-free-implementation). - ---- - -## Testing & Diagnostics - -**WaitForIdleAsync**: Returns `Task` when "was idle at some point". For testing convergence. NOT guaranteed still idle. See [Invariants - Testing](invariants.md#testing-infrastructure-deterministic-synchronization). -**Cache Diagnostics**: Instrumentation interface (`ICacheDiagnostics`). Emits events for requests, decisions, completions, failures. See [Diagnostics](diagnostics.md). - ---- - -## Invariants +## Common Misconceptions -**Architectural**: System truths that ALWAYS hold (Cache Contiguity, Single-Writer, User Path Priority). See [Invariants](invariants.md). -**Behavioral**: Expected behaviors, testable via public API. See [Invariants - Behavioral](invariants.md#understanding-this-document). -**Conceptual**: Design principles. See [Invariants - Conceptual](invariants.md#understanding-this-document). +**Intent vs Command**: Intents are signals — evaluation may skip execution entirely. They are not commands that guarantee rebalance will happen. ---- +**Async Rebalancing**: `GetDataAsync` returns immediately; the User Path completes at `PublishIntent()` return. Rebalancing happens in background loops after the user thread has already returned. -## Configuration +**"Was Idle" Semantics**: `WaitForIdleAsync` guarantees the system was idle at some point, not that it is still idle after the task completes. New activity may start immediately after completion. Re-check state if stronger guarantees are needed. -**Window Size**: Total cache size (domain elements). -**Left/Right Split**: Proportional division vs request. Example: 30%/70%. -**Threshold %**: NoRebalanceRange zone shrinkage percentage. Must satisfy: `leftThreshold + rightThreshold ≤ 1.0` when both are specified. Example: 10% = skip rebalance if request within 10% of boundary. Sum constraint prevents overlapping shrinkage zones. -**Debounce Delay**: Execution delay (e.g., 100ms). Settles bursts. -**Storage Strategy**: **Snapshot** (immutable, WebAssembly-safe) or **CopyOnRead** (memory-efficient). See [Storage Strategies](storage-strategies.md). +**NoRebalanceRange**: This is a stability zone derived from the current cache range using threshold percentages. It is NOT the same as the current cache range — it is a shrunk inner zone. If the requested range falls within this zone, rebalance is skipped even though the requested range may extend close to the cache boundary. ---- +## Concurrency Primitives -## Common Misconceptions +**Volatile Read / Write**: Memory barriers. `Volatile.Write` = release fence (writes before it are visible before the write is observed). `Volatile.Read` = acquire fence (reads after it observe writes before the corresponding release). Used for lock-free publishing of shared state. -**Intent vs Command**: Intents are signals (evaluation may skip), not commands (guaranteed execution). -**Async Rebalancing**: `GetDataAsync` returns immediately, rebalancing happens in background. -**"Was Idle" Semantics**: `WaitForIdleAsync` guarantees system was idle at some point, not still idle after. -**NoRebalanceRange**: Stability zone around cache (may differ from actual cache range). +**Interlocked Operations**: Atomic operations that complete without locks — `Increment`, `Decrement`, `Exchange`, `CompareExchange`. Used for activity counting, intent replacement, and disposal state transitions. ---- +**Acquire-Release Ordering**: Memory ordering model used throughout. Writes before a "release" fence are visible to any thread that subsequently observes an "acquire" fence on the same location. The `AsyncActivityCounter` and intent publication patterns rely on this for safe visibility across threads without locks. -## Related Documentation +## See Also -[README](../README.md) | [Architecture Model](architecture-model.md) | [Invariants](invariants.md) | [Component Map](component-map.md) | [Actor Responsibilities](actors-and-responsibilities.md) | [Scenarios](scenario-model.md) | [State Machine](cache-state-machine.md) | [Storage Strategies](storage-strategies.md) | [Diagnostics](diagnostics.md) +`README.md` +`docs/architecture.md` +`docs/components/overview.md` +`docs/actors.md` +`docs/scenarios.md` +`docs/state-machine.md` +`docs/invariants.md` +`docs/boundary-handling.md` +`docs/storage-strategies.md` +`docs/diagnostics.md` diff --git a/docs/invariants.md b/docs/invariants.md index 49f1fe4..9509f5d 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -157,7 +157,7 @@ without polling or timing dependencies. **Rationale:** Eliminates write-write races and simplifies reasoning about cache consistency through architectural constraints. -**Implementation:** See [component-map.md - Single-Writer Architecture](component-map.md#single-writer-architecture) for enforcement mechanism details. +**Implementation:** See `docs/components/overview.md` and `docs/architecture.md` for enforcement mechanism details. **A.0** 🔵 **[Architectural]** The User Path **always has higher priority** than Rebalance Execution. @@ -168,7 +168,7 @@ without polling or timing dependencies. **Rationale:** Ensures responsive user experience by preventing background optimization from interfering with user-facing operations. -**Implementation:** See [component-map.md - Priority and Cancellation](component-map.md#priority-and-cancellation) for enforcement mechanism details. +**Implementation:** See `docs/architecture.md` and `docs/components/execution.md` for enforcement mechanism details. **A.0a** 🟢 **[Behavioral — Test: `Invariant_A_0a_UserRequestCancelsRebalance`]** A User Request **MAY cancel** an ongoing or pending Rebalance Execution **ONLY when a new rebalance is validated as necessary** by the multi-stage decision pipeline. @@ -181,7 +181,7 @@ without polling or timing dependencies. **Rationale:** Prevents thrashing while allowing necessary cache adjustments when user access pattern changes significantly. -**Implementation:** See [component-map.md - Intent Management and Cancellation](component-map.md#intent-management-and-cancellation) for enforcement mechanism details. +**Implementation:** See `docs/components/execution.md` for enforcement mechanism details. ### A.2 User-Facing Guarantees @@ -206,7 +206,7 @@ without polling or timing dependencies. **Rationale:** Centralizes intent origination to single actor, simplifying reasoning about when and why rebalances occur. -**Implementation:** See [component-map.md - UserRequestHandler Responsibilities](component-map.md#userrequesthandler-responsibilities) for enforcement mechanism details. +**Implementation:** See `docs/components/user-path.md` for enforcement mechanism details. **A.4** 🔵 **[Architectural]** Rebalance execution is **always performed asynchronously** relative to the User Path. @@ -217,7 +217,7 @@ without polling or timing dependencies. **Rationale:** Prevents user requests from blocking on background optimization work, ensuring responsive user experience. -**Implementation:** See [component-map.md - Async Execution Model](component-map.md#async-execution-model) for enforcement mechanism details. +**Implementation:** See `docs/architecture.md` and `docs/components/execution.md` for enforcement mechanism details. **A.5** 🔵 **[Architectural]** The User Path performs **only the work necessary to return data to the user**. @@ -228,7 +228,7 @@ without polling or timing dependencies. **Rationale:** Minimizes user-facing latency by deferring non-essential work to background threads. -**Implementation:** See [component-map.md - UserRequestHandler Responsibilities](component-map.md#userrequesthandler-responsibilities) for enforcement mechanism details. +**Implementation:** See `docs/components/user-path.md` for enforcement mechanism details. **A.6** 🟡 **[Conceptual]** The User Path may synchronously request data from `IDataSource` in the user execution context if needed to serve `RequestedRange`. - *Design decision*: Prioritizes user-facing latency over background work @@ -265,7 +265,7 @@ without polling or timing dependencies. **Rationale:** Enforces single-writer architecture, eliminating write-write races and simplifying concurrency reasoning. -**Implementation:** See [component-map.md - Single-Writer Architecture](component-map.md#single-writer-architecture) for enforcement mechanism details. +**Implementation:** See `docs/architecture.md` and `docs/components/overview.md` for enforcement mechanism details. **A.8** 🔵 **[Architectural — Tests: `Invariant_A3_8_ColdStart`, `_CacheExpansion`, `_FullCacheReplacement`]** The User Path **MUST NOT mutate cache under any circumstance**. @@ -277,7 +277,7 @@ without polling or timing dependencies. **Rationale:** Enforces single-writer architecture at the strictest level, preventing any mutation-related bugs in User Path. -**Implementation:** See [component-map.md - Single-Writer Enforcement](component-map.md#single-writer-architecture) for enforcement mechanism details. +**Implementation:** See `docs/architecture.md` and `docs/components/overview.md` for enforcement mechanism details. **A.9** 🔵 **[Architectural]** Cache mutations are performed **exclusively by Rebalance Execution** (single-writer architecture). @@ -288,7 +288,7 @@ without polling or timing dependencies. **Rationale:** Single-writer architecture eliminates write-write races and simplifies concurrency model. -**Implementation:** See [component-map.md - Single-Writer Architecture](component-map.md#single-writer-architecture) for enforcement mechanism details. +**Implementation:** See `docs/architecture.md` and `docs/components/overview.md` for enforcement mechanism details. **A.9a** 🟢 **[Behavioral — Test: `Invariant_A3_9a_CacheContiguityMaintained`]** **Cache Contiguity Rule:** `CacheData` **MUST always remain contiguous** — gapped or partially materialized cache states are invalid. - *Observable via*: All requests return valid contiguous data @@ -311,7 +311,7 @@ without polling or timing dependencies. **Rationale:** Prevents readers from observing inconsistent cache state during updates. -**Implementation:** See [component-map.md - Atomic Cache Updates](component-map.md#atomic-cache-updates) for enforcement mechanism details. +**Implementation:** See `docs/invariants.md` (atomicity invariants) and source XML docs; architecture context in `docs/architecture.md`. **B.13** 🔵 **[Architectural]** The system **never enters a permanently inconsistent state** with respect to `CacheData ↔ CurrentCacheRange`. @@ -322,7 +322,7 @@ without polling or timing dependencies. **Rationale:** Ensures cache remains usable even when rebalance operations are cancelled mid-flight. -**Implementation:** See [component-map.md - Consistency Under Cancellation](component-map.md#consistency-under-cancellation) for enforcement mechanism details. +**Implementation:** See `docs/architecture.md` and execution invariants in `docs/invariants.md`. **B.14** 🟡 **[Conceptual]** Temporary geometric or coverage inefficiencies in the cache are acceptable **if they can be resolved by rebalance execution**. - *Design decision*: User Path prioritizes speed over optimal cache shape @@ -341,7 +341,7 @@ without polling or timing dependencies. **Rationale:** Prevents cache from being updated with results that no longer match current user access pattern. -**Implementation:** See [component-map.md - Obsolete Result Prevention](component-map.md#obsolete-result-prevention) for enforcement mechanism details. +**Implementation:** See `docs/components/intent-management.md` and intent invariants in `docs/invariants.md`. --- @@ -356,7 +356,7 @@ without polling or timing dependencies. **Rationale:** Prevents queue buildup and ensures system always works toward most recent user access pattern. -**Implementation:** See [component-map.md - Intent Singularity](component-map.md#intent-singularity) for enforcement mechanism details. +**Implementation:** See `docs/components/intent-management.md`. **C.18** 🟡 **[Conceptual]** Previously created intents may become **logically superseded** when a new intent is published, but rebalance execution relevance is determined by the **multi-stage rebalance validation logic**. - *Design intent*: Obsolescence ≠ cancellation; obsolescence ≠ guaranteed execution prevention @@ -371,7 +371,7 @@ without polling or timing dependencies. **Rationale:** Enables User Path priority by allowing cancellation of obsolete background work. -**Implementation:** See [component-map.md - Cancellation Protocol](component-map.md#cancellation-protocol) for enforcement mechanism details. +**Implementation:** See `docs/architecture.md` and `docs/components/intent-management.md`. **C.20** 🔵 **[Architectural]** If a rebalance intent becomes obsolete before execution begins, the execution **must not start**. @@ -382,7 +382,7 @@ without polling or timing dependencies. **Rationale:** Avoids wasting CPU and I/O resources on obsolete cache shapes that no longer match user needs. -**Implementation:** See [component-map.md - Early Exit Validation](component-map.md#early-exit-validation) for enforcement mechanism details. +**Implementation:** See `docs/components/decision.md` and decision invariants in `docs/invariants.md`. **C.21** 🔵 **[Architectural]** At any point in time, **at most one rebalance execution is active**. @@ -393,7 +393,7 @@ without polling or timing dependencies. **Rationale:** Enforces single-writer architecture by ensuring only one component can mutate cache at any time. -**Implementation:** See [component-map.md - Serial Execution Guarantee](component-map.md#serial-execution-guarantee) for enforcement mechanism details. +**Implementation:** See `docs/architecture.md` (execution strategies) and `docs/components/execution.md`. **C.22** 🟡 **[Conceptual]** The results of rebalance execution **always reflect the latest user access pattern**. - *Design guarantee*: Obsolete results are discarded @@ -421,7 +421,7 @@ without polling or timing dependencies. **Rationale:** Prevents duplicate data fetching and ensures cache converges to exact data user saw. -**Implementation:** See [component-map.md - Intent Data Contract](component-map.md#intent-data-contract) for enforcement mechanism details. +**Implementation:** See `docs/components/user-path.md` and intent invariants in `docs/invariants.md`. **C.24f** 🟡 **[Conceptual]** Delivered data in intent serves as the **authoritative source** for Rebalance Execution, avoiding duplicate fetches and ensuring consistency with user view. - *Design guarantee*: Rebalance Execution uses delivered data as base, not current cache @@ -431,7 +431,7 @@ without polling or timing dependencies. ## D. Rebalance Decision Path Invariants -> **📖 For detailed architectural explanation, see:** [Architecture Model - Decision-Driven Execution](architecture-model.md#rebalance-validation-vs-cancellation) +> **📖 For architectural explanation, see:** `docs/architecture.md` ### D.0 Rebalance Decision Model Overview @@ -509,7 +509,7 @@ The system prioritizes **decision correctness and work avoidance** over aggressi **Rationale:** Pure decision logic enables reasoning about correctness and prevents unintended side effects. -**Implementation:** See [component-map.md - Pure Decision Logic](component-map.md#pure-decision-logic) for enforcement mechanism details. +**Implementation:** See `docs/components/execution.md`. **D.26** 🔵 **[Architectural]** The Decision Path **never mutates cache state**. @@ -520,7 +520,7 @@ The system prioritizes **decision correctness and work avoidance** over aggressi **Rationale:** Enforces clean separation between decision-making and state mutation, simplifying reasoning. -**Implementation:** See [component-map.md - Decision-Execution Separation](component-map.md#decision-execution-separation) for enforcement mechanism details. +**Implementation:** See `docs/architecture.md` and `docs/components/execution.md`. **D.27** 🟢 **[Behavioral — Test: `Invariant_D27_NoRebalanceIfRequestInNoRebalanceRange`]** If `RequestedRange` is fully contained within `NoRebalanceRange`, **rebalance execution is prohibited**. - *Observable via*: DEBUG counters showing execution skipped (policy-based, see C.24b) @@ -548,7 +548,7 @@ The system prioritizes **decision correctness and work avoidance** over aggressi **Rationale:** Multi-stage validation prevents thrashing while ensuring cache converges to optimal state. -**Implementation:** See [component-map.md - Multi-Stage Decision Pipeline](component-map.md#multi-stage-decision-pipeline) for enforcement mechanism details. +**Implementation:** See decision engine source XML docs; conceptual model in `docs/architecture.md`. --- @@ -567,7 +567,7 @@ The system prioritizes **decision correctness and work avoidance** over aggressi **Rationale:** Deterministic range computation ensures predictable cache behavior independent of history. -**Implementation:** See [component-map.md - Desired Range Computation](component-map.md#desired-range-computation) for enforcement mechanism details. +**Implementation:** See range planner source XML docs; architecture context in `docs/components/decision.md`. **E.32** 🟡 **[Conceptual]** `DesiredCacheRange` represents the **canonical target state** towards which the system converges. - *Design concept*: Single source of truth for "what cache should be" @@ -586,7 +586,7 @@ The system prioritizes **decision correctness and work avoidance** over aggressi **Rationale:** Stability zone prevents thrashing when user makes small movements within already-cached area. -**Implementation:** See [component-map.md - NoRebalanceRange Computation](component-map.md#norebalancerange-computation) for enforcement mechanism details. +**Implementation:** See `docs/components/decision.md`. **E.35** 🟢 **[Behavioral]** When both `LeftThreshold` and `RightThreshold` are specified (non-null), their sum must not exceed 1.0. @@ -632,7 +632,7 @@ leftThreshold.HasValue && rightThreshold.HasValue **Rationale:** Ensures background work never degrades responsiveness to user requests. -**Implementation:** See [component-map.md - Cancellation Checkpoints](component-map.md#cancellation-checkpoints) for enforcement mechanism details. +**Implementation:** See `docs/components/execution.md`. **F.35b** 🟢 **[Behavioral — Covered by `Invariant_B15`]** Partially executed or cancelled Rebalance Execution **MUST NOT leave cache in inconsistent state**. - *Observable via*: Cache continues serving valid data after cancellation @@ -649,7 +649,7 @@ leftThreshold.HasValue && rightThreshold.HasValue **Rationale:** Single-writer architecture eliminates all write-write races and simplifies concurrency reasoning. -**Implementation:** See [component-map.md - Single-Writer Architecture](component-map.md#single-writer-architecture) for enforcement mechanism details. +**Implementation:** See `docs/architecture.md`. **F.36a** 🟢 **[Behavioral — Test: `Invariant_F36a_RebalanceNormalizesCache`]** Rebalance Execution mutates cache for normalization using **delivered data from intent as authoritative base**: - **Uses delivered data** from intent (not current cache) as starting point @@ -671,7 +671,7 @@ leftThreshold.HasValue && rightThreshold.HasValue **Rationale:** Complete mutation authority enables efficient convergence to optimal cache shape in single operation. -**Implementation:** See [component-map.md - Cache Normalization Operations](component-map.md#cache-normalization-operations) for enforcement mechanism details. +**Implementation:** See `docs/components/execution.md`. **F.38** 🔵 **[Architectural]** Rebalance Execution requests data from `IDataSource` **only for missing subranges**. @@ -682,7 +682,7 @@ leftThreshold.HasValue && rightThreshold.HasValue **Rationale:** Avoids wasting I/O bandwidth by re-fetching data already in cache. -**Implementation:** See [component-map.md - Incremental Data Fetching](component-map.md#incremental-data-fetching) for enforcement mechanism details. +**Implementation:** See `docs/components/user-path.md`. **F.39** 🔵 **[Architectural]** Rebalance Execution **does not overwrite existing data** that intersects with `DesiredCacheRange`. @@ -693,7 +693,7 @@ leftThreshold.HasValue && rightThreshold.HasValue **Rationale:** Preserves valid cached data, avoiding redundant fetches and ensuring consistency. -**Implementation:** See [component-map.md - Data Preservation During Expansion](component-map.md#data-preservation-during-expansion) for enforcement mechanism details. +**Implementation:** See execution invariants in `docs/invariants.md`. ### F.3 Post-Execution Guarantees @@ -729,7 +729,7 @@ The Rebalance Decision Path and Rebalance Execution Path MUST execute asynchrono **Rationale:** Ensures user requests remain responsive by offloading all optimization work to background threads. -**Implementation:** See [component-map.md - Async Execution Model](component-map.md#async-execution-model) for enforcement mechanism details. +**Implementation:** See `docs/architecture.md`. - 🔵 **[Architectural — Covered by same test as G.43]** ### G.45: I/O responsibilities are separated between User Path and Rebalance Execution Path @@ -747,7 +747,7 @@ I/O operations (data fetching via IDataSource) are divided by responsibility: **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](component-map.md#io-isolation) for enforcement mechanism details. +**Implementation:** See `docs/architecture.md` and execution invariants. - 🔵 **[Architectural — Covered by same test as G.43]** **G.46** 🟢 **[Behavioral — Tests: `Invariant_G46_UserCancellationDuringFetch`, `Invariant_F35_G46_RebalanceCancellationBehavior`]** Cancellation **must be supported** for all scenarios: @@ -793,7 +793,7 @@ When activity counter reaches zero (idle state), NO work exists in any of these **Rationale:** Ensures idle detection accurately reflects all enqueued work, preventing premature idle signals. -**Implementation:** See [component-map.md - Activity Counter Ordering](component-map.md#activity-counter-ordering) for enforcement mechanism details. +**Implementation:** See `src/SlidingWindowCache/Infrastructure/Concurrency/AsyncActivityCounter.cs`. - 🔵 **[Architectural — Enforced by call site ordering]** ### H.48: Decrement-After-Completion Invariant @@ -812,7 +812,7 @@ Activity counter accurately reflects active work count at all times: **Rationale:** Ensures `WaitForIdleAsync()` will eventually complete by preventing counter leaks on any execution path. -**Implementation:** See [component-map.md - Activity Counter Cleanup](component-map.md#activity-counter-cleanup) for enforcement mechanism details. +**Implementation:** See `src/SlidingWindowCache/Infrastructure/Concurrency/AsyncActivityCounter.cs`. - 🔵 **[Architectural — Enforced by finally blocks]** **H.49** 🟡 **[Conceptual — Eventual consistency design]** **"Was Idle" Semantics:** @@ -940,7 +940,7 @@ For conceptual invariants, the design rationale is explained. ## Related Documentation -- **[Component Map](component-map.md)** - Detailed component responsibilities and ownership -- **[Architecture Model](architecture-model.md)** - Single-consumer model and coordination -- **[Scenario Model](scenario-model.md)** - Temporal behavior scenarios +- **[Components](components/overview.md)** - Component responsibilities and ownership +- **[Architecture](architecture.md)** - Single-consumer model and coordination +- **[Scenarios](scenarios.md)** - Temporal behavior scenarios - **[Storage Strategies](storage-strategies.md)** - Staging buffer pattern and memory behavior diff --git a/docs/scenario-model.md b/docs/scenario-model.md deleted file mode 100644 index a237007..0000000 --- a/docs/scenario-model.md +++ /dev/null @@ -1,516 +0,0 @@ -# Sliding Window Cache — Scenario Model (Temporal Perspective) - -This document describes the complete behavioral model of the Sliding Window Cache -from a temporal and procedural perspective. - -The goal is to explicitly capture all possible execution scenarios and paths -before projecting them onto architectural components, responsibilities, and APIs. - -The model is structured into three independent but sequentially connected paths -(one logically follows another): - -1. User Path — synchronous, user-facing behavior -2. Rebalance Decision Path — validation and decision making -3. Rebalance Execution Path — asynchronous cache normalization - ---- - -## Base Definitions - -The following terms are used consistently across all scenarios: - -- **RequestedRange** - A range requested by the user. - -- **IsInitialized** - Whether the cache has been initialized (i.e., Rebalance Execution has written to the cache at least once). - -- **CurrentCacheRange** - The range of data currently stored in the cache. - -- **CacheData** - The data corresponding to CurrentCacheRange. - -- **DesiredCacheRange** - The target cache range computed from RequestedRange and cache configuration - (left/right expansion sizes, thresholds, etc.). - -- **NoRebalanceRange** - A range inside which cache rebalance is not required. - -- **IDataSource** - A sequential, range-based data source. - ---- - -## Testing Infrastructure Note - -**Deterministic Synchronization**: Tests use `cache.WaitForIdleAsync()` to synchronize with -background rebalance completion. This is infrastructure/testing API implementing an -observe-and-stabilize pattern based on Task lifecycle tracking. - -This synchronization mechanism is **not part of the domain flow** described below. -It exists solely to enable deterministic testing without timing dependencies. - -See [Architecture Model](architecture-model.md) for implementation details. - ---- - -# I. USER PATH — User-Facing Scenarios - -*(Synchronous — executed in the user's thread)* - -The User Path is responsible only for: - -- deciding how to serve the user request -- selecting the data source (cache or IDataSource) -- triggering rebalance (without executing it) - ---- - -## User Scenario U1 — Cold Cache Request - -### Preconditions - -- `IsInitialized == false` -- `CurrentCacheRange == null` -- `CacheData == null` - -### Action Sequence - -1. User requests RequestedRange -2. Cache detects that it is not initialized -3. Cache requests RequestedRange from IDataSource in the user thread - (this is unavoidable because the user request must be served) -4. A rebalance intent is published (fire-and-forget) with the fetched data -5. Data is immediately returned to the user -6. Rebalance execution (background) stores the data as CacheData, - sets CurrentCacheRange to RequestedRange, and sets IsInitialized to true - -**Note:** -The User Path does not expand the cache beyond RequestedRange. - ---- - -## User Scenario U2 — Full Cache Hit (Within NoRebalanceRange) - -### Preconditions - -- `IsInitialized == true` -- `CurrentCacheRange.Contains(RequestedRange) == true` -- `NoRebalanceRange.Contains(RequestedRange) == true` - -### Action Sequence - -1. User requests RequestedRange -2. Cache detects a full cache hit -3. Data is read from CacheData -4. Rebalance intent is published but Decision Engine skips execution - (because `NoRebalanceRange.Contains(RequestedRange) == true`) -5. Data is returned to the user - ---- - -## User Scenario U3 — Full Cache Hit (Outside NoRebalanceRange) - -### Preconditions - -- `IsInitialized == true` -- `CurrentCacheRange.Contains(RequestedRange) == true` -- `NoRebalanceRange.Contains(RequestedRange) == false` - -### Action Sequence - -1. User requests RequestedRange -2. Cache detects that all requested data is available -3. Subrange is read from CacheData -4. Rebalance is triggered asynchronously -5. Data is returned to the user - ---- - -## User Scenario U4 — Partial Cache Hit - -### Preconditions - -- `IsInitialized == true` -- `CurrentCacheRange.Intersects(RequestedRange) == true` -- `CurrentCacheRange.Contains(RequestedRange) == false` - -### Action Sequence - -1. User requests RequestedRange -2. Cache computes intersection with CurrentCacheRange -3. Missing part is synchronously requested from IDataSource -4. Cache: - - merges cached and newly fetched data **locally** (in-memory assembly, not stored to cache) - - does **not** trim excess data - - does **not** update CurrentCacheRange (User Path is READ-ONLY with respect to cache state) -5. Rebalance is triggered asynchronously -6. RequestedRange data is returned to the user - -**Note:** -Cache expansion is permitted here because RequestedRange intersects CurrentCacheRange, -preserving cache contiguity. Excess data may temporarily remain in CacheData for reuse during Rebalance. - ---- - -## User Scenario U5 — Full Cache Miss (Jump) - -### Preconditions - -- `IsInitialized == true` -- `CurrentCacheRange.Intersects(RequestedRange) == false` - -### Action Sequence - -1. User requests RequestedRange -2. Cache determines that RequestedRange does NOT intersect with CurrentCacheRange -3. **Cache contiguity enforcement:** Cached data cannot be preserved (would create gaps) -4. RequestedRange is synchronously requested from IDataSource -5. Cache: - - **fully replaces** CacheData with new data - - **fully replaces** CurrentCacheRange with RequestedRange -6. Rebalance is triggered asynchronously -7. Data is returned to the user - -**Critical Note:** -Partial cache expansion is FORBIDDEN in this case, as it would create logical gaps -and violate the Cache Contiguity Rule (Invariant 9a). The cache MUST remain contiguous. - ---- - -# II. REBALANCE DECISION PATH — Decision Scenarios - -> **📖 For architectural explanation of decision-driven execution, see:** [Architecture Model - Decision-Driven Execution](architecture-model.md#rebalance-validation-vs-cancellation) - -> **⚡ Execution Context:** This entire path executes in a **dedicated background thread** (IntentController.ProcessIntentsAsync loop). The user thread returns immediately after publishing the intent (fire-and-forget). See IntentController.cs:228-230 for implementation details. - -**Core Principle**: Rebalance necessity is determined by multi-stage analytical validation, not by intent existence. - -Publishing a rebalance intent does NOT guarantee execution. The **Rebalance Decision Engine** -is the sole authority for determining rebalance necessity through a multi-stage validation pipeline: - -1. **Stage 1**: Current Cache NoRebalanceRange validation (fast-path rejection) -2. **Stage 2**: Pending Desired Cache NoRebalanceRange validation (anti-thrashing) -3. **Stage 3**: Compute DesiredCacheRange from RequestedRange + configuration -4. **Stage 4**: DesiredCacheRange vs CurrentCacheRange equality check (no-op prevention) - -Execution occurs **ONLY if ALL validation stages confirm necessity**. The decision path -may determine that execution is not needed (NoRebalanceRange containment, pending -rebalance coverage, or DesiredRange == CurrentRange), in which case execution is -skipped entirely. Additionally, intents may be superseded or cancelled before -execution begins. - -The Rebalance Decision Path: - -- never mutates cache state (pure analytical logic, CPU-only) -- may result in a no-op (work avoidance through validation) -- determines whether execution is required (THE authority for necessity determination) - -This path is always triggered by the User Path, but validation determines execution. - ---- - -## Decision Scenario D1 — Rebalance Blocked by NoRebalanceRange (Stage 1 Validation) - -### Condition - -- `NoRebalanceRange.Contains(RequestedRange) == true` - -### Sequence - -1. Decision path starts (Stage 1: Current Cache NoRebalanceRange validation) -2. NoRebalanceRange computed from CurrentCacheRange is checked -3. RequestedRange is fully contained within NoRebalanceRange -4. Validation rejects: rebalance unnecessary (current cache provides sufficient buffer) -5. Fast return — rebalance is skipped - (Execution Path is not started) - -**Rationale**: Current cache already provides adequate coverage around the requested range. -No I/O or cache mutation needed. - ---- - -## Decision Scenario D2 — Rebalance Allowed but Desired Equals Current (Stage 4 Validation) - -### Condition - -- `NoRebalanceRange.Contains(RequestedRange) == false` (Stage 1 passed) -- `DesiredCacheRange == CurrentCacheRange` - -### Sequence - -1. Decision path starts -2. Stage 1 validation: NoRebalanceRange check — no fast return -3. Stage 3: DesiredCacheRange is computed from RequestedRange + config -4. Stage 4 validation: Desired equals Current (cache already in optimal configuration) -5. Validation rejects: rebalance unnecessary (no geometry change needed) -6. Fast return — rebalance is skipped - (Execution Path is not started) - -**Rationale**: Cache is already sized and positioned optimally for this request. -No I/O or cache mutation needed. - ---- - -## Decision Scenario D3 — Rebalance Required (All Validation Stages Passed) - -### Condition - -- `NoRebalanceRange.Contains(RequestedRange) == false` (Stage 1 passed) -- `DesiredCacheRange != CurrentCacheRange` (Stage 4 confirms change needed) - -### Sequence - -1. Decision path starts -2. Stage 1 validation: NoRebalanceRange check — no fast return -3. Stage 2 validation (if applicable): Pending Desired Cache NoRebalanceRange check — no rejection -4. Stage 3: DesiredCacheRange is computed from RequestedRange + config -5. Stage 4 validation: Desired differs from Current (cache geometry change required) -6. Validation confirms: rebalance necessary -7. Execution Path is started asynchronously - -**Rationale**: ALL validation stages confirm that cache requires rebalancing to optimal configuration. -Rebalance Execution will normalize cache to DesiredCacheRange using delivered data as authoritative source. - ---- - -## Decision Scenario D1b — Rebalance Blocked by Pending Desired Cache (Stage 2 Validation - Anti-Thrashing) - -### Condition - -- Stage 1 passed: `NoRebalanceRange(CurrentCacheRange).Contains(RequestedRange) == false` -- Stage 2 check: Pending rebalance exists with PendingDesiredCacheRange -- `NoRebalanceRange(PendingDesiredCacheRange).Contains(RequestedRange) == true` - -### Sequence - -1. Decision path starts -2. Stage 1 validation: Current Cache NoRebalanceRange check — no fast return -3. Stage 2 validation: Check if pending rebalance exists -4. If pending rebalance exists, compute NoRebalanceRange from PendingDesiredCacheRange -5. RequestedRange is fully contained within pending NoRebalanceRange -6. Validation rejects: rebalance unnecessary (pending execution will satisfy this request) -7. Fast return — rebalance is skipped - (Execution Path is not started, existing pending rebalance continues) - -**Purpose**: Anti-thrashing mechanism preventing oscillating cache geometry. - -**Rationale**: A rebalance is already scheduled/executing that will position the cache -optimally for this request. Starting a new rebalance would cancel the pending one, -potentially causing thrashing if user access pattern is rapidly changing. Better to let -the pending rebalance complete. - -**Note**: Stage 2 is fully implemented — `RebalanceDecisionEngine.Evaluate()` checks `lastExecutionRequest?.DesiredNoRebalanceRange` to determine if a pending execution already covers the requested range. - ---- - -# III. REBALANCE EXECUTION PATH — Execution Scenarios - -The Execution Path is the only path that: - -- performs I/O -- mutates cache state -- normalizes cache structure - ---- - -## Rebalance Scenario R1 — Build from Scratch - -### Preconditions - -- `CurrentCacheRange == null` - -**OR** - -- `DesiredCacheRange.Intersects(CurrentCacheRange) == false` - -### Sequence - -1. DesiredCacheRange is requested from IDataSource -2. CacheData is fully replaced -3. CurrentCacheRange is set to DesiredCacheRange -4. NoRebalanceRange is computed - ---- - -## Rebalance Scenario R2 — Expand Cache (Partial Overlap) - -### Preconditions - -- `DesiredCacheRange.Intersects(CurrentCacheRange) == true` -- `DesiredCacheRange != CurrentCacheRange` - -### Sequence - -1. Missing subranges are computed -2. Missing data is requested from IDataSource -3. Data is merged with existing CacheData -4. CacheData is normalized to DesiredCacheRange -5. NoRebalanceRange is updated - ---- - -## Rebalance Scenario R3 — Shrink / Normalize Cache - -### Preconditions - -- `CurrentCacheRange.Contains(DesiredCacheRange) == true` - -### Sequence - -1. CacheData is trimmed to DesiredCacheRange -2. CurrentCacheRange is updated -3. NoRebalanceRange is recomputed - ---- - -# IV. CONCURRENCY & CANCELLATION SCENARIOS - -This section describes temporal and concurrency-related scenarios -that occur when user requests arrive while rebalance logic is pending -or already executing. - -These scenarios are fundamental to the **Fast User Access** philosophy -and define how obsolete background work must be handled. - ---- - -## Concurrency Principles - -The Sliding Window Cache follows these rules: - -1. User Path is never blocked by rebalance logic -2. Multiple rebalance triggers may overlap in time -3. Only the **latest rebalance intent** is relevant -4. Obsolete rebalance work must be cancelled or abandoned -5. Rebalance execution must support cancellation -6. Cache state may be temporarily inconsistent but must be overwrite-safe - ---- - -## Concurrency Scenario C1 — Rebalance Triggered While Previous Rebalance Is Pending - -### Situation - -- User request U₁ triggers rebalance R₁ (fire-and-forget) -- R₁ has not started execution yet (queued or delayed) -- User request U₂ arrives before R₁ executes - -### Expected Behavior - -1. **The new intent from U₂ supersedes R₁: IntentController's decision pipeline cancels any pending rebalance work when validation confirms new execution is necessary** -2. User Path for U₂ executes normally and immediately -3. A new rebalance trigger R₂ is issued -4. R₁ is cancelled or marked obsolete -5. Only R₂ is allowed to proceed to execution - -**Outcome:** -No rebalance work is executed based on outdated user intent. User Path always has priority. - ---- - -## Concurrency Scenario C2 — Rebalance Triggered While Previous Rebalance Is Executing - -### Situation - -- User request U₁ triggers rebalance R₁ -- R₁ has already started execution (I/O or merge in progress) -- User request U₂ arrives and triggers rebalance R₂ - -### Expected Behavior - -1. **The new intent from U₂ supersedes R₁: IntentController's decision pipeline cancels the ongoing rebalance when validation confirms new execution is necessary** -2. User Path for U₂ executes normally and immediately -3. R₂ becomes the latest rebalance intent -4. R₁ receives a cancellation signal -5. R₁: - - stops execution as early as possible, OR - - completes but discards its results -6. R₂ proceeds with fresh DesiredCacheRange - -**Outcome:** -Cache normalization reflects the most recent user access pattern. User Path and Rebalance Execution never mutate cache -concurrently. - ---- - -## Concurrency Scenario C3 — Multiple Rapid User Requests (Spike / Random Access) - -### Situation - -- User produces a burst of requests: U₁, U₂, U₃, ..., Uₙ -- Each request triggers rebalance -- Rebalance execution cannot keep up with trigger rate - -### Expected Behavior - -1. User Path always serves requests independently -2. Rebalance triggers are debounced or superseded -3. At most one rebalance execution is active at any time -4. Only the final rebalance intent is executed -5. All intermediate rebalance work is cancelled or skipped - -**Outcome:** -System remains responsive and converges to a stable cache state -once user activity slows down. - ---- - -## Cancellation and State Safety Guarantees - -To support these scenarios, the following guarantees must hold: - -- Rebalance execution must be cancellable -- Cache mutations must be atomic or overwrite-safe -- Partial rebalance results must not corrupt cache state -- Final rebalance always produces a fully normalized cache - -Temporary inconsistency is acceptable. -Permanent inconsistency is not. - ---- - -## Design Note - -Concurrency handling is a **behavioral requirement**, not an implementation detail. - -The specific mechanism (cancellation tokens, versioning, actors, single-flight execution) -is intentionally left unspecified and will be defined during architectural projection. - ---- - -# Final Picture - -- User Path is fast, synchronous, and always responds -- Decision Path is lightweight and often results in no-op -- Execution Path is heavy, isolated, and asynchronous - -All scenarios: - -- are responsibility-isolated -- are expressed as temporal processes -- are independent of specific storage implementations - ---- - -## Notes and Considerations - -1. Decision Path and Execution Path should not execute in the user thread. - The Decision Path is lightweight, CPU-only (no I/O), and often results in no-op. - The Execution Path involves asynchronous I/O (IDataSource access). - - Using a ThreadPool-based or background scheduling approach aligns with - the core philosophy of SlidingWindowCache: - **fast user access with minimal mandatory work in the user thread**. - -2. Rebalance Execution scenarios (R1–R3) may be implemented as a unified pipeline: - - compute missing ranges - - request missing data - - merge with existing CacheData (if any) - - trim to DesiredCacheRange - - recompute NoRebalanceRange - - This document intentionally keeps these scenarios separate, as they describe - **semantic behavior**, not implementation strategy. diff --git a/docs/scenarios.md b/docs/scenarios.md new file mode 100644 index 0000000..800f0bf --- /dev/null +++ b/docs/scenarios.md @@ -0,0 +1,372 @@ +# Scenarios + +## Overview + +This document describes the temporal behavior of SlidingWindowCache: what happens over time when user requests occur, decisions are evaluated, and background executions run. + +## Motivation + +Component maps describe "what exists"; scenarios describe "what happens". Scenarios are the fastest way to debug behavior because they connect public API calls to background convergence. + +## Base Definitions + +The following terms are used consistently across all scenarios: + +- **RequestedRange** — A range requested by the user. +- **IsInitialized** — Whether the cache has been initialized (Rebalance Execution has written to the cache at least once). +- **CurrentCacheRange** — The range of data currently stored in the cache. +- **CacheData** — The data corresponding to `CurrentCacheRange`. +- **DesiredCacheRange** — The target cache range computed from `RequestedRange` and cache configuration (left/right expansion sizes, thresholds). +- **NoRebalanceRange** — A range inside which cache rebalance is not required (stability zone). +- **IDataSource** — A sequential, range-based data source. + +Canonical definitions: `docs/glossary.md`. + +## Design + +Scenarios are grouped by path: + +1. **User Path** (user thread) +2. **Decision Path** (background intent loop) +3. **Execution Path** (background execution) + +--- + +## I. User Path Scenarios + +### U1 — Cold Cache Request + +**Preconditions**: +- `IsInitialized == false` +- `CurrentCacheRange == null` +- `CacheData == null` + +**Action Sequence**: +1. User requests `RequestedRange` +2. Cache detects it is not initialized +3. Cache requests `RequestedRange` from `IDataSource` in the user thread (unavoidable — user request must be served immediately) +4. A rebalance intent is published (fire-and-forget) with the fetched data +5. Data is returned to the user immediately +6. Rebalance Execution (background) stores the data as `CacheData`, sets `CurrentCacheRange = RequestedRange`, sets `IsInitialized = true` + +**Note**: The User Path does not expand the cache beyond `RequestedRange`. Cache expansion to `DesiredCacheRange` is performed exclusively by Rebalance Execution. + +--- + +### U2 — Full Cache Hit (Within NoRebalanceRange) + +**Preconditions**: +- `IsInitialized == true` +- `CurrentCacheRange.Contains(RequestedRange) == true` +- `NoRebalanceRange.Contains(RequestedRange) == true` + +**Action Sequence**: +1. User requests `RequestedRange` +2. Cache detects a full cache hit +3. Data is read from `CacheData` +4. Rebalance intent is published; Decision Engine rejects execution at Stage 1 (NoRebalanceRange containment) +5. Data is returned to the user + +--- + +### U3 — Full Cache Hit (Outside NoRebalanceRange) + +**Preconditions**: +- `IsInitialized == true` +- `CurrentCacheRange.Contains(RequestedRange) == true` +- `NoRebalanceRange.Contains(RequestedRange) == false` + +**Action Sequence**: +1. User requests `RequestedRange` +2. Cache detects all requested data is available +3. Subrange is read from `CacheData` +4. Rebalance intent is published; Decision Engine proceeds through validation +5. Data is returned to the user +6. Rebalance executes asynchronously to shift the window + +--- + +### U4 — Partial Cache Hit + +**Preconditions**: +- `IsInitialized == true` +- `CurrentCacheRange.Intersects(RequestedRange) == true` +- `CurrentCacheRange.Contains(RequestedRange) == false` + +**Action Sequence**: +1. User requests `RequestedRange` +2. Cache computes intersection with `CurrentCacheRange` +3. Missing part is synchronously requested from `IDataSource` +4. Cache: + - merges cached and newly fetched data **locally** (in-memory assembly, not stored to cache) + - does **not** trim excess data + - does **not** update `CurrentCacheRange` (User Path is read-only with respect to cache state) +5. Rebalance intent is published; rebalance executes asynchronously +6. `RequestedRange` data is returned to the user + +**Note**: Cache expansion is permitted because `RequestedRange` intersects `CurrentCacheRange`, preserving cache contiguity. Excess data may temporarily remain in `CacheData` for reuse during Rebalance. + +--- + +### U5 — Full Cache Miss (Jump) + +**Preconditions**: +- `IsInitialized == true` +- `CurrentCacheRange.Intersects(RequestedRange) == false` + +**Action Sequence**: +1. User requests `RequestedRange` +2. Cache determines that `RequestedRange` does NOT intersect `CurrentCacheRange` +3. **Cache contiguity enforcement**: Cached data cannot be preserved — merging would create gaps +4. `RequestedRange` is synchronously requested from `IDataSource` +5. Cache: + - **fully replaces** `CacheData` with new data + - **fully replaces** `CurrentCacheRange` with `RequestedRange` +6. Rebalance intent is published; rebalance executes asynchronously +7. Data is returned to the user + +**Critical**: Partial cache expansion is FORBIDDEN in this case — it would create logical gaps and violate the Cache Contiguity Rule (Invariant A.9a). The cache MUST remain contiguous at all times. + +--- + +## II. Decision Path Scenarios + +**Core principle**: Rebalance necessity is determined by multi-stage analytical validation, not by intent existence. Publishing an intent does NOT guarantee execution. The Decision Engine is the sole authority for necessity determination. + +The validation pipeline: +1. **Stage 1**: Current Cache `NoRebalanceRange` validation (fast-path rejection) +2. **Stage 2**: Pending Desired Cache `NoRebalanceRange` validation (anti-thrashing) +3. **Stage 3**: Compute `DesiredCacheRange` from `RequestedRange` + configuration +4. **Stage 4**: `DesiredCacheRange` vs `CurrentCacheRange` equality check (no-op prevention) + +Execution occurs **only if ALL validation stages confirm necessity**. + +--- + +### D1 — Rebalance Blocked by NoRebalanceRange (Stage 1) + +**Condition**: +- `NoRebalanceRange.Contains(RequestedRange) == true` + +**Sequence**: +1. Intent arrives; Stage 1 validation begins +2. `NoRebalanceRange` computed from `CurrentCacheRange` is checked +3. `RequestedRange` is fully contained within `NoRebalanceRange` +4. Validation rejects: current cache provides sufficient buffer +5. Fast return — rebalance is skipped; Execution Path is not started + +**Rationale**: Current cache already provides adequate coverage around the requested range. No I/O or cache mutation needed. + +--- + +### D1b — Rebalance Blocked by Pending Desired Cache (Stage 2, Anti-Thrashing) + +**Condition**: +- Stage 1 passed: `NoRebalanceRange(CurrentCacheRange).Contains(RequestedRange) == false` +- Pending rebalance exists with `PendingDesiredCacheRange` +- `NoRebalanceRange(PendingDesiredCacheRange).Contains(RequestedRange) == true` + +**Sequence**: +1. Intent arrives; Stage 1 passes +2. Stage 2: pending rebalance exists — compute `NoRebalanceRange` from `PendingDesiredCacheRange` +3. `RequestedRange` is fully contained within pending `NoRebalanceRange` +4. Validation rejects: pending execution will already satisfy this request +5. Fast return — existing pending rebalance continues undisturbed + +**Purpose**: Anti-thrashing mechanism preventing oscillating cache geometry. + +**Rationale**: A rebalance is already scheduled that will position the cache optimally for this request. Starting a new rebalance would cancel the pending one, potentially causing thrashing. Better to let the pending rebalance complete. + +--- + +### D2 — Rebalance Blocked by No-Op Geometry (Stage 4) + +**Condition**: +- Stage 1 passed: `NoRebalanceRange.Contains(RequestedRange) == false` +- `DesiredCacheRange == CurrentCacheRange` + +**Sequence**: +1. Intent arrives; Stages 1–3 pass +2. Stage 3: `DesiredCacheRange` is computed from `RequestedRange` + config +3. Stage 4: `DesiredCacheRange == CurrentCacheRange` — cache already in optimal configuration +4. Validation rejects: no geometry change needed +5. Fast return — rebalance is skipped; Execution Path is not started + +**Rationale**: Cache is already sized and positioned optimally. No I/O or cache mutation needed. + +--- + +### D3 — Rebalance Required (All Validation Stages Passed) + +**Condition**: +- Stage 1 passed: `NoRebalanceRange.Contains(RequestedRange) == false` +- Stage 2 passed (if applicable): Pending coverage does not satisfy request +- Stage 4 passed: `DesiredCacheRange != CurrentCacheRange` + +**Sequence**: +1. Intent arrives; all validation stages pass +2. Stage 3: `DesiredCacheRange` computed +3. Stage 4 confirms: cache geometry change required +4. Validation confirms necessity +5. Prior pending execution is cancelled (if any) +6. New execution is scheduled + +**Rationale**: ALL validation stages confirm that cache requires rebalancing. Rebalance Execution will normalize cache to `DesiredCacheRange` using delivered data as authoritative source. + +--- + +## III. Execution Path Scenarios + +### R1 — Build from Scratch + +**Preconditions**: +- `CurrentCacheRange == null` + +OR: +- `DesiredCacheRange.Intersects(CurrentCacheRange) == false` + +**Sequence**: +1. `DesiredCacheRange` is requested from `IDataSource` +2. `CacheData` is fully replaced +3. `CurrentCacheRange` is set to `DesiredCacheRange` +4. `NoRebalanceRange` is computed + +--- + +### R2 — Expand Cache (Partial Overlap) + +**Preconditions**: +- `DesiredCacheRange.Intersects(CurrentCacheRange) == true` +- `DesiredCacheRange != CurrentCacheRange` + +**Sequence**: +1. Missing subranges are computed (`DesiredCacheRange \ CurrentCacheRange`) +2. Missing data is requested from `IDataSource` +3. Data is merged with existing `CacheData` +4. `CacheData` is normalized to `DesiredCacheRange` +5. `NoRebalanceRange` is updated + +--- + +### R3 — Shrink / Normalize Cache + +**Preconditions**: +- `CurrentCacheRange.Contains(DesiredCacheRange) == true` + +**Sequence**: +1. `CacheData` is trimmed to `DesiredCacheRange` +2. `CurrentCacheRange` is updated +3. `NoRebalanceRange` is recomputed + +--- + +## IV. Concurrency and Cancellation Scenarios + +### Concurrency Principles + +1. User Path is never blocked by rebalance logic. +2. Multiple rebalance triggers may overlap in time. +3. Only the **latest validated rebalance intent** is executed. +4. Obsolete rebalance work must be cancelled or abandoned. +5. Rebalance execution must support cancellation at all stages. +6. Cache state may be temporarily non-optimal but must always be consistent. + +--- + +### C1 — New Request While Rebalance Is Pending + +**Situation**: +- User request U₁ triggers rebalance R₁ (fire-and-forget) +- R₁ has not started execution yet (queued or debouncing) +- User request U₂ arrives before R₁ executes + +**Expected Behavior**: +1. New intent from U₂ supersedes R₁; Decision Engine validates necessity +2. User Path for U₂ executes normally and immediately +3. If validation confirms: R₁ is cancelled; new rebalance R₂ is scheduled +4. If validation rejects: R₁ continues (anti-thrashing, Stage 2 validation) +5. Only R₂ is allowed to execute (if scheduled) + +**Outcome**: No rebalance work executes based on outdated intent. User Path always has priority. + +--- + +### C2 — New Request While Rebalance Is Executing + +**Situation**: +- User request U₁ triggers rebalance R₁ +- R₁ has already started execution (I/O or merge in progress) +- User request U₂ arrives and triggers rebalance R₂ + +**Expected Behavior**: +1. New intent from U₂ supersedes R₁; Decision Engine validates necessity +2. User Path for U₂ executes normally and immediately +3. If validation confirms: R₁ receives cancellation signal +4. R₁ stops as early as possible or completes but discards its results +5. R₂ proceeds with fresh `DesiredCacheRange` + +**Outcome**: Cache normalization reflects the most recent validated access pattern. User Path and Rebalance Execution never mutate cache concurrently. + +--- + +### C3 — Multiple Rapid User Requests (Spike) + +**Situation**: +- User produces a burst of requests: U₁, U₂, U₃, ..., Uₙ +- Each request publishes an intent; rebalance execution cannot keep up + +**Expected Behavior**: +1. User Path serves all requests independently +2. Intents are superseded ("latest wins") +3. At most one rebalance execution is active at any time +4. Only the final validated intent is executed +5. All intermediate rebalance work is cancelled or skipped via decision validation + +**Outcome**: System remains responsive and converges to a stable cache state once user activity slows. + +--- + +### Cancellation and State Safety Guarantees + +For concurrency correctness, the following guarantees hold: + +- Rebalance execution is cancellable at all stages (before I/O, after I/O, before mutation) +- Cache mutations are atomic — no partial state is ever visible +- Partial rebalance results must not corrupt cache state (cancelled execution discards results) +- Final rebalance always produces a fully normalized, consistent cache + +Temporary non-optimal cache geometry is acceptable. Permanent inconsistency is not. + +## Invariants + +Scenarios must be consistent with: + +- User Path invariants: `docs/invariants.md` (Section A) +- Decision Path invariants: `docs/invariants.md` (Section D) +- Execution invariants: `docs/invariants.md` (Section F) +- Cache state invariants: `docs/invariants.md` (Section B) + +## Usage + +Use scenarios as a debugging checklist: + +1. What did the user call? +2. What was delivered? +3. What intent was published? +4. Did the decision validate execution? If not, which stage rejected? +5. Did execution run, debounce, and mutate atomically? +6. Was there a concurrent cancellation? Did the cache remain consistent? + +## Examples + +Diagnostics examples in `docs/diagnostics.md` show how to observe these scenario transitions in production. + +## Edge Cases + +- A cache can be "temporarily non-optimal"; eventual convergence is expected. +- `WaitForIdleAsync` indicates the system was idle at some point, not that it remains idle. +- In Scenario D1b, the pending rebalance may already be in execution; it continues undisturbed if validation confirms it will satisfy the new request. + +## Limitations + +- Scenarios are behavioral descriptions, not an exhaustive proof; invariants are the normative source. diff --git a/docs/state-machine.md b/docs/state-machine.md new file mode 100644 index 0000000..2998a62 --- /dev/null +++ b/docs/state-machine.md @@ -0,0 +1,277 @@ +# Cache State Machine + +## Overview + +This document defines the cache state machine at the public-observable level and clarifies transitions and mutation authority. + +## Motivation + +Most concurrency complexity disappears if we can answer two questions unambiguously: + +1. What state is the cache in? +2. Who is allowed to mutate shared state in that state? + +## Design + +### States + +The cache is in one of three states: + +**1. Uninitialized** +- `CurrentCacheRange == null` +- `CacheData == null` +- `IsInitialized == false` +- `NoRebalanceRange == null` + +**2. Initialized** +- `CurrentCacheRange != null` +- `CacheData != null` +- `CacheData` is consistent with `CurrentCacheRange` (Invariant B.11) +- Cache is contiguous — no gaps (Invariant A.9a) +- Ready to serve user requests + +**3. Rebalancing** +- Cache remains `Initialized` from the user-visible perspective +- User Path continues to serve requests normally +- Rebalance Execution is mutating cache asynchronously in the background +- Rebalance can be cancelled at any time + +### State Transition Diagram + +``` +┌─────────────────┐ +│ Uninitialized │ +└────────┬────────┘ + │ + │ T1: First User Request + │ (Rebalance Execution writes initial cache) + ▼ +┌─────────────────┐ +│ Initialized │◄───────────────┐ +└────────┬────────┘ │ + │ │ + │ T2: Decision validates │ + │ rebalance necessary │ + ▼ │ +┌─────────────────┐ │ +│ Rebalancing │ │ +└────────┬────────┘ │ + │ │ + │ T3: Execution completes │ + └─────────────────────────┘ + +T4: New user request during Rebalancing + → Decision validates new execution necessary + → Previous rebalance cancelled + → New rebalance scheduled (stays in Rebalancing) +``` + +### Mutation Authority + +Mutation authority is constant across all states: + +- **User Path**: read-only with respect to shared cache state in every state +- **Rebalance Execution**: sole writer in every state + +See `docs/invariants.md` for the formal single-writer rule (Invariants A.-1, A.7, A.8, A.9). + +### Transition Details + +#### T1: Uninitialized → Initialized (Cold Start) + +- **Trigger**: First user request (Scenario U1) +- **Actor**: Rebalance Execution (NOT User Path) +- **Sequence**: + 1. User Path fetches `RequestedRange` from `IDataSource` + 2. User Path returns data to user immediately + 3. User Path publishes intent with delivered data + 4. Rebalance Execution performs first cache write +- **Mutations** (Rebalance Execution only): + - Set `CacheData` = delivered data from intent + - Set `CurrentCacheRange` = delivered range + - Set `IsInitialized = true` +- **Atomicity**: Changes applied atomically (Invariant B.12) +- **Postcondition**: Cache enters `Initialized` after execution completes +- **Note**: User Path is read-only; initial cache population is performed exclusively by Rebalance Execution + +#### T2: Initialized → Rebalancing (Normal Operation) + +- **Trigger**: User request, decision validates rebalance necessary +- **Sequence**: + 1. User Path reads from cache or fetches from `IDataSource` (no cache mutation) + 2. User Path returns data to user immediately + 3. User Path publishes intent with delivered data + 4. Decision Engine runs multi-stage analytical validation (THE authority) + 5. If validation confirms necessity: prior pending rebalance cancelled, new execution scheduled + 6. If validation rejects (NoRebalanceRange containment, pending coverage, Desired==Current): no execution, work avoidance + 7. Rebalance Execution writes to cache (background, only if validated) +- **Mutations**: Rebalance Execution only — User Path never mutates `Cache`, `IsInitialized`, or `NoRebalanceRange` +- **Cancellation model**: Cancellation is mechanical coordination, not the decision mechanism; validation determines necessity +- **Postcondition**: Cache enters `Rebalancing` (only if all validation stages passed) + +#### T3: Rebalancing → Initialized (Rebalance Completion) + +- **Trigger**: Rebalance execution completes successfully +- **Actor**: Rebalance Executor (sole writer) +- **Mutations** (Rebalance Execution only): + - Use delivered data from intent as authoritative base + - Fetch missing data for `DesiredCacheRange` (only truly missing parts) + - Merge delivered data with fetched data + - Trim to `DesiredCacheRange` (normalization) + - Set `CacheData` and `CurrentCacheRange` via `Rematerialize()` + - Set `IsInitialized = true` + - Recompute `NoRebalanceRange` +- **Atomicity**: Changes applied atomically (Invariant B.12) +- **Postcondition**: Cache returns to stable `Initialized` state + +#### T4: Rebalancing → Rebalancing (New Request MAY Cancel Active Rebalance) + +- **Trigger**: User request arrives during rebalance execution (Scenarios C1, C2) +- **Sequence**: + 1. User Path reads from cache or fetches from `IDataSource` (no cache mutation) + 2. User Path returns data to user immediately + 3. User Path publishes new intent + 4. Decision Engine validates whether new rebalance is necessary + 5. If validation confirms necessity: active rebalance is cancelled; new execution scheduled + 6. If validation rejects necessity: active rebalance continues undisturbed (work avoidance) + 7. If cancelled: Rebalance yields; new rebalance uses new intent's delivered data +- **Critical**: User Path does NOT decide cancellation — Decision Engine validation determines necessity; cancellation is mechanical coordination +- **Note**: "User Request MAY Cancel" means cancellation occurs ONLY when validation confirms new rebalance is necessary + +### Mutation Ownership Matrix + +| State | User Path Mutations | Rebalance Execution Mutations | +|---------------|---------------------|-----------------------------------------------------------------------------------------------------------------| +| Uninitialized | None | Initial cache write (after first user request intent) | +| Initialized | None | Not active | +| Rebalancing | None | All cache mutations (expand, trim, Rematerialize, IsInitialized, NoRebalanceRange) — must yield on cancellation | + +**User Path mutations (Invariants A.7, A.8)**: +- User Path NEVER calls `Cache.Rematerialize()` +- User Path NEVER writes to `IsInitialized` +- User Path NEVER writes to `NoRebalanceRange` + +**Rebalance Execution mutations (Invariants F.36, F.36a)**: +1. Uses delivered data from intent as authoritative base +2. Expands to `DesiredCacheRange` (fetches only truly missing ranges) +3. Trims excess data outside `DesiredCacheRange` +4. Writes to `Cache.Rematerialize()` (cache data and range) +5. Writes to `IsInitialized = true` +6. Recomputes and writes to `NoRebalanceRange` + +### Concurrency Semantics + +**Cancellation Protocol**: + +1. User Path publishes new intent (atomically supersedes prior intent) +2. Background loop observes new intent; cancels active rebalance if validation confirms necessity +3. User Path reads from cache or fetches from `IDataSource` (no mutation) +4. User Path returns data to user (never waits) +5. New rebalance proceeds with new intent's delivered data (if validated) +6. Cancelled rebalance yields without leaving cache inconsistent + +**Cancellation Guarantees (Invariants F.34, F.34a, F.34b)**: +- Rebalance Execution MUST support cancellation at all stages +- Rebalance Execution MUST yield immediately when cancelled +- Cancelled execution MUST NOT leave cache inconsistent + +**State Safety**: +- **Atomicity**: All cache mutations are atomic (Invariant B.12) +- **Consistency**: `CacheData ↔ CurrentCacheRange` always consistent (Invariant B.11) +- **Contiguity**: Cache data never contains gaps (Invariant A.9a) +- **Idempotence**: Multiple cancellations are safe + +### State Invariants by State + +**In Uninitialized**: +- All range and data fields are null +- User Path is read-only (no mutations) +- Rebalance Execution is not active (activates after first intent) + +**In Initialized**: +- `CacheData ↔ CurrentCacheRange` consistent (Invariant B.11) +- Cache is contiguous (Invariant A.9a) +- User Path is read-only (Invariant A.8) +- Rebalance Execution is not active + +**In Rebalancing**: +- `CacheData ↔ CurrentCacheRange` remain consistent (Invariant B.11) +- Cache is contiguous (Invariant A.9a) +- User Path may cause cancellation but NOT mutate (Invariants A.0, A.0a) +- Rebalance Execution is active and sole writer (Invariant F.36) +- Rebalance Execution is cancellable (Invariant F.34) +- Single-writer architecture: no race conditions possible + +## Worked Examples + +### Example 1: Cold Start + +``` +State: Uninitialized +User requests [100, 200] +→ User Path fetches [100, 200] from IDataSource +→ User Path returns data to user immediately +→ User Path publishes intent with delivered data [100, 200] +→ Rebalance Execution writes: CacheData, CurrentCacheRange=[100,200], IsInitialized=true +State: Initialized +``` + +### Example 2: Expansion During Rebalancing + +``` +State: Initialized +CurrentCacheRange = [100, 200] + +User requests [150, 250] +→ User Path reads [150,200] from cache, fetches [201,250] from IDataSource +→ User Path returns assembled data to user +→ User Path publishes intent with delivered data [150, 250] +→ Decision validates: rebalance necessary → schedules R1 for DesiredCacheRange=[50,300] +State: Rebalancing (R1 executing) + +User requests [200, 300] (before R1 completes) +→ User Path reads/fetches data (no cache mutation) +→ User Path returns data to user +→ User Path publishes intent with delivered data [200, 300] +→ Decision validates: new rebalance necessary → cancels R1, schedules R2 +State: Rebalancing (R2 executing with new DesiredCacheRange) +``` + +### Example 3: Full Cache Miss During Rebalancing + +``` +State: Rebalancing +CurrentCacheRange = [100, 200] +R1 executing for DesiredCacheRange = [50, 250] + +User requests [500, 600] (no intersection with CurrentCacheRange) +→ User Path fetches [500, 600] from IDataSource (full miss) +→ User Path returns data to user +→ User Path publishes intent with delivered data [500, 600] +→ Decision validates: new rebalance necessary → cancels R1, schedules R2 +State: Rebalancing (R2 executing, will replace cache at DesiredCacheRange=[450,650]) +``` + +## Invariants + +- Cache state consistency: `docs/invariants.md` (Cache state invariants, Section B) +- Single-writer and atomic rematerialization: `docs/invariants.md` (Execution invariants, Section F) +- Cancellation protocol: `docs/invariants.md` (Execution invariants F.34, F.34a, F.34b) +- Decision authority and validation pipeline: `docs/invariants.md` (Decision Path invariants, Section D) + +## Usage + +Use this document to interpret diagnostics and scenarios: + +- `docs/diagnostics.md` +- `docs/scenarios.md` + +## Edge Cases + +- Cancellation may cause a rebalancing execution to stop early; atomicity guarantees prevent partial-state publication. +- Multiple rapid cancellations are safe; the single-writer architecture and atomic Rematerialize prevent inconsistency. + +## Limitations + +- This is a conceptual machine; internal implementation may use additional internal markers. +- The "Rebalancing" state is from the system's perspective; from the user's perspective the cache is always "Initialized" and serving requests. diff --git a/docs/storage-strategies.md b/docs/storage-strategies.md index a3845da..3517370 100644 --- a/docs/storage-strategies.md +++ b/docs/storage-strategies.md @@ -1,7 +1,7 @@ # Sliding Window Cache - Storage Strategies Guide > **📖 For component implementation details, see:** -> - [Component Map - Storage Section](component-map.md#2-storage-layer) - SnapshotReadStorage and CopyOnReadStorage architecture +> - `docs/components/infrastructure.md` - Storage components in context ## Overview diff --git a/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs b/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs index f996e68..65a84a7 100644 --- a/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs +++ b/src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs @@ -1,4 +1,4 @@ -using Intervals.NET; +using Intervals.NET; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.Rebalance.Intent; @@ -45,7 +45,7 @@ namespace SlidingWindowCache.Core.Planning; /// D.25, D.26: Analytical/pure (CPU-only), never mutates cache state /// /// Related: (threshold calculation, when to rebalance logic) -/// See: for architectural overview. +/// See: for architectural overview. /// /// Type representing the boundaries of a window/range; must be comparable (see ) so intervals can be ordered and spanned. /// Provides domain-specific logic to compute spans, boundaries, and interval arithmetic for TRange. @@ -97,7 +97,7 @@ public ProportionalRangePlanner(WindowCacheOptions options, TDomain domain) /// /// See also: /// - /// + /// /// /// public Range Plan(Range requested) @@ -113,4 +113,4 @@ public Range Plan(Range requested) right: (long)Math.Round(right) ); } -} \ No newline at end of file +} diff --git a/tests/SlidingWindowCache.Invariants.Tests/README.md b/tests/SlidingWindowCache.Invariants.Tests/README.md index dc60a92..3d07256 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/README.md +++ b/tests/SlidingWindowCache.Invariants.Tests/README.md @@ -323,12 +323,10 @@ See `docs/storage-strategies.md` for detailed documentation. - `CacheExpanded` and `CacheReplaced` counters are deprecated (User Path no longer mutates) ## Related Documentation -- `docs/invariants.md` - Complete invariant documentation (updated for single-writer architecture) -- `docs/cache-state-machine.md` - State transitions (updated to show only Rebalance Execution mutates) -- `docs/actors-and-responsibilities.md` - Component responsibilities (updated for read-only User Path) -- `docs/concurrency-model.md` - Single-writer architecture and eventual consistency model -- `MIGRATION_SUMMARY.md` - Implementation details of single-writer migration -- `DOCUMENTATION_UPDATES.md` - Documentation changes made for new architecture +- `docs/invariants.md` - Complete invariant documentation +- `docs/state-machine.md` - State transitions and mutation authority +- `docs/actors.md` - Actor responsibilities and component mapping +- `docs/architecture.md` - Concurrency model and single-writer rule ## Test Infrastructure @@ -465,4 +463,4 @@ See `TestHelpers.cs` for complete assertion library including: - `AssertFullCacheHit/PartialCacheHit/FullCacheMiss()` - Verify user scenarios - `AssertDataSourceFetchedFullRange/MissingSegments()` - Verify data source interaction -**See**: [Diagnostics Guide](../../docs/diagnostics.md) for comprehensive diagnostic API reference \ No newline at end of file +**See**: [Diagnostics Guide](../../docs/diagnostics.md) for comprehensive diagnostic API reference From 1edd9c52f526446d37cc6cbdc598bf064293e48a Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Sat, 28 Feb 2026 20:15:00 +0100 Subject: [PATCH 11/21] test: unit tests for cache data extension service and async activity counter have been added; test: rebalance failure handling and disposal scenarios have been validated; test: execution request lifecycle behavior has been verified --- .../BoundaryHandlingTests.cs | 19 ++++ .../CacheDataSourceInteractionTests.cs | 4 +- .../ExecutionStrategySelectionTests.cs | 30 ++++++ .../RebalanceExceptionHandlingTests.cs | 100 ++++++++++++++++++ .../Concurrency/AsyncActivityCounterTests.cs | 91 ++++++++++++++++ .../CacheDataExtensionServiceTests.cs | 57 ++++++++++ .../Concurrency/ExecutionRequestTests.cs | 58 ++++++++++ ...kBasedRebalanceExecutionControllerTests.cs | 70 ++++++++++++ .../Configuration/WindowCacheOptionsTests.cs | 37 ++++++- .../Public/WindowCacheDisposalTests.cs | 23 ++++ 10 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/AsyncActivityCounterTests.cs create mode 100644 tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/CacheDataExtensionServiceTests.cs create mode 100644 tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/ExecutionRequestTests.cs create mode 100644 tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/TaskBasedRebalanceExecutionControllerTests.cs diff --git a/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs index ebc3b01..e1b5654 100644 --- a/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/BoundaryHandlingTests.cs @@ -322,6 +322,25 @@ public async Task RebalancePath_FullHit_WithinBounds_CacheExpandsNormally() Assert.Equal(rightExpanded, rightResult.Range); } + [Fact] + public async Task RebalancePath_CompleteDataMiss_IncrementsDataSegmentUnavailable() + { + // ARRANGE - Configure cache to expand far beyond physical bounds + var cache = CreateCacheWithLeftExpansion(); + _cacheDiagnostics.Reset(); + + // Request at exact lower boundary to create an out-of-bounds missing segment + var initialRequest = Intervals.NET.Factories.Range.Closed(1000, 1010); + + // ACT + await cache.GetDataAsync(initialRequest, CancellationToken.None); + await cache.WaitForIdleAsync(); + + // ASSERT - At least one segment should be reported as unavailable + Assert.True(_cacheDiagnostics.DataSegmentUnavailable >= 1, + "Expected DataSegmentUnavailable to be recorded when rebalance requests out-of-bounds data."); + } + #endregion #region Helper Methods diff --git a/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs b/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs index dbc0622..9bb4748 100644 --- a/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/CacheDataSourceInteractionTests.cs @@ -117,6 +117,8 @@ public async Task CacheMiss_NonOverlappingJump_DataSourceReceivesNewRange() Assert.Equal(510, array[^1]); } + + #endregion #region Partial Cache Hit Scenarios @@ -400,4 +402,4 @@ public async Task EdgeCase_VeryLargeRange_HandlesWithoutError() } #endregion -} \ No newline at end of file +} diff --git a/tests/SlidingWindowCache.Integration.Tests/ExecutionStrategySelectionTests.cs b/tests/SlidingWindowCache.Integration.Tests/ExecutionStrategySelectionTests.cs index c288245..249cf0c 100644 --- a/tests/SlidingWindowCache.Integration.Tests/ExecutionStrategySelectionTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/ExecutionStrategySelectionTests.cs @@ -293,5 +293,35 @@ await Assert.ThrowsAsync(async () => }); } + [Fact] + public async Task ChannelBasedStrategy_DisposalDuringActiveRebalance_CompletesGracefully() + { + // ARRANGE + var dataSource = new SimpleTestDataSource(i => $"Item_{i}", simulateAsyncDelay: true); + var domain = new IntegerFixedStepDomain(); + var options = new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot, + leftThreshold: 0.0, + rightThreshold: 0.0, + debounceDelay: TimeSpan.FromMilliseconds(50), + rebalanceQueueCapacity: 1 + ); + + var cache = new WindowCache( + dataSource, + domain, + options + ); + + // ACT - Trigger a rebalance, then dispose immediately + _ = cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(0, 10), CancellationToken.None); + var exception = await Record.ExceptionAsync(async () => await cache.DisposeAsync()); + + // ASSERT + Assert.Null(exception); + } + #endregion } diff --git a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs index e54bb1d..01f4db4 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs @@ -197,6 +197,106 @@ public async Task ProductionDiagnostics_ProperlyLogsRebalanceFailures() Assert.Contains("Data source is unhealthy", exception.Message); } + [Theory] + [InlineData(null)] + [InlineData(3)] + public async Task RebalanceFailure_IsRecorded_ForBothExecutionStrategies(int? rebalanceQueueCapacity) + { + _diagnostics.Reset(); + + // Arrange: data source fails on second fetch (rebalance) + var callCount = 0; + var faultyDataSource = new FaultyDataSource( + fetchSingleRange: range => + { + callCount++; + if (callCount == 2) + { + throw new InvalidOperationException("Simulated rebalance failure"); + } + + 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), + rebalanceQueueCapacity: rebalanceQueueCapacity + ); + + await using var cache = new WindowCache( + faultyDataSource, + new IntegerFixedStepDomain(), + options, + _diagnostics + ); + + // Act + await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(100, 110), CancellationToken.None); + await cache.WaitForIdleAsync(); + + // Assert + Assert.Equal(1, _diagnostics.RebalanceExecutionFailed); + Assert.Equal(1, _diagnostics.RebalanceExecutionStarted); + Assert.Equal(0, _diagnostics.RebalanceExecutionCompleted); + } + + [Fact] + public async Task IntentProcessingLoop_ContinuesAfterRebalanceFailure() + { + _diagnostics.Reset(); + + // Arrange: fail on second fetch (rebalance), succeed afterwards + var callCount = 0; + var faultyDataSource = new FaultyDataSource( + fetchSingleRange: range => + { + callCount++; + if (callCount == 2) + { + throw new InvalidOperationException("Rebalance failed"); + } + + 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) + ); + + await using var cache = new WindowCache( + faultyDataSource, + new IntegerFixedStepDomain(), + options, + _diagnostics + ); + + // Act: trigger failure then continue with another request + await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(100, 110), CancellationToken.None); + await cache.WaitForIdleAsync(); + + await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(200, 210), CancellationToken.None); + await cache.WaitForIdleAsync(); + + // Assert: intent processing loop stayed alive + Assert.Equal(2, _diagnostics.UserRequestServed); + Assert.True(_diagnostics.RebalanceIntentPublished >= 2, + "Expected intents to continue publishing after a rebalance failure."); + Assert.True(_diagnostics.RebalanceExecutionFailed >= 1, + "Expected at least one rebalance failure to be recorded."); + } + #region Helper Classes ///
diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/AsyncActivityCounterTests.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/AsyncActivityCounterTests.cs new file mode 100644 index 0000000..8776e7a --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/AsyncActivityCounterTests.cs @@ -0,0 +1,91 @@ +using SlidingWindowCache.Infrastructure.Concurrency; + +namespace SlidingWindowCache.Unit.Tests.Infrastructure.Concurrency; + +/// +/// Unit tests for AsyncActivityCounter. +/// Validates underflow protection and idle detection semantics. +/// +public sealed class AsyncActivityCounterTests +{ + [Fact] + public void DecrementActivity_WithoutMatchingIncrement_ThrowsInvalidOperationException() + { + // ARRANGE + var counter = new AsyncActivityCounter(); + + // ACT + var exception = Record.Exception(() => counter.DecrementActivity()); + + // ASSERT + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("decremented below zero", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void DecrementActivity_AfterUnderflow_AllowsSubsequentBalancedActivity() + { + // ARRANGE + var counter = new AsyncActivityCounter(); + + // ACT - Force underflow + _ = Record.Exception(() => counter.DecrementActivity()); + + // ASSERT - Subsequent balanced activity works + var exception = Record.Exception(() => + { + counter.IncrementActivity(); + counter.DecrementActivity(); + }); + + Assert.Null(exception); + } + + [Fact] + public async Task IncrementAndDecrement_Balanced_CompletesSuccessfully() + { + // ARRANGE + var counter = new AsyncActivityCounter(); + + // ACT + counter.IncrementActivity(); + counter.DecrementActivity(); + var exception = await Record.ExceptionAsync(async () => await counter.WaitForIdleAsync()); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public async Task WaitForIdleAsync_CompletesImmediately_WhenNoActivity() + { + // ARRANGE + var counter = new AsyncActivityCounter(); + + // ACT + var exception = await Record.ExceptionAsync(async () => await counter.WaitForIdleAsync()); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public async Task WaitForIdleAsync_CompletesAfterLastDecrement() + { + // ARRANGE + var counter = new AsyncActivityCounter(); + + // ACT + counter.IncrementActivity(); + counter.IncrementActivity(); + var waitTask = counter.WaitForIdleAsync(); + + // ASSERT - Should still be waiting with one active operation + counter.DecrementActivity(); + Assert.False(waitTask.IsCompleted); + + counter.DecrementActivity(); + await waitTask; + } +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/CacheDataExtensionServiceTests.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/CacheDataExtensionServiceTests.cs new file mode 100644 index 0000000..5afffb7 --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/CacheDataExtensionServiceTests.cs @@ -0,0 +1,57 @@ +using Intervals.NET; +using Intervals.NET.Data.Extensions; +using Intervals.NET.Domain.Default.Numeric; +using Moq; +using SlidingWindowCache.Core.Rebalance.Execution; +using SlidingWindowCache.Public; +using SlidingWindowCache.Public.Instrumentation; +using SlidingWindowCache.Public.Dto; +using SlidingWindowCache.Tests.Infrastructure.DataSources; + +namespace SlidingWindowCache.Unit.Tests.Infrastructure.Concurrency; + +/// +/// Unit tests for CacheDataExtensionService. +/// Validates cache replacement diagnostics on non-overlapping requests. +/// +public sealed class CacheDataExtensionServiceTests +{ + [Fact] + public async Task ExtendCacheAsync_NoOverlap_RecordsCacheReplaced() + { + // ARRANGE + var domain = new IntegerFixedStepDomain(); + var diagnostics = new EventCounterCacheDiagnostics(); + + var dataSource = new Mock>(); + dataSource + .Setup(ds => ds.FetchAsync(It.IsAny>>(), It.IsAny())) + .ReturnsAsync((IEnumerable> ranges, CancellationToken _) => + { + var chunks = new List>(); + foreach (var range in ranges) + { + var data = DataGenerationHelpers.GenerateDataForRange(range); + chunks.Add(new RangeChunk(range, data)); + } + + return chunks; + }); + + var service = new CacheDataExtensionService( + dataSource.Object, + domain, + diagnostics + ); + + var currentRange = Intervals.NET.Factories.Range.Closed(0, 10); + var currentData = Enumerable.Range(0, 11).ToArray().ToRangeData(currentRange, domain); + var requestedRange = Intervals.NET.Factories.Range.Closed(1000, 1010); + // ACT + _ = await service.ExtendCacheAsync(currentData, requestedRange, CancellationToken.None); + + // ASSERT + Assert.Equal(1, diagnostics.CacheReplaced); + Assert.Equal(0, diagnostics.CacheExpanded); + } +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/ExecutionRequestTests.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/ExecutionRequestTests.cs new file mode 100644 index 0000000..0d219ca --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/ExecutionRequestTests.cs @@ -0,0 +1,58 @@ +using Intervals.NET.Data.Extensions; +using Intervals.NET.Domain.Default.Numeric; +using SlidingWindowCache.Core.Rebalance.Execution; +using SlidingWindowCache.Core.Rebalance.Intent; +using SlidingWindowCache.Tests.Infrastructure.DataSources; + +namespace SlidingWindowCache.Unit.Tests.Infrastructure.Concurrency; + +/// +/// Unit tests for ExecutionRequest lifecycle behavior. +/// +public sealed class ExecutionRequestTests +{ + [Fact] + public void Cancel_CalledAfterDispose_DoesNotThrow() + { + // ARRANGE + var request = CreateRequest(); + request.Dispose(); + + // ACT + var exception = Record.Exception(() => request.Cancel()); + + // ASSERT + Assert.Null(exception); + } + + [Fact] + public void Dispose_CalledMultipleTimes_DoesNotThrow() + { + // ARRANGE + var request = CreateRequest(); + + // ACT + request.Dispose(); + var exception = Record.Exception(() => request.Dispose()); + + // ASSERT + Assert.Null(exception); + } + + private static ExecutionRequest CreateRequest() + { + var domain = new IntegerFixedStepDomain(); + var range = Intervals.NET.Factories.Range.Closed(0, 10); + var data = DataGenerationHelpers.GenerateDataForRange(range); + var rangeData = data.ToRangeData(range, domain); + var intent = new Intent(range, rangeData); + var cts = new CancellationTokenSource(); + + return new ExecutionRequest( + intent, + range, + desiredNoRebalanceRange: null, + cts + ); + } +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/TaskBasedRebalanceExecutionControllerTests.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/TaskBasedRebalanceExecutionControllerTests.cs new file mode 100644 index 0000000..966c3c0 --- /dev/null +++ b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Concurrency/TaskBasedRebalanceExecutionControllerTests.cs @@ -0,0 +1,70 @@ +using System.Reflection; +using Intervals.NET.Data.Extensions; +using Intervals.NET.Domain.Default.Numeric; +using SlidingWindowCache.Core.Rebalance.Execution; +using SlidingWindowCache.Core.Rebalance.Intent; +using SlidingWindowCache.Core.State; +using SlidingWindowCache.Infrastructure.Concurrency; +using SlidingWindowCache.Infrastructure.Storage; +using SlidingWindowCache.Public.Instrumentation; +using SlidingWindowCache.Tests.Infrastructure.DataSources; + +namespace SlidingWindowCache.Unit.Tests.Infrastructure.Concurrency; + +/// +/// Unit tests for TaskBasedRebalanceExecutionController. +/// Validates chain resilience when previous task is faulted. +/// +public sealed class TaskBasedRebalanceExecutionControllerTests +{ + [Fact] + public async Task PublishExecutionRequest_ContinuesAfterFaultedPreviousTask() + { + // ARRANGE + var domain = new IntegerFixedStepDomain(); + var diagnostics = new EventCounterCacheDiagnostics(); + var storage = new SnapshotReadStorage(domain); + var state = new CacheState(storage, domain); + var dataSource = new SimpleTestDataSource(i => i); + var cacheExtensionService = new CacheDataExtensionService( + dataSource, + domain, + diagnostics + ); + var executor = new RebalanceExecutor( + state, + cacheExtensionService, + diagnostics + ); + var activityCounter = new AsyncActivityCounter(); + + var controller = new TaskBasedRebalanceExecutionController( + executor, + TimeSpan.Zero, + diagnostics, + activityCounter + ); + + var requestedRange = Intervals.NET.Factories.Range.Closed(0, 10); + var data = DataGenerationHelpers.GenerateDataForRange(requestedRange); + var rangeData = data.ToRangeData(requestedRange, domain); + var intent = new Intent(requestedRange, rangeData); + + var currentTaskField = typeof(TaskBasedRebalanceExecutionController) + .GetField("_currentExecutionTask", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(currentTaskField); + + currentTaskField!.SetValue(controller, Task.FromException(new InvalidOperationException("Previous task failed"))); + + // ACT + await controller.PublishExecutionRequest(intent, requestedRange, null, CancellationToken.None); + + var chainedTask = (Task)currentTaskField.GetValue(controller)!; + await chainedTask; + + // ASSERT + Assert.True(diagnostics.RebalanceExecutionFailed >= 1, + "Expected previous task failure to be recorded and current execution to continue."); + Assert.True(diagnostics.RebalanceExecutionStarted >= 1); + } +} diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsTests.cs index da2ea59..50928c6 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Public/Configuration/WindowCacheOptionsTests.cs @@ -760,6 +760,42 @@ public void Constructor_WithVerySmallPositiveValues_IsValid() Assert.Equal(0.0001, options.RightThreshold); } + [Fact] + public void Constructor_WithLeftThresholdAboveOne_ThrowsArgumentOutOfRangeException() + { + // ARRANGE, ACT & ASSERT + var exception = Record.Exception(() => new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot, + leftThreshold: 1.01, + rightThreshold: null + )); + + Assert.NotNull(exception); + Assert.IsType(exception); + var argException = (ArgumentOutOfRangeException)exception; + Assert.Equal("leftThreshold", argException.ParamName); + } + + [Fact] + public void Constructor_WithRightThresholdAboveOne_ThrowsArgumentOutOfRangeException() + { + // ARRANGE, ACT & ASSERT + var exception = Record.Exception(() => new WindowCacheOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + readMode: UserCacheReadMode.Snapshot, + leftThreshold: null, + rightThreshold: 1.01 + )); + + Assert.NotNull(exception); + Assert.IsType(exception); + var argException = (ArgumentOutOfRangeException)exception; + Assert.Equal("rightThreshold", argException.ParamName); + } + #endregion #region Documentation and Usage Scenario Tests @@ -919,4 +955,3 @@ public void Constructor_WithDefaultParameters_RebalanceQueueCapacityIsNull() #endregion } - diff --git a/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs b/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs index 18e8184..276654c 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Public/WindowCacheDisposalTests.cs @@ -149,6 +149,29 @@ public async Task DisposeAsync_CalledConcurrently_HandlesRaceSafely() Assert.All(exceptions, ex => Assert.Null(ex)); } + [Fact] + public async Task DisposeAsync_ConcurrentLoserThread_WaitsForWinnerCompletion() + { + // ARRANGE + var cache = CreateCache(); + var range = Intervals.NET.Factories.Range.Closed(0, 10); + + // Trigger background work so disposal takes some time + _ = await cache.GetDataAsync(range, CancellationToken.None); + + // ACT - Start two concurrent disposals + var firstDispose = cache.DisposeAsync().AsTask(); + var secondDispose = cache.DisposeAsync().AsTask(); + + var exceptions = await Task.WhenAll( + Record.ExceptionAsync(async () => await firstDispose), + Record.ExceptionAsync(async () => await secondDispose) + ); + + // ASSERT - Both dispose calls complete without exception + Assert.All(exceptions, ex => Assert.Null(ex)); + } + #endregion #region Post-Disposal Operation Tests From c37e31389258ffef7668dbdcad8c7082c5054782 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Sat, 28 Feb 2026 20:16:11 +0100 Subject: [PATCH 12/21] style: code formatting has been improved in NoRebalanceRangePlanner and UserRequestHandler --- .../Core/Planning/NoRebalanceRangePlanner.cs | 2 +- .../Core/UserPath/UserRequestHandler.cs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs b/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs index 3e7c3ea..ea1c85a 100644 --- a/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs +++ b/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs @@ -51,7 +51,7 @@ public NoRebalanceRangePlanner(WindowCacheOptions options, TDomain domain) /// - Left threshold shrinks from the left boundary inward /// - Right threshold shrinks from the right boundary inward /// This creates a "stability zone" where requests don't trigger rebalancing. - /// Returns null when the sum of left and right thresholds is >= 1.0, which would completely eliminate the no-rebalance range. + /// Returns null when the sum of left and right thresholds is >= 1.0, which would completely eliminate the no-rebalance range. /// Note: WindowCacheOptions constructor ensures leftThreshold + rightThreshold does not exceed 1.0. /// public Range? Plan(Range cacheRange) diff --git a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs index 86a6f0e..c177da9 100644 --- a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs +++ b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs @@ -134,7 +134,7 @@ public async ValueTask> HandleRequestAsync( var cacheStorage = _state.Storage; var fullyInCache = _state.IsInitialized && cacheStorage.Range.Contains(requestedRange); - var hasOverlap = _state.IsInitialized && !fullyInCache && cacheStorage.Range.Overlaps(requestedRange); + var hasOverlap = _state.IsInitialized && !fullyInCache && cacheStorage.Range.Overlaps(requestedRange); RangeData? assembledData; Range? actualRange; @@ -154,8 +154,8 @@ public async ValueTask> HandleRequestAsync( // Scenario 2: Full Cache Hit // All requested data is available in cache - read directly (no IDataSource call). assembledData = cacheStorage.ToRangeData(); - actualRange = requestedRange; // Fully in cache, so actual == requested - resultData = cacheStorage.Read(requestedRange); + actualRange = requestedRange; // Fully in cache, so actual == requested + resultData = cacheStorage.Read(requestedRange); _cacheDiagnostics.UserRequestFullCacheHit(); } else @@ -186,7 +186,7 @@ public async ValueTask> HandleRequestAsync( { // No actual intersection after extension (defensive fallback). assembledData = null; - resultData = ReadOnlyMemory.Empty; + resultData = ReadOnlyMemory.Empty; } } From 9af8f1f8ed1ee6d44b4bea5587b6e3833b4f8b3e Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Sat, 28 Feb 2026 23:08:41 +0100 Subject: [PATCH 13/21] docs: README file has been updated to include current performance baselines and benchmark reports; refactor: benchmark reports have been reorganized for clarity and consistency; style: formatting improvements made to benchmark sections for better readability --- .../SlidingWindowCache.Benchmarks/README.md | 216 ++++++------------ ...ecutionStrategyBenchmarks-report-github.md | 58 ++--- ...s.RebalanceFlowBenchmarks-report-github.md | 38 +-- ...hmarks.ScenarioBenchmarks-report-github.md | 58 ++--- ...hmarks.UserFlowBenchmarks-report-github.md | 202 ++++++++-------- 5 files changed, 251 insertions(+), 321 deletions(-) diff --git a/benchmarks/SlidingWindowCache.Benchmarks/README.md b/benchmarks/SlidingWindowCache.Benchmarks/README.md index a998238..bdb8d04 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/README.md +++ b/benchmarks/SlidingWindowCache.Benchmarks/README.md @@ -2,7 +2,20 @@ Comprehensive BenchmarkDotNet performance suite for SlidingWindowCache, measuring architectural performance characteristics using **public API only**. -**🎯 Methodologically Correct Benchmarks**: This suite follows rigorous benchmark methodology to ensure deterministic, reliable, and interpretable results. +**Methodologically Correct Benchmarks**: This suite follows rigorous benchmark methodology to ensure deterministic, reliable, and interpretable results. + +--- + +## Current Performance Baselines + +For current measured performance data, see the committed reports in `benchmarks/SlidingWindowCache.Benchmarks/Results/`: + +- **User Request Flow**: [UserFlowBenchmarks-report-github.md](Results/SlidingWindowCache.Benchmarks.Benchmarks.UserFlowBenchmarks-report-github.md) +- **Rebalance Mechanics**: [RebalanceFlowBenchmarks-report-github.md](Results/SlidingWindowCache.Benchmarks.Benchmarks.RebalanceFlowBenchmarks-report-github.md) +- **End-to-End Scenarios**: [ScenarioBenchmarks-report-github.md](Results/SlidingWindowCache.Benchmarks.Benchmarks.ScenarioBenchmarks-report-github.md) +- **Execution Strategy Comparison**: [ExecutionStrategyBenchmarks-report-github.md](Results/SlidingWindowCache.Benchmarks.Benchmarks.ExecutionStrategyBenchmarks-report-github.md) + +These reports are updated when benchmarks are re-run and committed to track performance over time. --- @@ -82,7 +95,6 @@ These benchmarks use a 3-axis orthogonal design to isolate rebalance behavior: - ⚠️ **LOH pressure at large sizes** (RangeSpan ≥ 10,000) - Array allocations go to LOH (no compaction) - GC pressure increases with Gen2 collections visible -- 📊 **Observed**: ~224KB allocation for Fixed/Snapshot at BaseSpanSize=100 vs ~92KB for CopyOnRead **CopyOnRead Mode:** - ❌ **Disadvantage at small sizes** (RangeSpan < 1,000) @@ -91,10 +103,6 @@ These benchmarks use a 3-axis orthogonal design to isolate rebalance behavior: - ✅ **Competitive at medium-to-large sizes** (RangeSpan ≥ 1,000) - List growth amortizes allocation cost - Reduced LOH pressure -- ✅ **Consistent allocation advantage** - - 2-3x lower allocations across most scenarios - - Buffer reuse shows in steady-state operations -- 📊 **Observed**: Allocation differences scale with BaseSpanSize (e.g., ~2.5MB vs ~16MB at BaseSpanSize=10,000) ### Interpretation Guide @@ -103,18 +111,14 @@ When analyzing results, look for: 1. **Allocation patterns**: - Snapshot: Zero on read, large on rebalance - CopyOnRead: Constant on read, incremental on rebalance - - **Actual measurements show 2-3x allocation reduction for CopyOnRead** 2. **Memory usage trends**: - - Watch for Gen2 collections (LOH pressure indicator at BaseSpanSize=10,000) + - Watch for Gen2 collections (LOH pressure indicator at large BaseSpanSize) - Compare total allocated bytes across modes - - CopyOnRead consistently shows lower memory footprint 3. **Execution time patterns**: - - **Rebalance benchmarks cluster around ~1 second baseline** across all parameters - - This isolation reveals pure rebalance cost without I/O variance - - User flow benchmarks show microsecond-level latencies for cache hits - - Cold start scenarios show ~97-98ms for initial population + - Compare rebalance cost across parameters + - Observe user flow latencies for cache hits vs misses 4. **Behavior-driven insights (RebalanceFlowBenchmarks)**: - Fixed span: Predictable, stable costs @@ -152,7 +156,7 @@ When analyzing results, look for: Benchmarks are organized by **execution flow** to clearly separate user-facing costs from background maintenance costs. -### 📱 User Request Flow Benchmarks +### User Request Flow Benchmarks **File**: `UserFlowBenchmarks.cs` @@ -170,35 +174,35 @@ Benchmarks are organized by **execution flow** to clearly separate user-facing c **Benchmark Methods** (grouped by category): -| Category | Method | Purpose | -|----------|--------|---------| -| **FullHit** | `User_FullHit_Snapshot` | Baseline: Full cache hit with Snapshot mode | -| **FullHit** | `User_FullHit_CopyOnRead` | Full cache hit with CopyOnRead mode | -| **PartialHit** | `User_PartialHit_ForwardShift_Snapshot` | Partial hit moving right (Snapshot) | -| **PartialHit** | `User_PartialHit_ForwardShift_CopyOnRead` | Partial hit moving right (CopyOnRead) | -| **PartialHit** | `User_PartialHit_BackwardShift_Snapshot` | Partial hit moving left (Snapshot) | -| **PartialHit** | `User_PartialHit_BackwardShift_CopyOnRead` | Partial hit moving left (CopyOnRead) | -| **FullMiss** | `User_FullMiss_Snapshot` | Full cache miss (Snapshot) | -| **FullMiss** | `User_FullMiss_CopyOnRead` | Full cache miss (CopyOnRead) | +| Category | Method | Purpose | +|----------------|--------------------------------------------|---------------------------------------------| +| **FullHit** | `User_FullHit_Snapshot` | Baseline: Full cache hit with Snapshot mode | +| **FullHit** | `User_FullHit_CopyOnRead` | Full cache hit with CopyOnRead mode | +| **PartialHit** | `User_PartialHit_ForwardShift_Snapshot` | Partial hit moving right (Snapshot) | +| **PartialHit** | `User_PartialHit_ForwardShift_CopyOnRead` | Partial hit moving right (CopyOnRead) | +| **PartialHit** | `User_PartialHit_BackwardShift_Snapshot` | Partial hit moving left (Snapshot) | +| **PartialHit** | `User_PartialHit_BackwardShift_CopyOnRead` | Partial hit moving left (CopyOnRead) | +| **FullMiss** | `User_FullMiss_Snapshot` | Full cache miss (Snapshot) | +| **FullMiss** | `User_FullMiss_CopyOnRead` | Full cache miss (CopyOnRead) | **Expected Results**: -- Full hit: Snapshot ~25-30µs (minimal allocation), CopyOnRead scales with cache size +- Full hit: Snapshot shows minimal allocation, CopyOnRead allocation scales with cache size - Partial hit: Both modes serve request immediately, rebalance deferred to cleanup - Full miss: Request served from data source, rebalance deferred to cleanup - **Scaling**: CopyOnRead allocation grows linearly with `CacheCoefficientSize` --- -### ⚙️ Rebalance Flow Benchmarks +### Rebalance Flow Benchmarks **File**: `RebalanceFlowBenchmarks.cs` **Goal**: Measure rebalance mechanics and storage rematerialization cost through behavior-driven modeling. This suite isolates how storage strategies handle different range span evolution patterns. **Philosophy**: Models system behavior through three orthogonal axes: -- ✔ **Span Behavior** (Fixed/Growing/Shrinking) - How requested range span evolves -- ✔ **Storage Strategy** (Snapshot/CopyOnRead) - Rematerialization approach -- ✔ **Base Span Size** (100/1,000/10,000) - Scaling behavior +- **Span Behavior** (Fixed/Growing/Shrinking) - How requested range span evolves +- **Storage Strategy** (Snapshot/CopyOnRead) - Rematerialization approach +- **Base Span Size** (100/1,000/10,000) - Scaling behavior **Parameters**: `Behavior` × `Strategy` × `BaseSpanSize` = **18 combinations** - Behavior: `[Fixed, Growing, Shrinking]` @@ -214,8 +218,8 @@ Benchmarks are organized by **execution flow** to clearly separate user-facing c **Benchmark Method**: -| Method | Purpose | -|--------|---------| +| Method | Purpose | +|-------------|----------------------------------------------------------------------------------------------| | `Rebalance` | Measures complete rebalance cycle cost for the configured span behavior and storage strategy | **Span Behaviors Explained**: @@ -224,19 +228,17 @@ Benchmarks are organized by **execution flow** to clearly separate user-facing c - **Shrinking**: Span decreases by 100 elements per request (models contracting data requirements) **Expected Results**: -- **Execution time**: Clusters around ~1.05-1.07 seconds across all parameters - - Cumulative rebalance overhead for 10 operations (~50-70ms each) - - Pure rebalance overhead is ~50-70ms cumulative +- **Execution time**: Cumulative rebalance overhead for 10 operations - **Allocation patterns**: - - Fixed/Snapshot: ~224KB (BaseSpanSize=100) → ~16MB (BaseSpanSize=10,000) - - Fixed/CopyOnRead: ~92KB (BaseSpanSize=100) → ~2.5MB (BaseSpanSize=10,000) - - **CopyOnRead shows 2-3x allocation reduction** through buffer reuse -- **GC pressure**: Gen2 collections visible at BaseSpanSize=10,000 for Snapshot mode -- **Behavior impact**: Growing span slightly increases allocation for CopyOnRead (~560KB vs ~92KB at BaseSpanSize=100) + - Fixed/Snapshot: Higher allocations, scales with BaseSpanSize + - Fixed/CopyOnRead: Lower allocations due to buffer reuse + - CopyOnRead shows allocation reduction through buffer reuse +- **GC pressure**: Gen2 collections may be visible at large BaseSpanSize for Snapshot mode +- **Behavior impact**: Growing span may increase allocation for CopyOnRead compared to Fixed --- -### 🌍 Scenario Benchmarks (End-to-End) +### Scenario Benchmarks (End-to-End) **File**: `ScenarioBenchmarks.cs` @@ -253,26 +255,26 @@ Benchmarks are organized by **execution flow** to clearly separate user-facing c **Benchmark Methods** (grouped by category): -| Category | Method | Purpose | -|----------|---------|---------| -| **ColdStart** | `ColdStart_Rebalance_Snapshot` | Baseline: Initial cache population (Snapshot) | -| **ColdStart** | `ColdStart_Rebalance_CopyOnRead` | Initial cache population (CopyOnRead) | +| Category | Method | Purpose | +|---------------|----------------------------------|-----------------------------------------------| +| **ColdStart** | `ColdStart_Rebalance_Snapshot` | Baseline: Initial cache population (Snapshot) | +| **ColdStart** | `ColdStart_Rebalance_CopyOnRead` | Initial cache population (CopyOnRead) | **Expected Results**: -- Cold start: ~97-98ms for initial population (measured end-to-end including rebalance) +- Cold start: Measures complete initialization including rebalance - Allocation patterns differ between modes: - Snapshot: Single upfront array allocation - CopyOnRead: List-based incremental allocation, less memory spike -- **Scaling**: Both modes show similar execution time (~97-150ms) +- **Scaling**: Both modes should show comparable execution times - **Memory differences**: - - Small ranges (RangeSpan=100, CacheCoefficientSize=1): Minimal difference (~7KB vs ~9KB) - - Large ranges (RangeSpan=10,000, CacheCoefficientSize=100): Snapshot ~15.8MB, CopyOnRead ~16.5MB - - CopyOnRead allocation ratio: 1.04-1.72x depending on cache size -- **GC impact**: Gen2 collections visible at largest parameter combination + - Small ranges: Minimal differences between storage modes + - Large ranges: Both modes show substantial allocations, with varying ratios + - CopyOnRead allocation ratio varies depending on cache size +- **GC impact**: Gen2 collections may be visible at largest parameter combinations --- -### 📊 Execution Strategy Benchmarks +### Execution Strategy Benchmarks **File**: `ExecutionStrategyBenchmarks.cs` @@ -297,77 +299,40 @@ Benchmarks are organized by **execution flow** to clearly separate user-facing c **Benchmark Methods**: -| Method | Baseline | Configuration | Implementation | Purpose | -|--------|----------|---------------|----------------|---------| -| `BurstPattern_NoCapacity` | ✓ Yes | `RebalanceQueueCapacity = null` | Task-based unbounded execution | Baseline for ratio calculations | -| `BurstPattern_WithCapacity` | - | `RebalanceQueueCapacity = 10` | Channel-based bounded execution | Measured relative to baseline | +| Method | Baseline | Configuration | Implementation | Purpose | +|-----------------------------|----------|---------------------------------|---------------------------------|---------------------------------| +| `BurstPattern_NoCapacity` | ✓ Yes | `RebalanceQueueCapacity = null` | Task-based unbounded execution | Baseline for ratio calculations | +| `BurstPattern_WithCapacity` | - | `RebalanceQueueCapacity = 10` | Channel-based bounded execution | Measured relative to baseline | -**Expected Results**: +**Interpretation Guide**: **Ratio Column Interpretation**: - **Ratio < 1.0**: WithCapacity is faster than NoCapacity - - Example: Ratio = 0.012 means WithCapacity is **83× faster** (1 / 0.012 ≈ 83) + - Example: Ratio = 0.012 means WithCapacity is 83× faster (1 / 0.012 ≈ 83) - **Ratio > 1.0**: WithCapacity is slower than NoCapacity - - Example: Ratio = 1.44 means WithCapacity is **1.44× slower** (44% overhead) + - Example: Ratio = 1.44 means WithCapacity is 1.44× slower (44% overhead) - **Ratio ≈ 1.0**: Both strategies perform similarly -**Actual Benchmark Results** (Intel Core i7-1065G7, .NET 8.0): - -1. **Low Latency (0ms) - Fast Local Data**: - - **Burst 10**: Ratio = **1.01** (nearly identical, ~100μs both) - - **Burst 100**: Ratio = **1.01** (nearly identical, ~128μs both) - - **Burst 1000**: Ratio = **0.83** (WithCapacity 1.2× faster, 571μs vs 468μs) - - **Interpretation**: Both strategies perform identically for typical bursts; bounded shows slight advantage at extreme burst even with zero latency - -2. **Typical Workload (50ms latency, Network I/O)**: - - **Burst 10**: Ratio = **1.01** (identical, ~385μs both) - - **Burst 100**: Ratio = **0.98** (nearly identical, 404μs vs 393μs) - - **Burst 1000**: Ratio = **0.04** (WithCapacity **25× faster**, 56.5ms vs 698μs) - - **Interpretation**: Both strategies handle moderate bursts identically; dramatic speedup appears at extreme burst - -3. **High Latency (100ms latency, High Network I/O)**: - - **Burst 10**: Ratio = **0.97** (nearly identical, 393μs vs 374μs) - - **Burst 100**: Ratio = **0.59** (WithCapacity **1.7× faster**, 393μs vs 231μs) - - **Burst 1000**: Ratio = **0.38** (WithCapacity **196× faster**, 71.7ms vs 365μs) - - **Interpretation**: Bounded advantage emerges at burst=100; becomes dramatic at burst=1000 - -**Key Findings**: -- **0ms latency**: Both strategies excellent, bounded has 1.2× advantage at burst=1000 -- **50ms latency, burst ≤100**: Nearly identical performance (ratio ~1.0) -- **50ms latency, burst=1000**: Bounded provides **25× speedup** (critical finding) -- **100ms latency, burst=1000**: Bounded provides **196× speedup** (even more dramatic) - -**Memory Allocation**: -- WithCapacity consistently uses **5-9% less memory** (Alloc Ratio: 0.91-0.95) -- Example: 131KB vs 125KB at burst=1000 scenarios -- Memory advantage consistent across all parameter combinations +**What to Look For**: + +1. **Low Latency Scenarios**: Both strategies typically perform similarly at low burst sizes; bounded may show advantages at extreme burst sizes + +2. **High Latency + High Burst**: Bounded strategy's backpressure mechanism should provide significant speedup when both I/O latency and burst size are high + +3. **Memory Allocation**: Compare Alloc Ratio column to assess memory efficiency differences between strategies **When to Use Each Strategy**: -✅ **Unbounded (NoCapacity) - Recommended for 99% of use cases**: +✅ **Unbounded (NoCapacity) - Recommended for typical use cases**: - Web APIs with moderate scrolling (10-100 rapid requests) -- Gaming/real-time with fast local data (0ms latency scenarios) -- Any scenario where burst ≤100 with typical network latency (50-100ms) +- Gaming/real-time with fast local data +- Scenarios where burst sizes remain moderate - Minimal overhead, excellent typical-case performance -- **Validated by benchmarks**: Performs identically to bounded for burst ≤100 ✅ **Bounded (WithCapacity) - High-frequency edge cases**: -- Streaming sensor data at 1000+ Hz with network I/O (50-100ms latency) -- Any scenario with 1000+ rapid requests and significant I/O latency +- Streaming sensor data at very high frequencies (1000+ Hz) with network I/O +- Scenarios with extreme burst sizes and significant I/O latency - When predictable bounded behavior is critical -- **Validated by benchmarks**: 25-196× faster under extreme burst (1000 requests with latency) -- Memory advantage: 5-9% less allocation across all scenarios - -**Critical Insight**: -The bounded strategy's advantage only appears under **extreme conditions** (burst ≥1000 with I/O latency). For typical workloads (burst ≤100), both strategies perform identically (ratio ~1.0), making unbounded the safer default choice with zero performance penalty. - -**Interpretation Guide**: - -Both strategies are production-ready with different trade-offs: -- **Unbounded**: Identical performance for typical workloads (burst ≤100), excellent general-purpose choice (default) -- **Bounded**: Prevents accumulation under extreme burst, provides 25-196× speedup at burst=1000 with latency - -The negligible differences in typical scenarios (burst ≤100, ratio ~1.0) prove both are well-optimized. The dramatic 25-196× speedup for bounded strategy at burst=1000 with I/O latency validates the backpressure design for high-frequency edge cases. --- @@ -465,41 +430,6 @@ var dataSource = new SynchronousDataSource(domain); --- -## Running Benchmarks - -### Run All Benchmarks -```bash -cd benchmarks/SlidingWindowCache.Benchmarks -dotnet run -c Release -``` - -### Run Specific Benchmark Class -```bash -# User request flow benchmarks -dotnet run -c Release -- --filter *UserFlowBenchmarks* - -# Rebalance/maintenance flow benchmarks -dotnet run -c Release -- --filter *RebalanceFlowBenchmarks* - -# Scenario benchmarks (cold start + locality) -dotnet run -c Release -- --filter *ScenarioBenchmarks* -``` - -### Run Specific Method -```bash -# User flow examples -dotnet run -c Release -- --filter *User_FullHit* -dotnet run -c Release -- --filter *User_PartialHit* - -# Rebalance flow examples -dotnet run -c Release -- --filter *Rebalance* - -# Scenario examples -dotnet run -c Release -- --filter *ColdStart_Rebalance* -``` - ---- - ## Interpreting Results ### Mean Execution Time @@ -583,7 +513,7 @@ benchmarks/SlidingWindowCache.Benchmarks/Results/ ├── SlidingWindowCache.Benchmarks.Benchmarks.UserFlowBenchmarks-report-github.md ├── SlidingWindowCache.Benchmarks.Benchmarks.RebalanceFlowBenchmarks-report-github.md ├── SlidingWindowCache.Benchmarks.Benchmarks.ScenarioBenchmarks-report-github.md -└── SlidingWindowCache.Benchmarks.Benchmarks.ExecutionStrategyBenchmarks-report-default.md +└── SlidingWindowCache.Benchmarks.Benchmarks.ExecutionStrategyBenchmarks-report-github.md ``` These markdown reports are checked into version control for: diff --git a/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.ExecutionStrategyBenchmarks-report-github.md b/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.ExecutionStrategyBenchmarks-report-github.md index 8278ddf..09ffc82 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.ExecutionStrategyBenchmarks-report-github.md +++ b/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.ExecutionStrategyBenchmarks-report-github.md @@ -4,36 +4,36 @@ BenchmarkDotNet v0.13.12, Windows 10 (10.0.19045.6456/22H2/2022Update) Intel Core i7-1065G7 CPU 1.30GHz, 1 CPU, 8 logical and 4 physical cores .NET SDK 8.0.418 [Host] : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI - Job-LLMARF : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + Job-BUXWGJ : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI InvocationCount=1 UnrollFactor=1 ``` -| Method | DataSourceLatencyMs | BurstSize | Mean | Error | StdDev | Median | Ratio | RatioSD | Allocated | Alloc Ratio | -|-------------------------- |-------------------- |---------- |-------------:|--------------:|-------------:|-------------:|------:|--------:|----------:|------------:| -| **BurstPattern_NoCapacity** | **0** | **10** | **100.22 μs** | **4.127 μs** | **11.30 μs** | **98.50 μs** | **1.00** | **0.00** | **5.88 KB** | **1.00** | -| BurstPattern_WithCapacity | 0 | 10 | 99.84 μs | 4.754 μs | 13.33 μs | 97.40 μs | 1.01 | 0.19 | 5.33 KB | 0.91 | -| | | | | | | | | | | | -| **BurstPattern_NoCapacity** | **0** | **100** | **128.00 μs** | **5.495 μs** | **15.59 μs** | **128.70 μs** | **1.00** | **0.00** | **19.82 KB** | **1.00** | -| BurstPattern_WithCapacity | 0 | 100 | 127.54 μs | 5.683 μs | 15.84 μs | 124.05 μs | 1.01 | 0.17 | 17.08 KB | 0.86 | -| | | | | | | | | | | | -| **BurstPattern_NoCapacity** | **0** | **1000** | **570.83 μs** | **11.332 μs** | **14.33 μs** | **570.70 μs** | **1.00** | **0.00** | **150.82 KB** | **1.00** | -| BurstPattern_WithCapacity | 0 | 1000 | 468.44 μs | 8.006 μs | 18.23 μs | 462.20 μs | 0.83 | 0.03 | 138.79 KB | 0.92 | -| | | | | | | | | | | | -| **BurstPattern_NoCapacity** | **50** | **10** | **385.08 μs** | **13.206 μs** | **38.10 μs** | **378.05 μs** | **1.00** | **0.00** | **5.38 KB** | **1.00** | -| BurstPattern_WithCapacity | 50 | 10 | 388.16 μs | 16.525 μs | 47.94 μs | 374.40 μs | 1.01 | 0.12 | 5.03 KB | 0.94 | -| | | | | | | | | | | | -| **BurstPattern_NoCapacity** | **50** | **100** | **403.71 μs** | **16.306 μs** | **47.57 μs** | **398.35 μs** | **1.00** | **0.00** | **15.92 KB** | **1.00** | -| BurstPattern_WithCapacity | 50 | 100 | 392.98 μs | 14.527 μs | 41.45 μs | 378.50 μs | 0.98 | 0.15 | 15.58 KB | 0.98 | -| | | | | | | | | | | | -| **BurstPattern_NoCapacity** | **50** | **1000** | **56,491.63 μs** | **3,906.851 μs** | **11,458.12 μs** | **60,914.60 μs** | **1.00** | **0.00** | **131.3 KB** | **1.00** | -| BurstPattern_WithCapacity | 50 | 1000 | 697.98 μs | 20.980 μs | 58.83 μs | 700.70 μs | 0.04 | 0.23 | 125.23 KB | 0.95 | -| | | | | | | | | | | | -| **BurstPattern_NoCapacity** | **100** | **10** | **392.57 μs** | **18.054 μs** | **52.38 μs** | **389.00 μs** | **1.00** | **0.00** | **5.38 KB** | **1.00** | -| BurstPattern_WithCapacity | 100 | 10 | 373.85 μs | 20.679 μs | 58.33 μs | 375.20 μs | 0.97 | 0.23 | 5.03 KB | 0.94 | -| | | | | | | | | | | | -| **BurstPattern_NoCapacity** | **100** | **100** | **392.97 μs** | **13.676 μs** | **38.35 μs** | **387.10 μs** | **1.00** | **0.00** | **15.92 KB** | **1.00** | -| BurstPattern_WithCapacity | 100 | 100 | 231.07 μs | 26.441 μs | 75.01 μs | 227.90 μs | 0.59 | 0.19 | 15.58 KB | 0.98 | -| | | | | | | | | | | | -| **BurstPattern_NoCapacity** | **100** | **1000** | **71,687.43 μs** | **17,446.785 μs** | **45,035.72 μs** | **99,525.60 μs** | **1.00** | **0.00** | **131.3 KB** | **1.00** | -| BurstPattern_WithCapacity | 100 | 1000 | 365.33 μs | 34.984 μs | 98.10 μs | 356.55 μs | 0.38 | 0.63 | 125.23 KB | 0.95 | +| Method | DataSourceLatencyMs | BurstSize | Mean | Error | StdDev | Median | Ratio | RatioSD | Allocated | Alloc Ratio | +|-----------------------------|---------------------|-----------|-----------------:|-----------------:|------------------:|-----------------:|---------:|---------:|--------------:|------------:| +| **BurstPattern_NoCapacity** | **0** | **10** | **110.66 μs** | **8.838 μs** | **25.779 μs** | **101.20 μs** | **1.00** | **0.00** | **6.88 KB** | **1.00** | +| BurstPattern_WithCapacity | 0 | 10 | 92.11 μs | 4.798 μs | 13.454 μs | 90.55 μs | 0.87 | 0.22 | 5.87 KB | 0.85 | +| | | | | | | | | | | | +| **BurstPattern_NoCapacity** | **0** | **100** | **119.55 μs** | **3.891 μs** | **10.848 μs** | **116.90 μs** | **1.00** | **0.00** | **25.28 KB** | **1.00** | +| BurstPattern_WithCapacity | 0 | 100 | 120.09 μs | 5.805 μs | 16.183 μs | 117.95 μs | 1.01 | 0.15 | 22.21 KB | 0.88 | +| | | | | | | | | | | | +| **BurstPattern_NoCapacity** | **0** | **1000** | **541.54 μs** | **11.752 μs** | **33.718 μs** | **545.20 μs** | **1.00** | **0.00** | **215.98 KB** | **1.00** | +| BurstPattern_WithCapacity | 0 | 1000 | 472.58 μs | 6.419 μs | 7.883 μs | 473.85 μs | 0.83 | 0.04 | 207.2 KB | 0.96 | +| | | | | | | | | | | | +| **BurstPattern_NoCapacity** | **50** | **10** | **388.69 μs** | **14.468 μs** | **41.744 μs** | **385.00 μs** | **1.00** | **0.00** | **5.91 KB** | **1.00** | +| BurstPattern_WithCapacity | 50 | 10 | 381.58 μs | 18.261 μs | 53.269 μs | 376.00 μs | 1.00 | 0.19 | 5.57 KB | 0.94 | +| | | | | | | | | | | | +| **BurstPattern_NoCapacity** | **50** | **100** | **394.14 μs** | **11.432 μs** | **32.985 μs** | **391.60 μs** | **1.00** | **0.00** | **21.38 KB** | **1.00** | +| BurstPattern_WithCapacity | 50 | 100 | 395.46 μs | 15.657 μs | 45.175 μs | 386.30 μs | 1.01 | 0.12 | 21.04 KB | 0.98 | +| | | | | | | | | | | | +| **BurstPattern_NoCapacity** | **50** | **1000** | **57,077.47 μs** | **3,928.179 μs** | **11,582.325 μs** | **60,679.55 μs** | **1.00** | **0.00** | **185.98 KB** | **1.00** | +| BurstPattern_WithCapacity | 50 | 1000 | 679.93 μs | 31.206 μs | 87.506 μs | 685.30 μs | 0.04 | 0.15 | 179.58 KB | 0.97 | +| | | | | | | | | | | | +| **BurstPattern_NoCapacity** | **100** | **10** | **378.76 μs** | **16.735 μs** | **47.745 μs** | **377.30 μs** | **1.00** | **0.00** | **5.91 KB** | **1.00** | +| BurstPattern_WithCapacity | 100 | 10 | 389.30 μs | 13.483 μs | 39.542 μs | 381.10 μs | 1.05 | 0.26 | 5.57 KB | 0.94 | +| | | | | | | | | | | | +| **BurstPattern_NoCapacity** | **100** | **100** | **393.76 μs** | **14.259 μs** | **40.910 μs** | **389.10 μs** | **1.00** | **0.00** | **21.38 KB** | **1.00** | +| BurstPattern_WithCapacity | 100 | 100 | 381.96 μs | 20.067 μs | 58.537 μs | 381.80 μs | 0.99 | 0.22 | 21.04 KB | 0.98 | +| | | | | | | | | | | | +| **BurstPattern_NoCapacity** | **100** | **1000** | **92,654.92 μs** | **8,661.615 μs** | **23,268.866 μs** | **98,367.65 μs** | **1.00** | **0.00** | **185.98 KB** | **1.00** | +| BurstPattern_WithCapacity | 100 | 1000 | 703.49 μs | 21.367 μs | 61.306 μs | 700.90 μs | 0.08 | 0.29 | 179.91 KB | 0.97 | diff --git a/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.RebalanceFlowBenchmarks-report-github.md b/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.RebalanceFlowBenchmarks-report-github.md index ef4f9af..c170d38 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.RebalanceFlowBenchmarks-report-github.md +++ b/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.RebalanceFlowBenchmarks-report-github.md @@ -4,28 +4,28 @@ BenchmarkDotNet v0.13.12, Windows 10 (10.0.19045.6456/22H2/2022Update) Intel Core i7-1065G7 CPU 1.30GHz, 1 CPU, 8 logical and 4 physical cores .NET SDK 8.0.418 [Host] : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI - Job-RLYSTP : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + Job-BUXWGJ : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI InvocationCount=1 UnrollFactor=1 ``` | Method | Behavior | Strategy | BaseSpanSize | Mean | Error | StdDev | Gen0 | Gen1 | Gen2 | Allocated | |---------------|---------------|----------------|--------------|-------------:|------------:|------------:|--------------:|--------------:|--------------:|----------------:| -| **Rebalance** | **Fixed** | **Snapshot** | **100** | **166.3 ms** | **3.11 ms** | **3.05 ms** | **-** | **-** | **-** | **198.18 KB** | -| **Rebalance** | **Fixed** | **Snapshot** | **1000** | **165.7 ms** | **3.16 ms** | **3.25 ms** | **-** | **-** | **-** | **1676.93 KB** | -| **Rebalance** | **Fixed** | **Snapshot** | **10000** | **163.8 ms** | **3.24 ms** | **3.60 ms** | **3000.0000** | **3000.0000** | **3000.0000** | **16445.02 KB** | -| **Rebalance** | **Fixed** | **CopyOnRead** | **100** | **166.4 ms** | **3.23 ms** | **3.72 ms** | **-** | **-** | **-** | **66.12 KB** | -| **Rebalance** | **Fixed** | **CopyOnRead** | **1000** | **166.4 ms** | **3.25 ms** | **3.48 ms** | **-** | **-** | **-** | **325.63 KB** | -| **Rebalance** | **Fixed** | **CopyOnRead** | **10000** | **162.6 ms** | **3.19 ms** | **3.54 ms** | **-** | **-** | **-** | **2469.26 KB** | -| **Rebalance** | **Growing** | **Snapshot** | **100** | **166.9 ms** | **3.30 ms** | **3.80 ms** | **-** | **-** | **-** | **940.55 KB** | -| **Rebalance** | **Growing** | **Snapshot** | **1000** | **167.4 ms** | **3.28 ms** | **4.27 ms** | **-** | **-** | **-** | **2417.61 KB** | -| **Rebalance** | **Growing** | **Snapshot** | **10000** | **164.9 ms** | **3.26 ms** | **4.77 ms** | **3000.0000** | **3000.0000** | **3000.0000** | **17185.6 KB** | -| **Rebalance** | **Growing** | **CopyOnRead** | **100** | **166.3 ms** | **3.21 ms** | **3.44 ms** | **-** | **-** | **-** | **534.23 KB** | -| **Rebalance** | **Growing** | **CopyOnRead** | **1000** | **166.5 ms** | **3.25 ms** | **3.04 ms** | **-** | **-** | **-** | **857.36 KB** | -| **Rebalance** | **Growing** | **CopyOnRead** | **10000** | **165.4 ms** | **3.27 ms** | **4.37 ms** | **-** | **-** | **-** | **2488.95 KB** | -| **Rebalance** | **Shrinking** | **Snapshot** | **100** | **166.0 ms** | **3.03 ms** | **3.11 ms** | **-** | **-** | **-** | **661.5 KB** | -| **Rebalance** | **Shrinking** | **Snapshot** | **1000** | **165.7 ms** | **3.25 ms** | **4.45 ms** | **-** | **-** | **-** | **1463.66 KB** | -| **Rebalance** | **Shrinking** | **Snapshot** | **10000** | **163.2 ms** | **3.14 ms** | **4.19 ms** | **1000.0000** | **1000.0000** | **1000.0000** | **9585.38 KB** | -| **Rebalance** | **Shrinking** | **CopyOnRead** | **100** | **166.0 ms** | **3.25 ms** | **3.47 ms** | **-** | **-** | **-** | **397.81 KB** | -| **Rebalance** | **Shrinking** | **CopyOnRead** | **1000** | **166.0 ms** | **3.19 ms** | **3.13 ms** | **-** | **-** | **-** | **856.37 KB** | -| **Rebalance** | **Shrinking** | **CopyOnRead** | **10000** | **162.2 ms** | **3.01 ms** | **2.82 ms** | **-** | **-** | **-** | **2487.95 KB** | +| **Rebalance** | **Fixed** | **Snapshot** | **100** | **166.2 ms** | **3.17 ms** | **2.96 ms** | **-** | **-** | **-** | **199.03 KB** | +| **Rebalance** | **Fixed** | **Snapshot** | **1000** | **164.6 ms** | **3.16 ms** | **3.64 ms** | **-** | **-** | **-** | **1677.78 KB** | +| **Rebalance** | **Fixed** | **Snapshot** | **10000** | **162.3 ms** | **2.77 ms** | **3.88 ms** | **3000.0000** | **3000.0000** | **3000.0000** | **16445.87 KB** | +| **Rebalance** | **Fixed** | **CopyOnRead** | **100** | **165.9 ms** | **3.24 ms** | **3.98 ms** | **-** | **-** | **-** | **67.25 KB** | +| **Rebalance** | **Fixed** | **CopyOnRead** | **1000** | **166.0 ms** | **3.13 ms** | **4.39 ms** | **-** | **-** | **-** | **326.48 KB** | +| **Rebalance** | **Fixed** | **CopyOnRead** | **10000** | **162.9 ms** | **2.76 ms** | **3.28 ms** | **-** | **-** | **-** | **2470.11 KB** | +| **Rebalance** | **Growing** | **Snapshot** | **100** | **166.2 ms** | **3.01 ms** | **3.09 ms** | **-** | **-** | **-** | **1162.11 KB** | +| **Rebalance** | **Growing** | **Snapshot** | **1000** | **165.6 ms** | **3.31 ms** | **3.10 ms** | **-** | **-** | **-** | **2639.17 KB** | +| **Rebalance** | **Growing** | **Snapshot** | **10000** | **159.7 ms** | **2.82 ms** | **3.25 ms** | **4000.0000** | **4000.0000** | **4000.0000** | **17407.75 KB** | +| **Rebalance** | **Growing** | **CopyOnRead** | **100** | **166.7 ms** | **3.31 ms** | **3.10 ms** | **-** | **-** | **-** | **755.79 KB** | +| **Rebalance** | **Growing** | **CopyOnRead** | **1000** | **166.1 ms** | **3.20 ms** | **3.28 ms** | **-** | **-** | **-** | **1078.92 KB** | +| **Rebalance** | **Growing** | **CopyOnRead** | **10000** | **164.3 ms** | **3.13 ms** | **4.28 ms** | **-** | **-** | **-** | **2710.51 KB** | +| **Rebalance** | **Shrinking** | **Snapshot** | **100** | **166.5 ms** | **3.21 ms** | **4.06 ms** | **-** | **-** | **-** | **918.7 KB** | +| **Rebalance** | **Shrinking** | **Snapshot** | **1000** | **164.8 ms** | **3.25 ms** | **3.61 ms** | **-** | **-** | **-** | **1720.91 KB** | +| **Rebalance** | **Shrinking** | **Snapshot** | **10000** | **162.4 ms** | **3.07 ms** | **4.40 ms** | **2000.0000** | **2000.0000** | **2000.0000** | **9843.23 KB** | +| **Rebalance** | **Shrinking** | **CopyOnRead** | **100** | **165.3 ms** | **3.30 ms** | **3.24 ms** | **-** | **-** | **-** | **654.09 KB** | +| **Rebalance** | **Shrinking** | **CopyOnRead** | **1000** | **164.6 ms** | **3.16 ms** | **3.51 ms** | **-** | **-** | **-** | **1113.63 KB** | +| **Rebalance** | **Shrinking** | **CopyOnRead** | **10000** | **161.4 ms** | **3.13 ms** | **4.78 ms** | **-** | **-** | **-** | **2745.21 KB** | diff --git a/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.ScenarioBenchmarks-report-github.md b/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.ScenarioBenchmarks-report-github.md index f8e344b..07a92b8 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.ScenarioBenchmarks-report-github.md +++ b/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.ScenarioBenchmarks-report-github.md @@ -4,36 +4,36 @@ BenchmarkDotNet v0.13.12, Windows 10 (10.0.19045.6456/22H2/2022Update) Intel Core i7-1065G7 CPU 1.30GHz, 1 CPU, 8 logical and 4 physical cores .NET SDK 8.0.418 [Host] : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI - Job-PMDJXO : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + Job-BUXWGJ : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI InvocationCount=1 UnrollFactor=1 ``` -| Method | RangeSpan | CacheCoefficientSize | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio | -|----------------------------------|-----------|----------------------|--------------:|-------------:|-------------:|--------------:|---------:|---------:|--------------:|--------------:|--------------:|----------------:|------------:| -| **ColdStart_Rebalance_Snapshot** | **100** | **1** | **97.38 ms** | **0.941 ms** | **0.880 ms** | **97.63 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **7.45 KB** | **1.00** | -| ColdStart_Rebalance_CopyOnRead | 100 | 1 | 98.23 ms | 1.029 ms | 1.602 ms | 97.82 ms | 1.01 | 0.03 | - | - | - | 8.91 KB | 1.20 | -| | | | | | | | | | | | | | | -| **ColdStart_Rebalance_Snapshot** | **100** | **10** | **97.64 ms** | **1.439 ms** | **1.202 ms** | **97.90 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **21.58 KB** | **1.00** | -| ColdStart_Rebalance_CopyOnRead | 100 | 10 | 97.61 ms | 1.251 ms | 1.045 ms | 97.85 ms | 1.00 | 0.00 | - | - | - | 36.98 KB | 1.71 | -| | | | | | | | | | | | | | | -| **ColdStart_Rebalance_Snapshot** | **100** | **100** | **98.92 ms** | **1.880 ms** | **2.927 ms** | **98.04 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **162.42 KB** | **1.00** | -| ColdStart_Rebalance_CopyOnRead | 100 | 100 | 97.52 ms | 1.566 ms | 1.223 ms | 97.89 ms | 0.98 | 0.04 | - | - | - | 261.05 KB | 1.61 | -| | | | | | | | | | | | | | | -| **ColdStart_Rebalance_Snapshot** | **1000** | **1** | **97.64 ms** | **1.474 ms** | **1.151 ms** | **97.97 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **35.78 KB** | **1.00** | -| ColdStart_Rebalance_CopyOnRead | 1000 | 1 | 97.56 ms | 1.442 ms | 1.205 ms | 97.78 ms | 1.00 | 0.00 | - | - | - | 44.15 KB | 1.23 | -| | | | | | | | | | | | | | | -| **ColdStart_Rebalance_Snapshot** | **1000** | **10** | **97.58 ms** | **0.701 ms** | **0.656 ms** | **97.72 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **176.63 KB** | **1.00** | -| ColdStart_Rebalance_CopyOnRead | 1000 | 10 | 99.26 ms | 1.914 ms | 3.037 ms | 97.93 ms | 1.02 | 0.04 | - | - | - | 268.22 KB | 1.52 | -| | | | | | | | | | | | | | | -| **ColdStart_Rebalance_Snapshot** | **1000** | **100** | **97.54 ms** | **1.023 ms** | **0.957 ms** | **97.72 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **1582.95 KB** | **1.00** | -| ColdStart_Rebalance_CopyOnRead | 1000 | 100 | 97.80 ms | 0.992 ms | 0.829 ms | 97.66 ms | 1.00 | 0.01 | - | - | - | 2060.29 KB | 1.30 | -| | | | | | | | | | | | | | | -| **ColdStart_Rebalance_Snapshot** | **10000** | **1** | **97.66 ms** | **1.055 ms** | **1.036 ms** | **97.90 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **342.34 KB** | **1.00** | -| ColdStart_Rebalance_CopyOnRead | 10000 | 1 | 97.68 ms | 1.260 ms | 1.052 ms | 98.07 ms | 1.00 | 0.01 | - | - | - | 363.62 KB | 1.06 | -| | | | | | | | | | | | | | | -| **ColdStart_Rebalance_Snapshot** | **10000** | **10** | **97.04 ms** | **1.077 ms** | **0.955 ms** | **97.43 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **1748.66 KB** | **1.00** | -| ColdStart_Rebalance_CopyOnRead | 10000 | 10 | 104.98 ms | 3.496 ms | 10.254 ms | 98.18 ms | 1.05 | 0.08 | - | - | - | 2155.69 KB | 1.23 | -| | | | | | | | | | | | | | | -| **ColdStart_Rebalance_Snapshot** | **10000** | **100** | **131.36 ms** | **2.675 ms** | **7.631 ms** | **129.97 ms** | **1.00** | **0.00** | **1000.0000** | **1000.0000** | **1000.0000** | **15812.11 KB** | **1.00** | -| ColdStart_Rebalance_CopyOnRead | 10000 | 100 | 156.91 ms | 8.491 ms | 25.036 ms | 146.14 ms | 1.21 | 0.21 | 2000.0000 | 2000.0000 | 2000.0000 | 16493.28 KB | 1.04 | +| Method | RangeSpan | CacheCoefficientSize | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio | +|----------------------------------|-----------|----------------------|--------------:|-------------:|--------------:|--------------:|---------:|---------:|--------------:|--------------:|--------------:|----------------:|------------:| +| **ColdStart_Rebalance_Snapshot** | **100** | **1** | **97.54 ms** | **1.131 ms** | **1.058 ms** | **97.81 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **10.33 KB** | **1.00** | +| ColdStart_Rebalance_CopyOnRead | 100 | 1 | 98.34 ms | 1.852 ms | 1.546 ms | 97.80 ms | 1.01 | 0.02 | - | - | - | 11.79 KB | 1.14 | +| | | | | | | | | | | | | | | +| **ColdStart_Rebalance_Snapshot** | **100** | **10** | **97.67 ms** | **1.244 ms** | **1.103 ms** | **98.00 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **38.6 KB** | **1.00** | +| ColdStart_Rebalance_CopyOnRead | 100 | 10 | 97.65 ms | 1.415 ms | 1.182 ms | 98.07 ms | 1.00 | 0.01 | - | - | - | 54 KB | 1.40 | +| | | | | | | | | | | | | | | +| **ColdStart_Rebalance_Snapshot** | **100** | **100** | **99.24 ms** | **1.960 ms** | **3.275 ms** | **98.01 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **419.63 KB** | **1.00** | +| ColdStart_Rebalance_CopyOnRead | 100 | 100 | 97.53 ms | 1.019 ms | 0.953 ms | 97.81 ms | 0.99 | 0.04 | - | - | - | 518.26 KB | 1.24 | +| | | | | | | | | | | | | | | +| **ColdStart_Rebalance_Snapshot** | **1000** | **1** | **97.69 ms** | **1.509 ms** | **1.260 ms** | **97.95 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **56.22 KB** | **1.00** | +| ColdStart_Rebalance_CopyOnRead | 1000 | 1 | 97.44 ms | 1.113 ms | 1.041 ms | 97.73 ms | 1.00 | 0.01 | - | - | - | 64.59 KB | 1.15 | +| | | | | | | | | | | | | | | +| **ColdStart_Rebalance_Snapshot** | **1000** | **10** | **97.30 ms** | **1.582 ms** | **1.235 ms** | **97.66 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **437.25 KB** | **1.00** | +| ColdStart_Rebalance_CopyOnRead | 1000 | 10 | 97.01 ms | 1.634 ms | 1.276 ms | 97.46 ms | 1.00 | 0.01 | - | - | - | 528.84 KB | 1.21 | +| | | | | | | | | | | | | | | +| **ColdStart_Rebalance_Snapshot** | **1000** | **100** | **101.54 ms** | **2.351 ms** | **6.821 ms** | **97.88 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **3635.71 KB** | **1.00** | +| ColdStart_Rebalance_CopyOnRead | 1000 | 100 | 106.59 ms | 3.575 ms | 10.541 ms | 103.07 ms | 1.05 | 0.12 | - | - | - | 4113.05 KB | 1.13 | +| | | | | | | | | | | | | | | +| **ColdStart_Rebalance_Snapshot** | **10000** | **1** | **97.45 ms** | **1.472 ms** | **1.149 ms** | **97.71 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **662.81 KB** | **1.00** | +| ColdStart_Rebalance_CopyOnRead | 10000 | 1 | 97.51 ms | 1.433 ms | 1.119 ms | 97.71 ms | 1.00 | 0.01 | - | - | - | 684.09 KB | 1.03 | +| | | | | | | | | | | | | | | +| **ColdStart_Rebalance_Snapshot** | **10000** | **10** | **98.81 ms** | **1.561 ms** | **3.555 ms** | **97.58 ms** | **1.00** | **0.00** | **-** | **-** | **-** | **3861.27 KB** | **1.00** | +| ColdStart_Rebalance_CopyOnRead | 10000 | 10 | 108.51 ms | 3.602 ms | 10.564 ms | 111.51 ms | 1.15 | 0.11 | - | - | - | 4268.3 KB | 1.11 | +| | | | | | | | | | | | | | | +| **ColdStart_Rebalance_Snapshot** | **10000** | **100** | **151.06 ms** | **3.972 ms** | **11.267 ms** | **151.08 ms** | **1.00** | **0.00** | **3000.0000** | **3000.0000** | **3000.0000** | **32262.02 KB** | **1.00** | +| ColdStart_Rebalance_CopyOnRead | 10000 | 100 | 167.92 ms | 8.161 ms | 24.062 ms | 160.41 ms | 1.13 | 0.17 | 3000.0000 | 3000.0000 | 3000.0000 | 32942.27 KB | 1.02 | diff --git a/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.UserFlowBenchmarks-report-github.md b/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.UserFlowBenchmarks-report-github.md index d70b688..be72892 100644 --- a/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.UserFlowBenchmarks-report-github.md +++ b/benchmarks/SlidingWindowCache.Benchmarks/Results/SlidingWindowCache.Benchmarks.Benchmarks.UserFlowBenchmarks-report-github.md @@ -4,108 +4,108 @@ BenchmarkDotNet v0.13.12, Windows 10 (10.0.19045.6456/22H2/2022Update) Intel Core i7-1065G7 CPU 1.30GHz, 1 CPU, 8 logical and 4 physical cores .NET SDK 8.0.418 [Host] : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI - Job-PMDJXO : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI + Job-BUXWGJ : .NET 8.0.24 (8.0.2426.7010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI InvocationCount=1 UnrollFactor=1 ``` -| Method | RangeSpan | CacheCoefficientSize | Mean | Error | StdDev | Median | Ratio | RatioSD | Allocated | Alloc Ratio | -|-------------------------------------------|-----------|----------------------|--------------------:|-----------------:|-----------------:|-----------------:|---------:|---------:|----------------:|------------:| -| **User_FullHit_Snapshot** | **100** | **1** | **31.26 μs** | **3.280 μs** | **9.411 μs** | **29.10 μs** | **1.00** | **0.00** | **1.32 KB** | **1.00** | -| User_FullHit_CopyOnRead | 100 | 1 | 34.46 μs | 3.526 μs | 10.173 μs | 30.80 μs | 1.12 | 0.22 | 2.06 KB | 1.56 | -| | | | | | | | | | | | -| **User_FullHit_Snapshot** | **100** | **10** | **26.02 μs** | **3.172 μs** | **8.946 μs** | **24.10 μs** | **1.00** | **0.00** | **1.32 KB** | **1.00** | -| User_FullHit_CopyOnRead | 100 | 10 | 45.92 μs | 7.613 μs | 22.085 μs | 30.15 μs | 1.98 | 1.16 | 6.32 KB | 4.79 | -| | | | | | | | | | | | -| **User_FullHit_Snapshot** | **100** | **100** | **26.10 μs** | **2.118 μs** | **5.975 μs** | **26.40 μs** | **1.00** | **0.00** | **1.32 KB** | **1.00** | -| User_FullHit_CopyOnRead | 100 | 100 | 70.55 μs | 7.519 μs | 22.053 μs | 78.00 μs | 2.75 | 0.60 | 48.93 KB | 37.06 | -| | | | | | | | | | | | -| **User_FullHit_Snapshot** | **1000** | **1** | **28.11 μs** | **3.000 μs** | **8.313 μs** | **26.00 μs** | **1.00** | **0.00** | **1.32 KB** | **1.00** | -| User_FullHit_CopyOnRead | 1000 | 1 | 51.49 μs | 8.242 μs | 23.912 μs | 57.60 μs | 1.96 | 0.80 | 8.39 KB | 6.36 | -| | | | | | | | | | | | -| **User_FullHit_Snapshot** | **1000** | **10** | **26.66 μs** | **2.224 μs** | **6.236 μs** | **28.20 μs** | **1.00** | **0.00** | **1.32 KB** | **1.00** | -| User_FullHit_CopyOnRead | 1000 | 10 | 74.43 μs | 8.027 μs | 23.414 μs | 83.30 μs | 2.90 | 0.80 | 50.62 KB | 38.34 | -| | | | | | | | | | | | -| **User_FullHit_Snapshot** | **1000** | **100** | **26.31 μs** | **2.547 μs** | **7.266 μs** | **24.30 μs** | **1.00** | **0.00** | **1.32 KB** | **1.00** | -| User_FullHit_CopyOnRead | 1000 | 100 | 288.42 μs | 26.812 μs | 78.636 μs | 294.10 μs | 11.77 | 4.11 | 472.91 KB | 358.18 | -| | | | | | | | | | | | -| **User_FullHit_Snapshot** | **10000** | **1** | **15.74 μs** | **2.110 μs** | **6.121 μs** | **14.50 μs** | **1.00** | **0.00** | **1.32 KB** | **1.00** | -| User_FullHit_CopyOnRead | 10000 | 1 | 47.63 μs | 5.995 μs | 17.391 μs | 44.20 μs | 3.22 | 1.10 | 71.67 KB | 54.28 | -| | | | | | | | | | | | -| **User_FullHit_Snapshot** | **10000** | **10** | **18.11 μs** | **2.417 μs** | **6.936 μs** | **17.70 μs** | **1.00** | **0.00** | **1.32 KB** | **1.00** | -| User_FullHit_CopyOnRead | 10000 | 10 | 321.96 μs | 21.435 μs | 62.864 μs | 335.40 μs | 20.19 | 7.70 | 493.59 KB | 373.84 | -| | | | | | | | | | | | -| **User_FullHit_Snapshot** | **10000** | **100** | **13.65 μs** | **1.139 μs** | **3.041 μs** | **14.60 μs** | **1.00** | **0.00** | **1.32 KB** | **1.00** | -| User_FullHit_CopyOnRead | 10000 | 100 | 1,627.24 μs | 241.090 μs | 710.858 μs | 1,228.45 μs | 131.10 | 61.19 | 4712.76 KB | 3,569.43 | -| | | | | | | | | | | | -| **User_FullMiss_Snapshot** | **100** | **1** | **42.82 μs** | **2.507 μs** | **6.693 μs** | **42.50 μs** | **?** | **?** | **6.47 KB** | **?** | -| User_FullMiss_CopyOnRead | 100 | 1 | 44.97 μs | 3.070 μs | 8.351 μs | 44.00 μs | ? | ? | 6.47 KB | ? | -| | | | | | | | | | | | -| **User_FullMiss_Snapshot** | **100** | **10** | **66.30 μs** | **1.320 μs** | **3.262 μs** | **66.35 μs** | **?** | **?** | **27.64 KB** | **?** | -| User_FullMiss_CopyOnRead | 100 | 10 | 66.02 μs | 1.802 μs | 4.841 μs | 66.05 μs | ? | ? | 27.64 KB | ? | -| | | | | | | | | | | | -| **User_FullMiss_Snapshot** | **100** | **100** | **244.85 μs** | **12.346 μs** | **33.378 μs** | **252.80 μs** | **?** | **?** | **210.88 KB** | **?** | -| User_FullMiss_CopyOnRead | 100 | 100 | 258.13 μs | 9.359 μs | 25.935 μs | 261.90 μs | ? | ? | 210.88 KB | ? | -| | | | | | | | | | | | -| **User_FullMiss_Snapshot** | **1000** | **1** | **71.30 μs** | **2.052 μs** | **5.442 μs** | **69.90 μs** | **?** | **?** | **31.09 KB** | **?** | -| User_FullMiss_CopyOnRead | 1000 | 1 | 71.73 μs | 2.411 μs | 6.519 μs | 71.55 μs | ? | ? | 31.09 KB | ? | -| | | | | | | | | | | | -| **User_FullMiss_Snapshot** | **1000** | **10** | **126.31 μs** | **8.422 μs** | **22.769 μs** | **122.60 μs** | **?** | **?** | **212.63 KB** | **?** | -| User_FullMiss_CopyOnRead | 1000 | 10 | 140.75 μs | 11.412 μs | 31.813 μs | 144.25 μs | ? | ? | 213.69 KB | ? | -| | | | | | | | | | | | -| **User_FullMiss_Snapshot** | **1000** | **100** | **932.72 μs** | **49.104 μs** | **135.247 μs** | **881.25 μs** | **?** | **?** | **1813.59 KB** | **?** | -| User_FullMiss_CopyOnRead | 1000 | 100 | 1,843.16 μs | 209.596 μs | 584.269 μs | 2,114.05 μs | ? | ? | 1812.09 KB | ? | -| | | | | | | | | | | | -| **User_FullMiss_Snapshot** | **10000** | **1** | **325.50 μs** | **21.469 μs** | **58.408 μs** | **352.15 μs** | **?** | **?** | **248.77 KB** | **?** | -| User_FullMiss_CopyOnRead | 10000 | 1 | 345.79 μs | 6.858 μs | 18.067 μs | 348.80 μs | ? | ? | 248.77 KB | ? | -| | | | | | | | | | | | -| **User_FullMiss_Snapshot** | **10000** | **10** | **2,084.77 μs** | **150.453 μs** | **398.979 μs** | **2,221.20 μs** | **?** | **?** | **1848.04 KB** | **?** | -| User_FullMiss_CopyOnRead | 10000 | 10 | 2,129.79 μs | 106.833 μs | 277.674 μs | 2,227.50 μs | ? | ? | 1848.04 KB | ? | -| | | | | | | | | | | | -| **User_FullMiss_Snapshot** | **10000** | **100** | **8,709.28 μs** | **691.244 μs** | **1,845.070 μs** | **7,924.45 μs** | **?** | **?** | **16048.36 KB** | **?** | -| User_FullMiss_CopyOnRead | 10000 | 100 | 9,873.87 μs | 885.900 μs | 2,454.824 μs | 9,722.10 μs | ? | ? | 16046.84 KB | ? | -| | | | | | | | | | | | -| **User_PartialHit_ForwardShift_Snapshot** | **100** | **1** | **64.46 μs** | **5.562 μs** | **15.412 μs** | **61.40 μs** | **?** | **?** | **6.35 KB** | **?** | -| User_PartialHit_ForwardShift_CopyOnRead | 100 | 1 | 60.24 μs | 3.333 μs | 8.723 μs | 60.05 μs | ? | ? | 6.36 KB | ? | -| User_PartialHit_BackwardShift_Snapshot | 100 | 1 | 52.74 μs | 1.789 μs | 4.744 μs | 52.60 μs | ? | ? | 6.3 KB | ? | -| User_PartialHit_BackwardShift_CopyOnRead | 100 | 1 | 64.81 μs | 6.651 μs | 19.294 μs | 56.90 μs | ? | ? | 6.92 KB | ? | -| | | | | | | | | | | | -| **User_PartialHit_ForwardShift_Snapshot** | **100** | **10** | **121.93 μs** | **3.800 μs** | **10.403 μs** | **120.95 μs** | **?** | **?** | **20.63 KB** | **?** | -| User_PartialHit_ForwardShift_CopyOnRead | 100 | 10 | 128.64 μs | 5.914 μs | 15.265 μs | 126.95 μs | ? | ? | 20.63 KB | ? | -| User_PartialHit_BackwardShift_Snapshot | 100 | 10 | 91.33 μs | 2.236 μs | 5.929 μs | 90.65 μs | ? | ? | 20.57 KB | ? | -| User_PartialHit_BackwardShift_CopyOnRead | 100 | 10 | 102.66 μs | 3.812 μs | 9.907 μs | 99.80 μs | ? | ? | 20.58 KB | ? | -| | | | | | | | | | | | -| **User_PartialHit_ForwardShift_Snapshot** | **100** | **100** | **715.70 μs** | **17.401 μs** | **46.746 μs** | **724.70 μs** | **?** | **?** | **161.38 KB** | **?** | -| User_PartialHit_ForwardShift_CopyOnRead | 100 | 100 | 786.19 μs | 15.678 μs | 39.907 μs | 789.30 μs | ? | ? | 162.88 KB | ? | -| User_PartialHit_BackwardShift_Snapshot | 100 | 100 | 539.84 μs | 23.799 μs | 64.747 μs | 552.15 μs | ? | ? | 162.82 KB | ? | -| User_PartialHit_BackwardShift_CopyOnRead | 100 | 100 | 504.87 μs | 19.855 μs | 52.306 μs | 511.70 μs | ? | ? | 162.83 KB | ? | -| | | | | | | | | | | | -| **User_PartialHit_ForwardShift_Snapshot** | **1000** | **1** | **132.28 μs** | **3.258 μs** | **8.640 μs** | **131.25 μs** | **?** | **?** | **27.52 KB** | **?** | -| User_PartialHit_ForwardShift_CopyOnRead | 1000 | 1 | 158.52 μs | 2.790 μs | 6.297 μs | 157.55 μs | ? | ? | 27.52 KB | ? | -| User_PartialHit_BackwardShift_Snapshot | 1000 | 1 | 119.84 μs | 2.836 μs | 7.569 μs | 119.00 μs | ? | ? | 27.46 KB | ? | -| User_PartialHit_BackwardShift_CopyOnRead | 1000 | 1 | 115.82 μs | 2.687 μs | 7.031 μs | 114.55 μs | ? | ? | 27.47 KB | ? | -| | | | | | | | | | | | -| **User_PartialHit_ForwardShift_Snapshot** | **1000** | **10** | **578.40 μs** | **11.398 μs** | **25.494 μs** | **580.30 μs** | **?** | **?** | **168.5 KB** | **?** | -| User_PartialHit_ForwardShift_CopyOnRead | 1000 | 10 | 866.30 μs | 44.396 μs | 129.505 μs | 794.85 μs | ? | ? | 168.51 KB | ? | -| User_PartialHit_BackwardShift_Snapshot | 1000 | 10 | 417.43 μs | 12.077 μs | 32.651 μs | 424.30 μs | ? | ? | 168.45 KB | ? | -| User_PartialHit_BackwardShift_CopyOnRead | 1000 | 10 | 501.60 μs | 11.092 μs | 28.631 μs | 506.40 μs | ? | ? | 168.45 KB | ? | -| | | | | | | | | | | | -| **User_PartialHit_ForwardShift_Snapshot** | **1000** | **100** | **5,982.06 μs** | **494.680 μs** | **1,458.576 μs** | **6,578.30 μs** | **?** | **?** | **1576.25 KB** | **?** | -| User_PartialHit_ForwardShift_CopyOnRead | 1000 | 100 | 7,914.86 μs | 526.029 μs | 1,551.009 μs | 8,492.20 μs | ? | ? | 1576.23 KB | ? | -| User_PartialHit_BackwardShift_Snapshot | 1000 | 100 | 4,469.76 μs | 349.830 μs | 1,031.482 μs | 4,843.75 μs | ? | ? | 1576.17 KB | ? | -| User_PartialHit_BackwardShift_CopyOnRead | 1000 | 100 | 3,866.99 μs | 452.560 μs | 1,192.225 μs | 4,546.70 μs | ? | ? | 1574.69 KB | ? | -| | | | | | | | | | | | -| **User_PartialHit_ForwardShift_Snapshot** | **10000** | **1** | **807.67 μs** | **12.108 μs** | **21.522 μs** | **809.00 μs** | **?** | **?** | **238.67 KB** | **?** | -| User_PartialHit_ForwardShift_CopyOnRead | 10000 | 1 | 1,097.37 μs | 25.335 μs | 64.024 μs | 1,100.30 μs | ? | ? | 238.68 KB | ? | -| User_PartialHit_BackwardShift_Snapshot | 10000 | 1 | 593.11 μs | 17.900 μs | 48.395 μs | 597.70 μs | ? | ? | 238.62 KB | ? | -| User_PartialHit_BackwardShift_CopyOnRead | 10000 | 1 | 675.89 μs | 4.438 μs | 10.018 μs | 674.70 μs | ? | ? | 238.63 KB | ? | -| | | | | | | | | | | | -| **User_PartialHit_ForwardShift_Snapshot** | **10000** | **10** | **6,705.68 μs** | **348.699 μs** | **1,022.673 μs** | **6,946.60 μs** | **?** | **?** | **1645.13 KB** | **?** | -| User_PartialHit_ForwardShift_CopyOnRead | 10000 | 10 | 8,066.30 μs | 388.037 μs | 1,138.046 μs | 8,305.40 μs | ? | ? | 1643.65 KB | ? | -| User_PartialHit_BackwardShift_Snapshot | 10000 | 10 | 4,519.36 μs | 297.315 μs | 867.283 μs | 4,834.05 μs | ? | ? | 1643.81 KB | ? | -| User_PartialHit_BackwardShift_CopyOnRead | 10000 | 10 | 4,693.33 μs | 229.131 μs | 611.598 μs | 4,767.70 μs | ? | ? | 1645.09 KB | ? | -| | | | | | | | | | | | -| **User_PartialHit_ForwardShift_Snapshot** | **10000** | **100** | **27,022.21 μs** | **1,189.747 μs** | **3,432.693 μs** | **25,733.55 μs** | **?** | **?** | **15708.63 KB** | **?** | -| User_PartialHit_ForwardShift_CopyOnRead | 10000 | 100 | 35,055.92 μs | 2,298.232 μs | 6,740.316 μs | 32,342.90 μs | ? | ? | 15708.15 KB | ? | -| User_PartialHit_BackwardShift_Snapshot | 10000 | 100 | 20,446.49 μs | 1,155.748 μs | 3,297.415 μs | 19,069.30 μs | ? | ? | 15707.95 KB | ? | -| User_PartialHit_BackwardShift_CopyOnRead | 10000 | 100 | 23,373.30 μs | 1,962.415 μs | 5,786.225 μs | 22,798.40 μs | ? | ? | 15708.59 KB | ? | +| Method | RangeSpan | CacheCoefficientSize | Mean | Error | StdDev | Median | Ratio | RatioSD | Allocated | Alloc Ratio | +|-------------------------------------------|-----------|----------------------|-----------------:|-----------------:|-----------------:|-----------------:|---------:|---------:|----------------:|------------:| +| **User_FullHit_Snapshot** | **100** | **1** | **29.96 μs** | **2.855 μs** | **7.960 μs** | **30.85 μs** | **1.00** | **0.00** | **1.38 KB** | **1.00** | +| User_FullHit_CopyOnRead | 100 | 1 | 35.13 μs | 4.092 μs | 11.806 μs | 30.50 μs | 1.21 | 0.33 | 2.12 KB | 1.54 | +| | | | | | | | | | | | +| **User_FullHit_Snapshot** | **100** | **10** | **30.85 μs** | **2.636 μs** | **7.604 μs** | **31.90 μs** | **1.00** | **0.00** | **1.38 KB** | **1.00** | +| User_FullHit_CopyOnRead | 100 | 10 | 48.88 μs | 8.043 μs | 23.462 μs | 49.75 μs | 1.54 | 0.44 | 6.38 KB | 4.64 | +| | | | | | | | | | | | +| **User_FullHit_Snapshot** | **100** | **100** | **27.20 μs** | **2.017 μs** | **5.688 μs** | **24.45 μs** | **1.00** | **0.00** | **1.38 KB** | **1.00** | +| User_FullHit_CopyOnRead | 100 | 100 | 69.98 μs | 7.059 μs | 20.703 μs | 78.00 μs | 2.62 | 0.56 | 48.98 KB | 35.62 | +| | | | | | | | | | | | +| **User_FullHit_Snapshot** | **1000** | **1** | **29.70 μs** | **2.644 μs** | **7.457 μs** | **26.55 μs** | **1.00** | **0.00** | **1.38 KB** | **1.00** | +| User_FullHit_CopyOnRead | 1000 | 1 | 49.76 μs | 8.004 μs | 23.221 μs | 56.40 μs | 1.69 | 0.64 | 8.45 KB | 6.14 | +| | | | | | | | | | | | +| **User_FullHit_Snapshot** | **1000** | **10** | **26.67 μs** | **2.065 μs** | **5.892 μs** | **24.05 μs** | **1.00** | **0.00** | **1.38 KB** | **1.00** | +| User_FullHit_CopyOnRead | 1000 | 10 | 71.54 μs | 7.724 μs | 22.409 μs | 78.70 μs | 2.72 | 0.74 | 50.67 KB | 36.85 | +| | | | | | | | | | | | +| **User_FullHit_Snapshot** | **1000** | **100** | **24.30 μs** | **2.301 μs** | **6.376 μs** | **21.60 μs** | **1.00** | **0.00** | **1.38 KB** | **1.00** | +| User_FullHit_CopyOnRead | 1000 | 100 | 302.58 μs | 10.121 μs | 29.524 μs | 296.35 μs | 13.47 | 4.45 | 472.97 KB | 343.98 | +| | | | | | | | | | | | +| **User_FullHit_Snapshot** | **10000** | **1** | **27.95 μs** | **2.182 μs** | **6.153 μs** | **29.05 μs** | **1.00** | **0.00** | **1.38 KB** | **1.00** | +| User_FullHit_CopyOnRead | 10000 | 1 | 85.71 μs | 7.473 μs | 21.916 μs | 92.50 μs | 3.13 | 0.48 | 71.73 KB | 52.16 | +| | | | | | | | | | | | +| **User_FullHit_Snapshot** | **10000** | **10** | **27.82 μs** | **2.442 μs** | **6.766 μs** | **28.00 μs** | **1.00** | **0.00** | **1.38 KB** | **1.00** | +| User_FullHit_CopyOnRead | 10000 | 10 | 315.29 μs | 12.731 μs | 37.337 μs | 309.20 μs | 12.04 | 2.90 | 493.64 KB | 359.01 | +| | | | | | | | | | | | +| **User_FullHit_Snapshot** | **10000** | **100** | **14.01 μs** | **1.748 μs** | **4.786 μs** | **12.80 μs** | **1.00** | **0.00** | **1.38 KB** | **1.00** | +| User_FullHit_CopyOnRead | 10000 | 100 | 1,880.60 μs | 257.551 μs | 755.351 μs | 2,162.30 μs | 143.58 | 48.53 | 4712.81 KB | 3,427.50 | +| | | | | | | | | | | | +| **User_FullMiss_Snapshot** | **100** | **1** | **44.32 μs** | **3.037 μs** | **8.364 μs** | **43.05 μs** | **?** | **?** | **8.43 KB** | **?** | +| User_FullMiss_CopyOnRead | 100 | 1 | 43.19 μs | 3.200 μs | 8.973 μs | 41.50 μs | ? | ? | 8.43 KB | ? | +| | | | | | | | | | | | +| **User_FullMiss_Snapshot** | **100** | **10** | **65.40 μs** | **2.306 μs** | **6.390 μs** | **64.40 μs** | **?** | **?** | **43.6 KB** | **?** | +| User_FullMiss_CopyOnRead | 100 | 10 | 64.70 μs | 2.707 μs | 7.501 μs | 63.80 μs | ? | ? | 43.6 KB | ? | +| | | | | | | | | | | | +| **User_FullMiss_Snapshot** | **100** | **100** | **237.37 μs** | **10.835 μs** | **29.477 μs** | **242.55 μs** | **?** | **?** | **338.69 KB** | **?** | +| User_FullMiss_CopyOnRead | 100 | 100 | 230.09 μs | 14.281 μs | 38.851 μs | 241.45 μs | ? | ? | 338.69 KB | ? | +| | | | | | | | | | | | +| **User_FullMiss_Snapshot** | **1000** | **1** | **73.20 μs** | **3.111 μs** | **8.463 μs** | **72.35 μs** | **?** | **?** | **46.08 KB** | **?** | +| User_FullMiss_CopyOnRead | 1000 | 1 | 70.86 μs | 2.302 μs | 6.183 μs | 69.80 μs | ? | ? | 47.05 KB | ? | +| | | | | | | | | | | | +| **User_FullMiss_Snapshot** | **1000** | **10** | **254.12 μs** | **7.715 μs** | **20.989 μs** | **255.85 μs** | **?** | **?** | **341.5 KB** | **?** | +| User_FullMiss_CopyOnRead | 1000 | 10 | 255.75 μs | 5.140 μs | 14.665 μs | 254.85 μs | ? | ? | 341.5 KB | ? | +| | | | | | | | | | | | +| **User_FullMiss_Snapshot** | **1000** | **100** | **2,029.39 μs** | **161.830 μs** | **474.619 μs** | **2,207.40 μs** | **?** | **?** | **2837.4 KB** | **?** | +| User_FullMiss_CopyOnRead | 1000 | 100 | 1,836.24 μs | 194.372 μs | 573.110 μs | 2,164.00 μs | ? | ? | 2836.02 KB | ? | +| | | | | | | | | | | | +| **User_FullMiss_Snapshot** | **10000** | **1** | **337.32 μs** | **6.736 μs** | **9.661 μs** | **336.00 μs** | **?** | **?** | **375.09 KB** | **?** | +| User_FullMiss_CopyOnRead | 10000 | 1 | 321.29 μs | 7.587 μs | 20.513 μs | 322.90 μs | ? | ? | 376.59 KB | ? | +| | | | | | | | | | | | +| **User_FullMiss_Snapshot** | **10000** | **10** | **2,674.83 μs** | **211.148 μs** | **622.575 μs** | **2,802.20 μs** | **?** | **?** | **2871.85 KB** | **?** | +| User_FullMiss_CopyOnRead | 10000 | 10 | 1,913.67 μs | 155.929 μs | 459.761 μs | 2,130.10 μs | ? | ? | 2871.85 KB | ? | +| | | | | | | | | | | | +| **User_FullMiss_Snapshot** | **10000** | **100** | **7,949.13 μs** | **155.932 μs** | **292.877 μs** | **7,905.60 μs** | **?** | **?** | **24238.63 KB** | **?** | +| User_FullMiss_CopyOnRead | 10000 | 100 | 10,734.45 μs | 1,270.301 μs | 3,725.574 μs | 8,346.10 μs | ? | ? | 24238.63 KB | ? | +| | | | | | | | | | | | +| **User_PartialHit_ForwardShift_Snapshot** | **100** | **1** | **62.20 μs** | **3.479 μs** | **9.164 μs** | **61.70 μs** | **?** | **?** | **7.55 KB** | **?** | +| User_PartialHit_ForwardShift_CopyOnRead | 100 | 1 | 73.25 μs | 8.521 μs | 24.720 μs | 61.85 μs | ? | ? | 8.63 KB | ? | +| User_PartialHit_BackwardShift_Snapshot | 100 | 1 | 60.92 μs | 2.312 μs | 5.969 μs | 60.25 μs | ? | ? | 8.57 KB | ? | +| User_PartialHit_BackwardShift_CopyOnRead | 100 | 1 | 67.06 μs | 7.733 μs | 22.061 μs | 57.15 μs | ? | ? | 8.58 KB | ? | +| | | | | | | | | | | | +| **User_PartialHit_ForwardShift_Snapshot** | **100** | **10** | **131.90 μs** | **5.349 μs** | **14.186 μs** | **133.30 μs** | **?** | **?** | **36.97 KB** | **?** | +| User_PartialHit_ForwardShift_CopyOnRead | 100 | 10 | 104.56 μs | 3.975 μs | 10.540 μs | 102.80 μs | ? | ? | 36.98 KB | ? | +| User_PartialHit_BackwardShift_Snapshot | 100 | 10 | 102.07 μs | 3.674 μs | 9.995 μs | 101.60 μs | ? | ? | 36.91 KB | ? | +| User_PartialHit_BackwardShift_CopyOnRead | 100 | 10 | 98.00 μs | 7.240 μs | 18.818 μs | 93.70 μs | ? | ? | 36.92 KB | ? | +| | | | | | | | | | | | +| **User_PartialHit_ForwardShift_Snapshot** | **100** | **100** | **652.47 μs** | **23.683 μs** | **64.028 μs** | **664.40 μs** | **?** | **?** | **289.8 KB** | **?** | +| User_PartialHit_ForwardShift_CopyOnRead | 100 | 100 | 485.86 μs | 26.372 μs | 68.076 μs | 502.25 μs | ? | ? | 289.8 KB | ? | +| User_PartialHit_BackwardShift_Snapshot | 100 | 100 | 465.19 μs | 22.154 μs | 59.134 μs | 476.15 μs | ? | ? | 291.23 KB | ? | +| User_PartialHit_BackwardShift_CopyOnRead | 100 | 100 | 389.69 μs | 27.684 μs | 71.954 μs | 416.40 μs | ? | ? | 289.75 KB | ? | +| | | | | | | | | | | | +| **User_PartialHit_ForwardShift_Snapshot** | **1000** | **1** | **155.32 μs** | **3.576 μs** | **9.544 μs** | **155.70 μs** | **?** | **?** | **43.86 KB** | **?** | +| User_PartialHit_ForwardShift_CopyOnRead | 1000 | 1 | 124.29 μs | 4.768 μs | 12.309 μs | 123.35 μs | ? | ? | 43.87 KB | ? | +| User_PartialHit_BackwardShift_Snapshot | 1000 | 1 | 123.71 μs | 2.206 μs | 4.796 μs | 123.80 μs | ? | ? | 43.8 KB | ? | +| User_PartialHit_BackwardShift_CopyOnRead | 1000 | 1 | 105.33 μs | 4.644 μs | 12.153 μs | 106.50 μs | ? | ? | 43.81 KB | ? | +| | | | | | | | | | | | +| **User_PartialHit_ForwardShift_Snapshot** | **1000** | **10** | **670.66 μs** | **24.535 μs** | **65.910 μs** | **681.60 μs** | **?** | **?** | **296.91 KB** | **?** | +| User_PartialHit_ForwardShift_CopyOnRead | 1000 | 10 | 514.15 μs | 10.155 μs | 25.664 μs | 517.50 μs | ? | ? | 296.92 KB | ? | +| User_PartialHit_BackwardShift_Snapshot | 1000 | 10 | 621.96 μs | 14.831 μs | 42.313 μs | 626.95 μs | ? | ? | 296.86 KB | ? | +| User_PartialHit_BackwardShift_CopyOnRead | 1000 | 10 | 489.42 μs | 31.658 μs | 92.348 μs | 448.95 μs | ? | ? | 295.6 KB | ? | +| | | | | | | | | | | | +| **User_PartialHit_ForwardShift_Snapshot** | **1000** | **100** | **5,248.27 μs** | **510.892 μs** | **1,506.376 μs** | **5,894.90 μs** | **?** | **?** | **2600.71 KB** | **?** | +| User_PartialHit_ForwardShift_CopyOnRead | 1000 | 100 | 4,767.05 μs | 409.194 μs | 1,193.638 μs | 5,281.85 μs | ? | ? | 2600.72 KB | ? | +| User_PartialHit_BackwardShift_Snapshot | 1000 | 100 | 3,755.66 μs | 343.639 μs | 957.927 μs | 4,144.60 μs | ? | ? | 2599.16 KB | ? | +| User_PartialHit_BackwardShift_CopyOnRead | 1000 | 100 | 3,228.39 μs | 296.816 μs | 797.378 μs | 3,632.55 μs | ? | ? | 2600.66 KB | ? | +| | | | | | | | | | | | +| **User_PartialHit_ForwardShift_Snapshot** | **10000** | **1** | **1,016.99 μs** | **6.934 μs** | **12.853 μs** | **1,014.90 μs** | **?** | **?** | **365.59 KB** | **?** | +| User_PartialHit_ForwardShift_CopyOnRead | 10000 | 1 | 713.44 μs | 14.272 μs | 36.842 μs | 714.55 μs | ? | ? | 367.09 KB | ? | +| User_PartialHit_BackwardShift_Snapshot | 10000 | 1 | 732.28 μs | 26.092 μs | 70.095 μs | 710.90 μs | ? | ? | 367.03 KB | ? | +| User_PartialHit_BackwardShift_CopyOnRead | 10000 | 1 | 573.70 μs | 11.410 μs | 27.556 μs | 578.80 μs | ? | ? | 367.04 KB | ? | +| | | | | | | | | | | | +| **User_PartialHit_ForwardShift_Snapshot** | **10000** | **10** | **5,623.62 μs** | **409.161 μs** | **1,133.784 μs** | **6,097.60 μs** | **?** | **?** | **2669.62 KB** | **?** | +| User_PartialHit_ForwardShift_CopyOnRead | 10000 | 10 | 5,195.34 μs | 373.495 μs | 1,083.577 μs | 5,588.80 μs | ? | ? | 2668.13 KB | ? | +| User_PartialHit_BackwardShift_Snapshot | 10000 | 10 | 4,019.55 μs | 327.104 μs | 900.940 μs | 4,382.55 μs | ? | ? | 2668.16 KB | ? | +| User_PartialHit_BackwardShift_CopyOnRead | 10000 | 10 | 3,449.88 μs | 301.895 μs | 779.287 μs | 3,779.80 μs | ? | ? | 2669.57 KB | ? | +| | | | | | | | | | | | +| **User_PartialHit_ForwardShift_Snapshot** | **10000** | **100** | **29,005.11 μs** | **1,309.680 μs** | **3,861.622 μs** | **27,406.10 μs** | **?** | **?** | **23900.88 KB** | **?** | +| User_PartialHit_ForwardShift_CopyOnRead | 10000 | 100 | 23,645.77 μs | 1,477.890 μs | 4,311.074 μs | 21,620.00 μs | ? | ? | 23901.2 KB | ? | +| User_PartialHit_BackwardShift_Snapshot | 10000 | 100 | 20,928.49 μs | 1,412.896 μs | 4,165.956 μs | 18,886.40 μs | ? | ? | 23900.39 KB | ? | +| User_PartialHit_BackwardShift_CopyOnRead | 10000 | 100 | 18,722.83 μs | 1,429.961 μs | 4,193.828 μs | 16,507.45 μs | ? | ? | 23900.84 KB | ? | From e965773582a20cd1bcb05e21361119c970f51388 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Sat, 28 Feb 2026 23:09:51 +0100 Subject: [PATCH 14/21] Update docs/components/rebalance-path.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/components/rebalance-path.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/components/rebalance-path.md b/docs/components/rebalance-path.md index 4969296..78edb63 100644 --- a/docs/components/rebalance-path.md +++ b/docs/components/rebalance-path.md @@ -14,10 +14,10 @@ Rebalancing is expensive: it involves debounce delays, optional I/O, and atomic |---------------------------------------------------------|------------------------------------------------------------------------------------|--------------------------------------------------------------| | `IntentController` | `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` | Background loop; decision orchestration; cancellation | | `RebalanceDecisionEngine` | `src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs` | **Sole authority** for rebalance necessity; 5-stage pipeline | -| `NoRebalanceSatisfactionPolicy` | `src/SlidingWindowCache/Core/Planning/NoRebalanceSatisfactionPolicy.cs` | Stages 1 & 2: NoRebalanceRange containment checks | +| `NoRebalanceSatisfactionPolicy` | `src/SlidingWindowCache/Core/Rebalance/Decision/NoRebalanceSatisfactionPolicy.cs` | Stages 1 & 2: NoRebalanceRange containment checks | | `ProportionalRangePlanner` | `src/SlidingWindowCache/Core/Planning/ProportionalRangePlanner.cs` | Stage 3: desired cache range computation | | `NoRebalanceRangePlanner` | `src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs` | Stage 3: desired NoRebalanceRange computation | -| `IRebalanceExecutionController` | `src/SlidingWindowCache/Infrastructure/Execution/IRebalanceExecutionController.cs` | Debounce + single-flight execution contract | +| `IRebalanceExecutionController` | `src/SlidingWindowCache/Core/Rebalance/Execution/IRebalanceExecutionController.cs` | Debounce + single-flight execution contract | | `RebalanceExecutor` | `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` | Sole writer; performs `Rematerialize` | See also the split component pages for deeper detail: From 2300da487dcdfba98edc8d4c598b055849cca3ff Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Sat, 28 Feb 2026 23:18:45 +0100 Subject: [PATCH 15/21] refactor: improve diagnostics and data handling in IDataSource and RangeChunk --- docs/diagnostics.md | 2 +- src/SlidingWindowCache/Public/Dto/RangeChunk.cs | 4 ++-- src/SlidingWindowCache/Public/IDataSource.cs | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 93df597..936a13e 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -56,10 +56,10 @@ public interface ICacheDiagnostics // Data Source Access Events void DataSourceFetchSingleRange(); void DataSourceFetchMissingSegments(); + void DataSegmentUnavailable(); // Rebalance Intent Lifecycle Events void RebalanceIntentPublished(); - void RebalanceIntentCancelled(); // Rebalance Execution Lifecycle Events void RebalanceExecutionStarted(); diff --git a/src/SlidingWindowCache/Public/Dto/RangeChunk.cs b/src/SlidingWindowCache/Public/Dto/RangeChunk.cs index 37e498e..e049065 100644 --- a/src/SlidingWindowCache/Public/Dto/RangeChunk.cs +++ b/src/SlidingWindowCache/Public/Dto/RangeChunk.cs @@ -14,7 +14,7 @@ namespace SlidingWindowCache.Public.Dto; /// /// /// The data elements for the range. -/// Empty list when Range is null. +/// Empty sequence when Range is null. /// /// /// IDataSource Contract: @@ -28,5 +28,5 @@ namespace SlidingWindowCache.Public.Dto; /// // Request [600..700] → Return RangeChunk(null, empty list) /// /// -public sealed record RangeChunk(Range? Range, IReadOnlyList Data) +public sealed record RangeChunk(Range? Range, IEnumerable Data) where TRange : IComparable; \ No newline at end of file diff --git a/src/SlidingWindowCache/Public/IDataSource.cs b/src/SlidingWindowCache/Public/IDataSource.cs index 370ffca..b67ba10 100644 --- a/src/SlidingWindowCache/Public/IDataSource.cs +++ b/src/SlidingWindowCache/Public/IDataSource.cs @@ -74,12 +74,12 @@ public interface IDataSource where TRange : IComparable /// 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 - /// + /// + /// 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 contains exactly Range.Span elements when Range is non-null + /// /// Boundary Handling Examples: /// /// // Database with records ID 100-500 From e62bdb7606abae4cd6725b6df14bcc2ec20e2d9a Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Sat, 28 Feb 2026 23:21:18 +0100 Subject: [PATCH 16/21] refactor: update user-path documentation for improved clarity and accuracy --- docs/components/user-path.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/components/user-path.md b/docs/components/user-path.md index 259fbc6..4ab2fec 100644 --- a/docs/components/user-path.md +++ b/docs/components/user-path.md @@ -10,12 +10,12 @@ User requests must not block on background optimization. The user path does the ## Key Components -| Component | File | Role | -|-----------------------------------------------------|-------------------------------------------------------------------------------|-----------------------------------------------------| -| `WindowCache` | `src/SlidingWindowCache/Public/WindowCache.cs` | Public facade; delegates to `UserRequestHandler` | -| `UserRequestHandler` | `src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs` | Internal user-path logic; sole publisher of intents | -| `CacheDataExtensionService` | `src/SlidingWindowCache/Infrastructure/Services/CacheDataExtensionService.cs` | Assembles requested range from cache + IDataSource | -| `IntentController` | `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` | Publish-side only from user path | +| Component | File | Role | +|-----------------------------------------------------|--------------------------------------------------------------------------------|-----------------------------------------------------| +| `WindowCache` | `src/SlidingWindowCache/Public/WindowCache.cs` | Public facade; delegates to `UserRequestHandler` | +| `UserRequestHandler` | `src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs` | Internal user-path logic; sole publisher of intents | +| `CacheDataExtensionService` | `src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs` | Assembles requested range from cache + IDataSource | +| `IntentController` | `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` | Publish-side only from user path | ## Execution Context @@ -44,14 +44,14 @@ All user-path code executes on the **⚡ User Thread** (the caller's thread). No ## Invariants -| Invariant | Description | -|-----------|--------------------------------------------------------------------------------------------------------------------------| -| A.0 | User requests always served immediately (never blocked by rebalance) | -| A.3 | `UserRequestHandler` is the sole publisher of rebalance intents | -| A.4 | Intent publication is fire-and-forget (background only) | -| A.5 | User path is strictly read-only w.r.t. `CacheState` | -| A.10 | Returns exactly `RequestedRange` data | -| G.45 | I/O isolation: `IDataSource` never called from background on user's behalf (extension service runs on background thread) | +| Invariant | Description | +|-----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| A.0 | User requests always served immediately (never blocked by rebalance) | +| A.3 | `UserRequestHandler` is the sole publisher of rebalance intents | +| A.4 | Intent publication is fire-and-forget (background only) | +| A.5 | User path is strictly read-only w.r.t. `CacheState` | +| A.10 | Returns exactly `RequestedRange` data | +| G.45 | I/O isolation: `IDataSource` called on user's behalf from User Thread (partial hits) or Background Thread (rebalance execution); shared `CacheDataExtensionService` used by both paths | See `docs/invariants.md` (Section A: User Path invariants) for full specification. From 08c570dfe9b314d5e852ef59ffed022e8c293843 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Sun, 1 Mar 2026 00:07:38 +0100 Subject: [PATCH 17/21] refactor: improve documentation for CopyOnReadStorage and clarify locking behavior; refactor: enhance ToRangeData method to ensure immutable snapshot is returned; refactor: update invariants and summaries for better clarity on storage strategies --- docs/components/overview.md | 4 +- docs/invariants.md | 7 +- docs/storage-strategies.md | 44 +++++--- .../Storage/CopyOnReadStorage.cs | 100 +++++++++++++----- 4 files changed, 109 insertions(+), 46 deletions(-) diff --git a/docs/components/overview.md b/docs/components/overview.md index e2d8e21..3bd0e2d 100644 --- a/docs/components/overview.md +++ b/docs/components/overview.md @@ -236,10 +236,10 @@ Only `UserRequestHandler` has access to `IntentController.PublishIntent`. Its sc ### Atomic Cache Updates **Invariants**: B.12, B.13 -Storage strategies build new state before atomic swap. `Volatile.Write` atomically publishes new cache state reference. `Rematerialize` succeeds completely or not at all. +Storage strategies build new state before atomic swap. `Volatile.Write` atomically publishes new cache state reference (Snapshot). `CopyOnReadStorage` uses a lock-protected buffer swap instead. `Rematerialize` succeeds completely or not at all. - `src/SlidingWindowCache/Infrastructure/Storage/SnapshotReadStorage.cs` — `Array.Copy` + `Volatile.Write` -- `src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs` — list replacement + `Volatile.Write` +- `src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs` — lock-protected dual-buffer swap (`_lock`) - `src/SlidingWindowCache/Core/State/CacheState.cs` — `Rematerialize` ensures atomicity ### Consistency Under Cancellation diff --git a/docs/invariants.md b/docs/invariants.md index 9509f5d..4435217 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -192,9 +192,10 @@ without polling or timing dependencies. **A.2** 🟢 **[Behavioral — Test: `Invariant_A2_2_UserPathNeverWaitsForRebalance`]** The User Path **never waits for rebalance execution** to complete. - *Observable via*: Request completion time vs. debounce delay - *Test verifies*: Request completes in <500ms with 1-second debounce -- *Conditional compliance*: `CopyOnReadStorage` acquires a short-lived `_lock` in `Read()` shared with - `Rematerialize()`. The lock is held only for the buffer swap and `Range` update — not for data fetching - or the full rebalance cycle. Contention is sub-millisecond and bounded. `SnapshotReadStorage` remains +- *Conditional compliance*: `CopyOnReadStorage` acquires a short-lived `_lock` in `Read()` and + `ToRangeData()`, shared with `Rematerialize()`. The lock is held only for the buffer swap and `Range` + update (in `Rematerialize()`), or for the duration of the array copy (in `Read()` and `ToRangeData()`). + All contention is sub-millisecond and bounded. `SnapshotReadStorage` remains fully lock-free. See [Storage Strategies Guide](storage-strategies.md#invariant-a2---user-path-never-waits-for-rebalance-conditional-compliance) for details. **A.3** 🔵 **[Architectural]** The User Path is the **sole source of rebalance intent**. diff --git a/docs/storage-strategies.md b/docs/storage-strategies.md index 3517370..4c924bd 100644 --- a/docs/storage-strategies.md +++ b/docs/storage-strategies.md @@ -180,7 +180,7 @@ lock (_lock) - ✅ **No LOH pressure**: List growth strategy avoids large single allocations - ✅ **Correct enumeration**: Staging buffer prevents corruption during LINQ-derived expansion - ✅ **Amortized performance**: Cost decreases over time as capacity stabilizes -- ✅ **Safe concurrent access**: `Read()` and `Rematerialize()` share a lock; mid-swap observation is impossible +- ✅ **Safe concurrent access**: `Read()`, `Rematerialize()`, and `ToRangeData()` share a lock; mid-swap observation is impossible - ❌ **Expensive reads**: Each read acquires a lock, allocates, and copies - ❌ **Higher memory**: Two buffers instead of one - ⚠️ **Lock contention**: Reader briefly blocks if rematerialization is in progress (bounded to a single `Rematerialize()` call duration) @@ -290,9 +290,8 @@ This composition leverages the strengths of both strategies: | Read | O(n) | n × sizeof(T) | Lock acquired + copy | | Rematerialize (cold) | O(n) | n × sizeof(T) | Enumerate outside lock | | Rematerialize (warm) | O(n) | 0 bytes** | Enumerate outside lock | -| ToRangeData | O(1) | 0 bytes* | Not synchronized (rebalance path only) | +| ToRangeData | O(n) | n × sizeof(T) | Lock acquired + array snapshot copy | -*Returns lazy enumerable **When capacity is sufficient ### Measured Benchmark Results @@ -334,24 +333,34 @@ Consider cache expansion during user request: ```csharp // Current cache: [100, 110] -var currentData = cache.ToRangeData(); // Lazy IEnumerable over _activeStorage +var currentData = cache.ToRangeData(); +// CopyOnReadStorage: acquires _lock, copies _activeStorage to a new array, returns immutable snapshot. +// The returned RangeData.Data is decoupled from the live buffers — no lazy reference. // User requests: [105, 115] var extendedData = await ExtendCacheAsync(currentData, [105, 115]); -// extendedData.Data = Concat(currentData.Data, newlyFetched) -// This is a LINQ chain still tied to _activeStorage! +// extendedData.Data = Union(currentData.Data, newlyFetched) +// Safe to enumerate later: currentData.Data is an array, not a live List reference. cache.Rematerialize(extendedData); -// OLD (BROKEN): _storage.Clear() → corrupts LINQ chain mid-enumeration -// NEW (CORRECT): _stagingBuffer.Clear() → _activeStorage remains immutable +// _stagingBuffer.Clear() is safe: extendedData.Data chains from the immutable snapshot array, +// not from _activeStorage directly. ``` +> **Why the snapshot copy matters:** Without `.ToArray()`, `ToRangeData()` would return a lazy +> `IEnumerable` over the live `_activeStorage` list. That reference is published as an `Intent` +> and consumed asynchronously on the rebalance thread. A second `Rematerialize()` call would swap +> the list to `_stagingBuffer` and clear it before the Intent is consumed — silently emptying the +> enumerable mid-enumeration (or causing `InvalidOperationException`). The snapshot copy eliminates +> this race entirely. + ### Buffer Swap Invariants 1. **Active storage is immutable during reads**: Never mutated until swap; lock prevents concurrent observation mid-swap 2. **Staging buffer is write-only during rematerialization**: Cleared and filled outside the lock, then swapped under lock -3. **Swap is lock-protected**: `Read()` and `Rematerialize()` share `_lock`; a reader always sees a consistent `(_activeStorage, Range)` pair +3. **Swap is lock-protected**: `Read()`, `ToRangeData()`, and `Rematerialize()` share `_lock`; all callers always observe a consistent `(_activeStorage, Range)` pair 4. **Buffers never shrink**: Capacity grows monotonically, amortizing allocation cost +5. **`ToRangeData()` snapshots are immutable**: `ToRangeData()` copies `_activeStorage` to a new array under the lock, ensuring the returned `RangeData` is decoupled from buffer reuse — a subsequent `Rematerialize()` cannot corrupt or empty data still referenced by an outstanding enumerable ### Memory Growth Example @@ -399,8 +408,9 @@ The staging buffer pattern directly supports key system invariants: ### Invariant A.2 - User Path Never Waits for Rebalance (Conditional Compliance) -- `CopyOnReadStorage` is **conditionally compliant**: `Read()` acquires `_lock`, which is also held by - `Rematerialize()` for the duration of the buffer swap and Range update (a fast, bounded operation). +- `CopyOnReadStorage` is **conditionally compliant**: `Read()` and `ToRangeData()` acquire `_lock`, + which is also held by `Rematerialize()` for the duration of the buffer swap and Range update (a fast, + bounded operation). - Contention is limited to the swap itself — not the full rebalance cycle (fetch + decision + execution). The enumeration into the staging buffer happens **before** the lock is acquired, so the lock hold time is just the cost of two field writes and a property assignment. @@ -460,10 +470,14 @@ public async Task CopyOnReadMode_CorrectDuringExpansion() ## Summary -- **Snapshot**: Fast reads, expensive rematerialization, best for read-heavy workloads -- **CopyOnRead with Staging Buffer**: Fast rematerialization, expensive reads, best for rematerialization-heavy - workloads +- **Snapshot**: Fast reads (zero-allocation), expensive rematerialization, best for read-heavy workloads +- **CopyOnRead with Staging Buffer**: Fast rematerialization, all reads copy under lock (`Read()` and + `ToRangeData()`), best for rematerialization-heavy workloads - **Composition**: Combine both strategies in multi-level caches for optimal performance -- **Staging Buffer**: Critical correctness pattern preventing enumeration corruption +- **Staging Buffer**: Critical correctness pattern preventing enumeration corruption during cache expansion +- **`ToRangeData()` safety**: `CopyOnReadStorage.ToRangeData()` copies `_activeStorage` to an immutable + array snapshot under the lock. This is required because `ToRangeData()` is called from the user thread + concurrently with `Rematerialize()`, and a lazy reference to the live buffer could be corrupted by a + subsequent buffer swap and clear. Choose based on your access pattern. When in doubt, start with Snapshot and profile. diff --git a/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs b/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs index de49ff5..4a9f77d 100644 --- a/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs +++ b/src/SlidingWindowCache/Infrastructure/Storage/CopyOnReadStorage.cs @@ -26,7 +26,7 @@ namespace SlidingWindowCache.Infrastructure.Storage; /// This storage maintains two internal lists: /// /// -/// _activeStorage - Serves data to Read() operations; never mutated during reads +/// _activeStorage - Serves data to Read() and ToRangeData(); never mutated during those calls /// _stagingBuffer - Write-only during rematerialization; reused across operations /// /// Rematerialization Process: @@ -39,18 +39,33 @@ namespace SlidingWindowCache.Infrastructure.Storage; /// Release _lock /// /// -/// This ensures that active storage is never observed mid-swap by a concurrent Read() call, -/// preventing data races when range data is derived from the same storage (e.g., during cache expansion -/// per Invariant A.3.8). +/// This ensures that active storage is never observed mid-swap by a concurrent Read() or +/// ToRangeData() call, preventing data races when range data is derived from the same storage +/// (e.g., during cache expansion per Invariant A.3.8). /// /// Synchronization: /// -/// Read() and Rematerialize() share a single _lock object. -/// This is the accepted trade-off for buffer reuse: contention is bounded to the duration of a -/// single Rematerialize() call (a sub-millisecond linear copy), not the full rebalance cycle. -/// ToRangeData() is only called by the rebalance path (the same thread as Rematerialize()) -/// and is therefore not synchronized. +/// Read(), Rematerialize(), and ToRangeData() share a single _lock +/// object. /// +/// +/// +/// Rematerialize() holds the lock only for the two-field swap and Range update +/// (bounded to two field writes and a property assignment — sub-microsecond). The enumeration +/// into the staging buffer happens before the lock is acquired. +/// +/// +/// Read() holds the lock for the duration of the array copy (O(n), bounded by cache size). +/// +/// +/// ToRangeData() is called from the user path and holds the lock while copying +/// _activeStorage to an immutable array snapshot. This ensures the returned +/// captures a consistent +/// (_activeStorage, Range) pair and is decoupled from buffer reuse: a subsequent +/// Rematerialize() that swaps and clears the old active buffer cannot corrupt or +/// truncate data that is still referenced by an outstanding lazy enumerable. +/// +/// /// /// See Invariant A.2 for the conditional compliance note regarding this lock. /// @@ -63,8 +78,9 @@ namespace SlidingWindowCache.Infrastructure.Storage; /// /// Read Behavior: /// -/// Each read operation acquires the lock, allocates a new array, and copies data from active storage -/// (copy-on-read semantics). This is a trade-off for cheaper rematerialization compared to Snapshot mode. +/// Both Read() and ToRangeData() acquire the lock, allocate a new array, and copy +/// data from active storage (copy-on-read semantics). This is a trade-off for cheaper +/// rematerialization compared to Snapshot mode. /// /// When to Use: /// @@ -80,12 +96,12 @@ internal sealed class CopyOnReadStorage : ICacheStorage< { private readonly TDomain _domain; - // Shared lock: acquired by both Read() and Rematerialize() to prevent observation of mid-swap state. - // ToRangeData() is not synchronized because it is only called from the rebalance path. + // Shared lock: acquired by Read(), Rematerialize(), and ToRangeData() to prevent observation of + // mid-swap state and to ensure each caller captures a consistent (_activeStorage, Range) pair. private readonly object _lock = new(); - // Active storage: serves data to Read() operations; never mutated while _lock is held by Read() - // volatile is NOT needed: both Read() and the swap in Rematerialize() access this field + // Active storage: serves data to Read() and ToRangeData() operations; never mutated while _lock is held + // volatile is NOT needed: Read(), ToRangeData(), and the swap in Rematerialize() access this field // exclusively under _lock, which provides full acquire/release fence semantics. private List _activeStorage = []; @@ -116,7 +132,7 @@ public CopyOnReadStorage(TDomain domain) /// This method implements a dual-buffer pattern to satisfy Invariants A.3.8, B.11-12: /// /// - /// Acquire _lock (shared with Read()) + /// Acquire _lock (shared with Read() and ToRangeData()) /// Clear staging buffer (preserves capacity for reuse) /// Enumerate range data into staging buffer (single-pass, no double enumeration) /// Swap buffers: staging becomes active, old active becomes staging @@ -130,10 +146,10 @@ public CopyOnReadStorage(TDomain domain) /// /// /// Why the lock? The buffer swap consists of two separate field writes, which are - /// not atomic at the CPU level. Without the lock, a concurrent Read() on the User thread could - /// observe _activeStorage mid-swap (new list reference but stale Range, or vice versa), - /// producing incorrect results. The lock eliminates this window. Contention is bounded to the duration - /// of this method call, not the full rebalance cycle. + /// not atomic at the CPU level. Without the lock, a concurrent Read() or ToRangeData() + /// on the User thread could observe _activeStorage mid-swap (new list reference but stale + /// Range, or vice versa), producing incorrect results. The lock eliminates this window. + /// Contention is bounded to the duration of this method call, not the full rebalance cycle. /// /// /// Memory efficiency: The staging buffer reuses capacity across rematerializations, @@ -215,14 +231,46 @@ public ReadOnlyMemory Read(Range range) /// /// /// - /// Returns a representing - /// the current active storage. The returned data is a lazy enumerable over the active list. + /// Acquires _lock and captures an immutable array snapshot of _activeStorage + /// together with the current Range, returning a fully materialized + /// backed by that snapshot. + /// + /// + /// Why synchronized? This method is called from the user path + /// (e.g., UserRequestHandler) concurrently with Rematerialize() on the rebalance + /// thread. Without the lock, two distinct races are possible: + /// + /// + /// + /// Non-atomic pair read: a concurrent buffer swap could complete between the + /// read of _activeStorage and the read of Range, pairing the new list with the + /// old range (or vice versa), violating the + /// contract that the range length must match the data count. + /// + /// + /// Dangling lazy reference: a lazy IEnumerable over the live + /// _activeStorage list is published as an Intent and later enumerated on the + /// rebalance thread. A subsequent Rematerialize() swaps that list to + /// _stagingBuffer and immediately clears it via _stagingBuffer.Clear() + /// (line 151), corrupting or emptying the data under the still-live enumerable. + /// + /// + /// + /// The lock eliminates both races. The .ToArray() copy decouples the returned + /// from the mutable buffer lifecycle: + /// once the snapshot array is created, no future Rematerialize() can affect it. /// /// - /// This method is only called from the rebalance path — the same thread that calls - /// Rematerialize() — so it is not synchronized. It must not be called concurrently - /// with Rematerialize(). + /// Cost: O(n) time and O(n) allocation (n = number of cached elements), + /// identical to Read(). This is the accepted trade-off: ToRangeData() is called + /// at most once per user request, so the amortized impact on throughput is negligible. /// /// - public RangeData ToRangeData() => _activeStorage.ToRangeData(Range, _domain); + public RangeData ToRangeData() + { + lock (_lock) + { + return _activeStorage.ToArray().ToRangeData(Range, _domain); + } + } } From 65b6caa794fe816912361223cc7ae4edcb92e010 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Sun, 1 Mar 2026 00:44:17 +0100 Subject: [PATCH 18/21] Update docs/components/public-api.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/components/public-api.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/components/public-api.md b/docs/components/public-api.md index d49f777..62a5aef 100644 --- a/docs/components/public-api.md +++ b/docs/components/public-api.md @@ -94,9 +94,9 @@ Batch fetch result from `IDataSource`. Contains: Optional observability interface with 18 event recording methods covering: - User request outcomes (full hit, partial hit, full miss) -- Data source access events -- Rebalance intent lifecycle (published, cancelled) -- Rebalance execution lifecycle (started, completed, cancelled) +- Data source access events and data unavailability (`DataSegmentUnavailable`) +- Rebalance intent events (published) +- Rebalance execution lifecycle (started, completed, failed via `RebalanceExecutionFailed`) - Rebalance skip optimizations (NoRebalanceRange stage 1 & 2, same-range short-circuit) **Implementations**: From d9bf588d721b09cfc2657c6ad9347873cdcc4bdd Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Sun, 1 Mar 2026 00:44:49 +0100 Subject: [PATCH 19/21] Update docs/components/public-api.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/components/public-api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/components/public-api.md b/docs/components/public-api.md index 62a5aef..45766ea 100644 --- a/docs/components/public-api.md +++ b/docs/components/public-api.md @@ -66,9 +66,9 @@ Configuration parameters: - Batch fetch (optional): default implementation uses parallel single-range fetches - Cancellation is cooperative; implementations must respect `CancellationToken` -**Used by**: `CacheDataExtensionService` (background execution path only — never called on the user thread). +**Used by**: `CacheDataExtensionService` (background path) and `UserRequestHandler` (user path for cold starts and full cache misses). -**Invariant**: G.45 (I/O isolation — IDataSource is never called from the user path). +**Invariant**: G.45 (IDataSource is thread-safe and may be called from both background and user paths). ## DTOs From 81fa24675284e15da036be84a8c500623209615d Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Sun, 1 Mar 2026 00:45:10 +0100 Subject: [PATCH 20/21] Update docs/components/intent-management.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/components/intent-management.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/components/intent-management.md b/docs/components/intent-management.md index 6693f6b..2314442 100644 --- a/docs/components/intent-management.md +++ b/docs/components/intent-management.md @@ -6,10 +6,10 @@ Intent management bridges the user path and background work. It receives access ## Key Components -| Component | File | Role | -|--------------------------------------------|--------------------------------------------------------------------|------------------------------------------------------------------| -| `IntentController` | `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` | Manages intent lifecycle; runs background processing loop | -| `Intent` | `src/SlidingWindowCache/Core/Rebalance/Intent/Intent.cs` | Carries `RequestedRange` + `DeliveredData` + `CancellationToken` | +| Component | File | Role | +|--------------------------------------------|--------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| +| `IntentController` | `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` | Manages intent lifecycle; runs background processing loop | +| `Intent` | `src/SlidingWindowCache/Core/Rebalance/Intent/Intent.cs` | Carries `RequestedRange` + `AssembledRangeData`; cancellation is owned by execution requests | ## Execution Contexts From 62902641fc0a44558eedd9887182f9498c6d97e0 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Sun, 1 Mar 2026 01:06:23 +0100 Subject: [PATCH 21/21] refactor(execution): improve execution controller structure and semantics; refactor(intent-management): enhance intent management logic for clarity; refactor(state): update state management to reflect storage changes; refactor(api): adjust public API documentation for consistency --- docs/components/execution.md | 59 +++++++++++-------- docs/components/intent-management.md | 18 +++--- docs/components/public-api.md | 6 +- docs/components/state-and-storage.md | 34 +++++------ docs/state-machine.md | 37 ++++++------ .../Core/Rebalance/Intent/IntentController.cs | 8 +++ .../Core/UserPath/UserRequestHandler.cs | 6 ++ 7 files changed, 95 insertions(+), 73 deletions(-) diff --git a/docs/components/execution.md b/docs/components/execution.md index 3127ce4..7dd5cea 100644 --- a/docs/components/execution.md +++ b/docs/components/execution.md @@ -8,9 +8,9 @@ The execution subsystem performs debounced, cancellable background work and is t | Component | File | Role | |--------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|--------------------------------------------------------------------| -| `IRebalanceExecutionController` | `src/SlidingWindowCache/Infrastructure/Execution/IRebalanceExecutionController.cs` | Execution serialization contract | -| `TaskBasedRebalanceExecutionController` | `src/SlidingWindowCache/Infrastructure/Execution/TaskBasedRebalanceExecutionController.cs` | Default: Task.Run-based debounce + cancellation | -| `ChannelBasedRebalanceExecutionController` | `src/SlidingWindowCache/Infrastructure/Execution/ChannelBasedRebalanceExecutionController.cs` | Optional: Channel-based bounded execution queue | +| `IRebalanceExecutionController` | `src/SlidingWindowCache/Core/Rebalance/Execution/IRebalanceExecutionController.cs` | Execution serialization contract | +| `TaskBasedRebalanceExecutionController` | `src/SlidingWindowCache/Core/Rebalance/Execution/TaskBasedRebalanceExecutionController.cs` | Default: async task-chaining debounce + per-request cancellation | +| `ChannelBasedRebalanceExecutionController` | `src/SlidingWindowCache/Core/Rebalance/Execution/ChannelBasedRebalanceExecutionController.cs` | Optional: channel-based bounded execution queue with backpressure | | `RebalanceExecutor` | `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` | Sole writer; performs `Rematerialize`; the single-writer authority | | `CacheDataExtensionService` | `src/SlidingWindowCache/Infrastructure/Services/CacheDataExtensionService.cs` | Incremental data fetching; range gap analysis | @@ -18,14 +18,16 @@ The execution subsystem performs debounced, cancellable background work and is t ### TaskBasedRebalanceExecutionController (default) -- Uses `Task.Run` with debounce delay and `CancellationTokenSource` -- On each new execution request: cancels previous task, starts new task after debounce +- Uses **async task chaining**: each `PublishExecutionRequest` call creates a new `async Task` that first `await`s the previous task, then runs `ExecuteRequestAsync` after the debounce delay. No `Task.Run` is used — the async state machine naturally schedules continuations on the thread pool via `ConfigureAwait(false)`. +- On each new execution request: a new task is chained onto the tail of the previous one; a per-request `CancellationTokenSource` is created so any in-progress debounce delay can be cancelled when superseded. +- The chaining approach is lock-free: `_currentExecutionTask` is updated via `Volatile.Write` after each chain step. - Selected when `WindowCacheOptions.RebalanceQueueCapacity` is `null` ### ChannelBasedRebalanceExecutionController (optional) -- Uses `System.Threading.Channels.Channel` with bounded capacity -- Provides backpressure semantics; oldest unprocessed request may be dropped on overflow +- Uses `System.Threading.Channels.Channel` with `BoundedChannelFullMode.Wait` +- Provides backpressure semantics: when the channel is at capacity, `PublishExecutionRequest` (an `async ValueTask`) awaits the channel write, throttling the background intent processing loop. **No requests are ever dropped.** +- A dedicated `ProcessExecutionRequestsAsync` loop reads from the channel and executes requests sequentially. - Selected when `WindowCacheOptions.RebalanceQueueCapacity` is set **Strategy comparison:** @@ -83,29 +85,36 @@ The execution subsystem performs debounced, cancellable background work and is t ## Exception Handling -Exceptions in `RebalanceExecutor` are caught by `IntentController.ProcessIntentsAsync` and reported via `ICacheDiagnostics.RebalanceExecutionFailed`. They are **never propagated to the user thread**. +Exceptions thrown by `RebalanceExecutor` are caught **inside the execution controllers**, not in `IntentController.ProcessIntentsAsync`: + +- **`TaskBasedRebalanceExecutionController`**: Exceptions from `ExecuteRequestAsync` (including `OperationCanceledException`) are caught in `ChainExecutionAsync`. An outer try/catch in `ChainExecutionAsync` also handles failures propagated from the previous chained task. +- **`ChannelBasedRebalanceExecutionController`**: Exceptions from `ExecuteRequestAsync` are caught inside the `ProcessExecutionRequestsAsync` reader loop. + +In both cases, `OperationCanceledException` is reported via `ICacheDiagnostics.RebalanceExecutionCancelled` and other exceptions via `ICacheDiagnostics.RebalanceExecutionFailed`. Background execution exceptions are **never propagated to the user thread**. + +`IntentController.ProcessIntentsAsync` has its own exception handling for the intent processing loop itself (e.g., decision evaluation failures or channel write errors during `PublishExecutionRequest`), which are also reported via `ICacheDiagnostics.RebalanceExecutionFailed` and swallowed to keep the loop alive. > ⚠️ Always wire `RebalanceExecutionFailed` in production — it is the only signal for background execution failures. See `docs/diagnostics.md`. ## Invariants -| Invariant | Description | -|-----------|---------------------------------------------------------------------------| -| A.7 | Only `RebalanceExecutor` writes to `CacheState` (single-writer) | -| A.8 | User path never blocks waiting for rebalance | -| B.12 | Cache updates are atomic (all-or-nothing via `Rematerialize`) | -| B.13 | Consistency under cancellation: mutations discarded if cancelled | -| B.15 | Cache contiguity maintained after every `Rematerialize` | -| B.16 | Obsolete results never applied (cancellation token identity check) | -| C.21 | Serial execution: at most one active rebalance at a time | -| F.35 | Multiple cancellation checkpoints: before I/O, after I/O, before mutation | -| F.35a | Cancellation-before-mutation guarantee | -| F.37 | `Rematerialize` accepts arbitrary range and data (full replacement) | -| F.38 | Incremental fetching: only missing subranges fetched | -| F.39 | Data preservation: existing cached data merged during expansion | -| G.45 | I/O isolation: `IDataSource` only called on background thread | -| H.47 | Activity counter incremented before channel write / Task.Run | -| H.48 | Activity counter decremented in `finally` blocks | +| Invariant | Description | +|-----------|--------------------------------------------------------------------------------------------------------| +| A.7 | Only `RebalanceExecutor` writes to `CacheState` (single-writer) | +| A.8 | User path never blocks waiting for rebalance | +| B.12 | Cache updates are atomic (all-or-nothing via `Rematerialize`) | +| B.13 | Consistency under cancellation: mutations discarded if cancelled | +| B.15 | Cache contiguity maintained after every `Rematerialize` | +| B.16 | Obsolete results never applied (cancellation token identity check) | +| C.21 | Serial execution: at most one active rebalance at a time | +| F.35 | Multiple cancellation checkpoints: before I/O, after I/O, before mutation | +| F.35a | Cancellation-before-mutation guarantee | +| F.37 | `Rematerialize` accepts arbitrary range and data (full replacement) | +| F.38 | Incremental fetching: only missing subranges fetched | +| F.39 | Data preservation: existing cached data merged during expansion | +| G.45 | I/O isolation: `IDataSource` called by execution path only (not by user path during normal cache hits) | +| H.47 | Activity counter incremented before channel write / task chain step | +| H.48 | Activity counter decremented in `finally` blocks | See `docs/invariants.md` (Sections A, B, C, F, G, H) for full specification. diff --git a/docs/components/intent-management.md b/docs/components/intent-management.md index 6693f6b..de8fd0a 100644 --- a/docs/components/intent-management.md +++ b/docs/components/intent-management.md @@ -35,9 +35,9 @@ Called by `UserRequestHandler` after serving a request: Runs for the lifetime of the cache on a dedicated background task: -1. Wait on semaphore (no CPU spinning) -2. Atomically read and clear pending intent -3. Check cancellation (post-debounce early exit) +1. Wait on semaphore (no CPU spinning) — passes `_loopCancellation.Token` to `WaitAsync` so disposal cancels the wait cleanly +2. Atomically read and clear pending intent via `Interlocked.Exchange` +3. If intent is null (multiple intents collapsed before the loop read): decrement activity counter in `finally`, continue 4. Invoke `RebalanceDecisionEngine.Evaluate()` (5-stage pipeline, CPU-only) 5. If no execution required: record skip diagnostic, decrement activity counter, continue 6. If execution required: cancel previous `CancellationTokenSource`, enqueue to `IRebalanceExecutionController` @@ -69,12 +69,12 @@ User burst: intent₁ → intent₂ → intent₃ ## Internal State -| Field | Type | Description | -|--------------------|---------------------------|--------------------------------------------------------------------| -| `_pendingIntent` | `Intent?` (volatile) | Latest unprocessed intent; written by user thread, cleared by loop | -| `_semaphore` | `SemaphoreSlim` | Wakes background loop when new intent arrives | -| `_loopCts` | `CancellationTokenSource` | Cancels the background loop on disposal | -| `_activityCounter` | `AsyncActivityCounter` | Tracks in-flight operations for `WaitForIdleAsync` | +| Field | Type | Description | +|----------------------|---------------------------|--------------------------------------------------------------------| +| `_pendingIntent` | `Intent?` (volatile) | Latest unprocessed intent; written by user thread, cleared by loop | +| `_intentSignal` | `SemaphoreSlim` | Wakes background loop when new intent arrives | +| `_loopCancellation` | `CancellationTokenSource` | Cancels the background loop on disposal | +| `_activityCounter` | `AsyncActivityCounter` | Tracks in-flight operations for `WaitForIdleAsync` | ## Invariants diff --git a/docs/components/public-api.md b/docs/components/public-api.md index d49f777..df42232 100644 --- a/docs/components/public-api.md +++ b/docs/components/public-api.md @@ -66,9 +66,11 @@ Configuration parameters: - Batch fetch (optional): default implementation uses parallel single-range fetches - Cancellation is cooperative; implementations must respect `CancellationToken` -**Used by**: `CacheDataExtensionService` (background execution path only — never called on the user thread). +**Called from two contexts:** +- **User Path** (`UserRequestHandler`): on cold start (uninitialized cache), full cache miss (no overlap with current cache range), and partial cache hit (for the uncached portion via `CacheDataExtensionService`). These are synchronous to the user request — the user awaits the result. +- **Background Execution Path** (`CacheDataExtensionService` via `RebalanceExecutor`): for incremental cache expansion during background rebalance. Only missing sub-ranges are fetched. -**Invariant**: G.45 (I/O isolation — IDataSource is never called from the user path). +**Implementations must be safe to call from both contexts** and must not assume a single caller thread. ## DTOs diff --git a/docs/components/state-and-storage.md b/docs/components/state-and-storage.md index 7772c27..5627811 100644 --- a/docs/components/state-and-storage.md +++ b/docs/components/state-and-storage.md @@ -21,15 +21,15 @@ State and storage define how cached data is held, read, and published. `CacheSta | Field | Type | Written by | Read by | |--------------------|-----------------|--------------------------|----------------------------------------| -| `Cache` (storage) | `ICacheStorage` | `RebalanceExecutor` only | `UserRequestHandler`, `DecisionEngine` | +| `Storage` | `ICacheStorage` | `RebalanceExecutor` only | `UserRequestHandler`, `DecisionEngine` | | `IsInitialized` | `bool` | `RebalanceExecutor` only | `UserRequestHandler` | | `NoRebalanceRange` | `Range?` | `RebalanceExecutor` only | `DecisionEngine` | **Single-Writer Rule (Invariant A.7):** Only `RebalanceExecutor` writes any field of `CacheState`. User path components are read-only. This is enforced by internal visibility modifiers (setters are `internal`), not by locks. -**No internal locking:** The single-writer constraint makes locks unnecessary. `Volatile.Write` / `Volatile.Read` patterns ensure visibility across threads where needed. +**Visibility model:** `CacheState` itself has no locks. Cross-thread visibility for `IsInitialized` and `NoRebalanceRange` is provided by the single-writer architecture — only one background thread ever writes these fields, and readers accept eventual consistency. Storage-level thread safety is handled inside each `ICacheStorage` implementation: `SnapshotReadStorage` uses a `volatile` array field with release/acquire fence ordering; `CopyOnReadStorage` uses a `lock` for its active-buffer swap and all reads. -**Atomic updates via `Rematerialize`:** The `Rematerialize` method replaces the storage contents in a single atomic operation. No intermediate states are visible to readers. +**Atomic updates via `Rematerialize`:** The `Rematerialize` method replaces the storage contents in a single atomic operation (under `lock` for `CopyOnReadStorage`, via `volatile` write for `SnapshotReadStorage`). No intermediate states are visible to readers. ## Storage Strategies @@ -57,17 +57,17 @@ State and storage define how cached data is held, read, and published. `CacheSta **Strategy**: Dual-buffer pattern — active storage is never mutated during enumeration. -| Operation | Behavior | -|-----------------|-----------------------------------------------------------------------------------------| -| `Rematerialize` | Clears staging buffer, fills with new data, atomically swaps with active | -| `Read` | Acquires lock, allocates `TData[]`, copies from active buffer, returns `ReadOnlyMemory` | -| `ToRangeData` | Returns lazy enumerable over active storage (unsynchronized; rebalance path only) | +| Operation | Behavior | +|-----------------|--------------------------------------------------------------------------------------------------| +| `Rematerialize` | Fills staging buffer outside the lock, then atomically swaps staging/active buffers under lock | +| `Read` | Acquires lock, allocates `TData[]`, copies from active buffer, returns `ReadOnlyMemory` | +| `ToRangeData` | Acquires lock, copies active buffer via `.ToArray()`, returns immutable `RangeData` snapshot | **Staging Buffer Pattern:** ``` -Active buffer: [existing data] ← user reads here (immutable during enumeration) -Staging buffer: [new data] ← rematerialization builds here - ↓ swap (under lock) +Active buffer: [existing data] ← user reads here (lock-protected) +Staging buffer: [new data] ← rematerialization builds here (outside lock) + ↓ swap (under lock, sub-microsecond) Active buffer: [new data] ← now visible to reads Staging buffer: [old data] ← reused next rematerialization (capacity preserved) ``` @@ -75,11 +75,11 @@ Staging buffer: [old data] ← reused next rematerialization (capacity pr **Characteristics**: - ✅ Cheap rematerialization (amortized O(1) when capacity sufficient) - ✅ No LOH pressure (List growth strategy) -- ✅ Correct enumeration during LINQ-derived expansion +- ✅ Correct enumeration during LINQ-derived expansion (staging buffer filled outside lock using LINQ chains over immutable data) - ❌ Allocation on every read (lock + array copy) - Best for: rematerialization-heavy workloads, large sliding windows -> **Note**: `ToRangeData()` is unsynchronized and must only be called from the rebalance path. See `docs/storage-strategies.md`. +> **Note**: `ToRangeData()` acquires the same lock as `Read()` and `Rematerialize()` (the critical section). It returns an immutable snapshot — a freshly allocated array — that is fully decoupled from the mutable buffer lifecycle. See `docs/storage-strategies.md`. ### Strategy Selection @@ -90,14 +90,14 @@ Controlled by `WindowCacheOptions.UserCacheReadMode`: ## Read/Write Pattern Summary ``` -UserRequestHandler ──reads───▶ CacheState.Cache.Read() - CacheState.Cache.ToRangeData() +UserRequestHandler ──reads───▶ CacheState.Storage.Read() + CacheState.Storage.ToRangeData() CacheState.IsInitialized DecisionEngine ──reads───▶ CacheState.NoRebalanceRange - CacheState.Cache.Range + CacheState.Storage.Range -RebalanceExecutor ──writes──▶ CacheState.Cache.Rematerialize() ← SOLE WRITER +RebalanceExecutor ──writes──▶ CacheState.Storage.Rematerialize() ← SOLE WRITER CacheState.NoRebalanceRange ← SOLE WRITER CacheState.IsInitialized ← SOLE WRITER ``` diff --git a/docs/state-machine.md b/docs/state-machine.md index 2998a62..4020f29 100644 --- a/docs/state-machine.md +++ b/docs/state-machine.md @@ -18,15 +18,13 @@ Most concurrency complexity disappears if we can answer two questions unambiguou The cache is in one of three states: **1. Uninitialized** -- `CurrentCacheRange == null` -- `CacheData == null` -- `IsInitialized == false` -- `NoRebalanceRange == null` +- `CacheState.IsInitialized == false` +- `CacheState.Storage` exists but contains no data (empty buffer) +- `CacheState.NoRebalanceRange == null` **2. Initialized** -- `CurrentCacheRange != null` -- `CacheData != null` -- `CacheData` is consistent with `CurrentCacheRange` (Invariant B.11) +- `CacheState.IsInitialized == true` +- `CacheState.Storage` holds a contiguous, non-empty range of data consistent with `CacheState.Storage.Range` (Invariant B.11) - Cache is contiguous — no gaps (Invariant A.9a) - Ready to serve user requests @@ -87,8 +85,7 @@ See `docs/invariants.md` for the formal single-writer rule (Invariants A.-1, A.7 3. User Path publishes intent with delivered data 4. Rebalance Execution performs first cache write - **Mutations** (Rebalance Execution only): - - Set `CacheData` = delivered data from intent - - Set `CurrentCacheRange` = delivered range + - Call `Storage.Rematerialize()` with delivered data and range - Set `IsInitialized = true` - **Atomicity**: Changes applied atomically (Invariant B.12) - **Postcondition**: Cache enters `Initialized` after execution completes @@ -118,7 +115,7 @@ See `docs/invariants.md` for the formal single-writer rule (Invariants A.-1, A.7 - Fetch missing data for `DesiredCacheRange` (only truly missing parts) - Merge delivered data with fetched data - Trim to `DesiredCacheRange` (normalization) - - Set `CacheData` and `CurrentCacheRange` via `Rematerialize()` + - Call `Storage.Rematerialize()` with merged, trimmed data (sets storage contents and `Storage.Range`) - Set `IsInitialized = true` - Recompute `NoRebalanceRange` - **Atomicity**: Changes applied atomically (Invariant B.12) @@ -147,7 +144,7 @@ See `docs/invariants.md` for the formal single-writer rule (Invariants A.-1, A.7 | Rebalancing | None | All cache mutations (expand, trim, Rematerialize, IsInitialized, NoRebalanceRange) — must yield on cancellation | **User Path mutations (Invariants A.7, A.8)**: -- User Path NEVER calls `Cache.Rematerialize()` +- User Path NEVER calls `Storage.Rematerialize()` - User Path NEVER writes to `IsInitialized` - User Path NEVER writes to `NoRebalanceRange` @@ -155,7 +152,7 @@ See `docs/invariants.md` for the formal single-writer rule (Invariants A.-1, A.7 1. Uses delivered data from intent as authoritative base 2. Expands to `DesiredCacheRange` (fetches only truly missing ranges) 3. Trims excess data outside `DesiredCacheRange` -4. Writes to `Cache.Rematerialize()` (cache data and range) +4. Calls `Storage.Rematerialize()` (atomically replaces storage data and `Storage.Range`) 5. Writes to `IsInitialized = true` 6. Recomputes and writes to `NoRebalanceRange` @@ -177,25 +174,25 @@ See `docs/invariants.md` for the formal single-writer rule (Invariants A.-1, A.7 **State Safety**: - **Atomicity**: All cache mutations are atomic (Invariant B.12) -- **Consistency**: `CacheData ↔ CurrentCacheRange` always consistent (Invariant B.11) +- **Consistency**: `Storage` data and `Storage.Range` always consistent (Invariant B.11) - **Contiguity**: Cache data never contains gaps (Invariant A.9a) - **Idempotence**: Multiple cancellations are safe ### State Invariants by State **In Uninitialized**: -- All range and data fields are null +- `IsInitialized == false`; `Storage` contains no data; `NoRebalanceRange == null` - User Path is read-only (no mutations) - Rebalance Execution is not active (activates after first intent) **In Initialized**: -- `CacheData ↔ CurrentCacheRange` consistent (Invariant B.11) +- `Storage` data and `Storage.Range` consistent (Invariant B.11) - Cache is contiguous (Invariant A.9a) - User Path is read-only (Invariant A.8) - Rebalance Execution is not active **In Rebalancing**: -- `CacheData ↔ CurrentCacheRange` remain consistent (Invariant B.11) +- `Storage` data and `Storage.Range` remain consistent (Invariant B.11) - Cache is contiguous (Invariant A.9a) - User Path may cause cancellation but NOT mutate (Invariants A.0, A.0a) - Rebalance Execution is active and sole writer (Invariant F.36) @@ -212,7 +209,7 @@ User requests [100, 200] → User Path fetches [100, 200] from IDataSource → User Path returns data to user immediately → User Path publishes intent with delivered data [100, 200] -→ Rebalance Execution writes: CacheData, CurrentCacheRange=[100,200], IsInitialized=true +→ Rebalance Execution calls Storage.Rematerialize([100,200]), sets IsInitialized=true State: Initialized ``` @@ -220,7 +217,7 @@ State: Initialized ``` State: Initialized -CurrentCacheRange = [100, 200] +Storage.Range = [100, 200] User requests [150, 250] → User Path reads [150,200] from cache, fetches [201,250] from IDataSource @@ -241,10 +238,10 @@ State: Rebalancing (R2 executing with new DesiredCacheRange) ``` State: Rebalancing -CurrentCacheRange = [100, 200] +Storage.Range = [100, 200] R1 executing for DesiredCacheRange = [50, 250] -User requests [500, 600] (no intersection with CurrentCacheRange) +User requests [500, 600] (no intersection with Storage.Range) → User Path fetches [500, 600] from IDataSource (full miss) → User Path returns data to user → User Path publishes intent with delivered data [500, 600] diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index c1b72f4..27631bd 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -211,6 +211,14 @@ private async Task ProcessIntentsAsync() // All decision evaluation (DecisionEngine, Planners, Policy) happens HERE in background // Evaluate DecisionEngine INSIDE loop (avoids race conditions) var lastExecutionRequest = _executionController.LastExecutionRequest; + // _state.Storage.Range and _state.NoRebalanceRange are read without explicit + // synchronization. This is intentional: the decision engine operates on an + // eventually-consistent snapshot of cache state. A slightly stale range or + // NoRebalanceRange value may cause one extra or skipped rebalance, but the + // system self-corrects on the next intent. The single-writer architecture + // guarantees no torn writes; CopyOnReadStorage protects the Range value via its + // internal lock only for reads inside Read()/ToRangeData(); bare Range reads + // here accept the same eventual-consistency contract. var decision = _decisionEngine.Evaluate( requestedRange: intent.RequestedRange, currentNoRebalanceRange: _state.NoRebalanceRange, diff --git a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs index c177da9..b545390 100644 --- a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs +++ b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs @@ -133,6 +133,12 @@ public async ValueTask> HandleRequestAsync( } var cacheStorage = _state.Storage; + // Bare Range reads without lock/volatile fence are intentional. + // CacheState follows an eventual-consistency model on the user path: IsInitialized, + // Storage.Range, and the storage contents are all written by the single rebalance writer. + // A slightly stale Range observation here at most causes the user path to take a + // suboptimal branch (e.g., treating a full hit as a partial hit), but the intent + // published at the end of this method will drive the system back to the correct state. var fullyInCache = _state.IsInitialized && cacheStorage.Range.Contains(requestedRange); var hasOverlap = _state.IsInitialized && !fullyInCache && cacheStorage.Range.Overlaps(requestedRange);