From d689ce95a5e85950db994783f88e567dfa8bfdfe Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Mon, 16 Feb 2026 23:24:18 +0100 Subject: [PATCH 01/23] docs: refactor rebalance decision model to implement multi-stage validation pipeline, clarifying cancellation logic and ensuring correctness in rebalance necessity determination. --- README.md | 14 ++- docs/actors-and-responsibilities.md | 69 +++++++---- docs/actors-to-components-mapping.md | 91 +++++++++----- docs/cache-state-machine.md | 37 +++--- docs/component-map.md | 114 +++++++++++++++--- docs/concurrency-model.md | 35 ++++-- docs/invariants.md | 80 +++++++++++- docs/scenario-model.md | 81 ++++++++++--- .../README.md | 10 +- 9 files changed, 405 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 0e1422c..4cd03de 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Sliding Window Cache -**A read-only, range-based, sequential-optimized cac~~~~he with background rebalancing and cancellation-aware prefetching.** +**A read-only, range-based, sequential-optimized cache with background rebalancing and intelligent prefetching.** --- @@ -38,10 +38,12 @@ The Sliding Window Cache is a high-performance caching library designed for scen - **Automatic Prefetching**: Intelligently prefetches data on both sides of requested ranges based on configurable coefficients - **Background Rebalancing**: Asynchronously adjusts the cache window when access patterns change, with debouncing to avoid thrashing -- **Cancellation-Aware**: Full support for `CancellationToken` throughout the async pipeline +- **Multi-Stage Rebalance Validation**: CPU-only analytical decision pipeline determines rebalance necessity through NoRebalanceRange validation and cache geometry analysis +- **Opportunistic Execution**: Rebalance operations may be skipped when validation determines they are unnecessary (intent represents observed access, not mandatory work) - **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 --- @@ -424,9 +426,11 @@ For detailed architectural documentation, see: ### Key Architectural Principles 1. **Cache Contiguity**: Cache data must always remain contiguous (no gaps). Non-intersecting requests fully replace the cache. -2. **User Priority**: User requests always cancel ongoing/pending rebalance operations to maintain responsiveness. -3. **Single-Writer Architecture**: Only Rebalance Execution writes to cache state; User Path is read-only. -4. **Lock-Free Concurrency**: Intent management uses `Interlocked.Exchange` for atomic operations - no locks, no race conditions, guaranteed progress. Validated under concurrent load in test suite. +2. **Multi-Stage Rebalance Validation**: Rebalance necessity determined by CPU-only analytical decision pipeline (NoRebalanceRange validation, cache geometry analysis). Rebalance is opportunistic and may be skipped when validation determines it's unnecessary. +3. **Intent Semantics**: Intents represent observed access patterns (signals), not mandatory work (commands). Publishing an intent does not guarantee rebalance execution. +4. **Single-Writer Architecture**: Only Rebalance Execution writes to cache state; User Path is read-only. Cancellation serves as mechanical coordination tool (prevents concurrent executions), not a decision mechanism. +5. **User Path Priority**: User requests always served immediately. When rebalance validation confirms necessity, pending rebalance is cancelled and rescheduled with new validated parameters. +6. **Lock-Free Concurrency**: Intent management uses `Interlocked.Exchange` for atomic operations - no locks, no race conditions, guaranteed progress. Validated under concurrent load in test suite. --- diff --git a/docs/actors-and-responsibilities.md b/docs/actors-and-responsibilities.md index 2a9daec..eaf1dda 100644 --- a/docs/actors-and-responsibilities.md +++ b/docs/actors-and-responsibilities.md @@ -26,7 +26,7 @@ The UserRequestHandler NEVER invokes directly decision logic - it just publishes **Responsible for invariants:** - -1. User Path and Rebalance Execution never write to cache concurrently - 0. User Path has higher priority than rebalance execution -- 0a. Every User Request MUST cancel any ongoing or pending Rebalance Execution to prevent interference +- 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 @@ -54,31 +54,37 @@ The UserRequestHandler NEVER invokes directly decision logic - it just publishes ## 2. Rebalance Decision Engine (Pure Decision Actor) **Role:** -Analyzes the need for rebalance and forms intents without mutating system state. +The **sole authority for rebalance necessity determination**. Analyzes the need for rebalance through multi-stage analytical validation without mutating system state. **Execution Context:** **Lives in: Background / ThreadPool** **Visibility:** - **Not visible to User Path** -- Invoked only by RebalanceIntentManager +- Invoked only by RebalanceScheduler - May execute many times, results may be discarded **Critical Rule:** ``` DecisionEngine lives strictly inside the background contour. +DecisionEngine is the ONLY authority for rebalance necessity determination. ``` +**Multi-Stage Validation Pipeline:** +1. **Stage 1**: Current Cache NoRebalanceRange containment check (fast path) +2. **Stage 2**: Pending Desired Cache NoRebalanceRange validation (anti-thrashing, conceptual) +3. **Stage 3**: DesiredCacheRange vs CurrentCacheRange equality check + **Responsible for invariants:** -- 24. Decision Path is purely analytical +- 24. Decision Path is purely analytical (CPU-only, no I/O) - 25. Never mutates cache state -- 26. No rebalance if inside NoRebalanceRange -- 27. No rebalance if DesiredCacheRange == CurrentCacheRange -- 28. Rebalance triggered only if confirmed necessary +- 26. No rebalance if inside NoRebalanceRange (Stage 1 validation) +- 27. No rebalance if DesiredCacheRange == CurrentCacheRange (Stage 3 validation) +- 28. Rebalance triggered only if ALL validation stages confirm necessity -**Responsibility Type:** ensures correctness of decisions +**Responsibility Type:** ensures correctness of rebalance necessity decisions through analytical validation -**Note:** Not a top-level actor — internal tool of IntentManager/Executor pipeline. +**Note:** Not a top-level actor — internal tool of IntentManager/Executor pipeline, but THE authority for necessity determination. --- @@ -113,7 +119,7 @@ This logical actor is internally decomposed into two components for separation o ## 4. Rebalance Intent Manager (Intent & Concurrency Actor) **Role:** -Manages lifecycle of rebalance intents and prevents races and stale applications. +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: @@ -128,28 +134,31 @@ This logical actor is internally decomposed into two components for separation o Now responsible for: - **Receiving intents** (on every user request) [Intent Controller] - **Intent identity and versioning** [Intent Controller] -- **Cancellation** of obsolete intents [Intent Controller] +- **Cancellation coordination** based on validation results [Intent Controller] - **Deduplication** and debouncing [Execution Scheduler] - **Single-flight execution** enforcement [Execution Scheduler] - **Starting background tasks** [Execution Scheduler] - **Orchestrating the decision pipeline**: [Execution Scheduler] - 1. Invoke DecisionEngine - 2. If allowed, invoke Executor - 3. Handle cancellation + 1. Invoke DecisionEngine (multi-stage validation) + 2. If ALL stages pass validation, invoke Executor + 3. If validation rejects, skip execution (no-op) + 4. Handle cancellation when new validated rebalance is needed + +**Authority:** *Owns time and concurrency, orchestrates validation-driven execution.* -**Authority:** *Owns time and concurrency.* +**Key Principle:** Cancellation is mechanical coordination (prevents concurrent executions), NOT a decision mechanism. The DecisionEngine determines rebalance necessity; the Intent Manager coordinates execution based on those decisions. **Responsible for invariants:** - 17. At most one active rebalance intent -- 18. Older intents become obsolete -- 19. Executions can be cancelled or ignored +- 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 -- 23. System eventually stabilizes under load -- 24. Intent does not guarantee execution - execution is opportunistic +- 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 +**Responsibility Type:** controls and coordinates intent execution based on validation results **Note:** Internally decomposed into Intent Controller + Execution Scheduler, but externally appears as a single unified actor. @@ -159,7 +168,7 @@ 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). Responsible for cache normalization using delivered data from intent as authoritative source. +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** @@ -172,6 +181,14 @@ Rebalance Executor is the ONLY component that mutates: 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:** - 4. Rebalance is asynchronous relative to User Path - 34. MUST support cancellation at all stages @@ -192,14 +209,14 @@ This eliminates race conditions and ensures consistent cache state. - 40. Upon completion: CurrentCacheRange == DesiredCacheRange - 41. Upon completion: NoRebalanceRange recomputed -**Responsibility Type:** executes rebalance and normalizes cache (cancellable, never concurrent with User Path) +**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. +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 @@ -208,9 +225,9 @@ Ensures atomicity and internal consistency of cache state, coordinates cancellat - 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 before mutation +- 0a. Coordinates cancellation: User Request cancels ongoing/pending Rebalance ONLY when validation confirms new rebalance is necessary -**Responsibility Type:** guarantees state correctness and mutual exclusion +**Responsibility Type:** guarantees state correctness and coordinates single-writer execution --- diff --git a/docs/actors-to-components-mapping.md b/docs/actors-to-components-mapping.md index f913791..e4cd53c 100644 --- a/docs/actors-to-components-mapping.md +++ b/docs/actors-to-components-mapping.md @@ -195,22 +195,26 @@ return _userRequestHandler.HandleRequestAsync(requestedRange, cancellationToken) ``` DecisionEngine lives strictly inside the background contour. +DecisionEngine is the SOLE AUTHORITY for rebalance necessity determination. ``` ### Responsibilities -- Evaluates whether rebalance is required -- Checks: - - NoRebalanceRange - - DesiredCacheRange vs CurrentCacheRange -- Produces a boolean decision +- **THE authority for rebalance necessity determination** +- Evaluates whether rebalance is required through multi-stage validation: + - **Stage 1**: NoRebalanceRange containment check (fast path) + - **Stage 2**: Conceptual anti-thrashing validation (pending desired cache) + - **Stage 3**: DesiredCacheRange vs CurrentCacheRange equality check +- Produces analytical decision (execute or skip) +- Rebalance executes ONLY if ALL validation stages confirm necessity ### Characteristics -- Pure +- Pure (CPU-only, no I/O) - Deterministic - Side-effect free - Does not mutate cache state +- Authority for necessity determination (not a mere helper) ### Notes @@ -220,7 +224,9 @@ This component should be: - fully synchronous - independent of execution context -**Not a top-level actor** — internal tool of IntentManager/Executor pipeline. +**Critical Distinction:** While this is an internal tool of IntentManager/Executor pipeline, +it is **THE sole authority** for determining rebalance necessity. All execution decisions +flow from this component's analytical validation. --- @@ -325,7 +331,7 @@ but externally appears as a unified policy concept. - `internal class RebalanceScheduler` - File: `src/SlidingWindowCache/CacheRebalance/RebalanceScheduler.cs` - Owns debounce timing and background execution - - Orchestrates DecisionEngine → Executor pipeline + - Orchestrates DecisionEngine → Executor pipeline based on validation results - Ensures single-flight execution - **Intentionally stateless** - does not own intent identity - **Task tracking** - provides `WaitForIdleAsync()` for deterministic synchronization (infrastructure/testing) @@ -344,14 +350,15 @@ The Rebalance Intent Manager actor is responsible for: - **Receiving intents** (on every user request) [Intent Controller responsibility] - **Intent lifecycle management** (identity, versioning) [Intent Controller responsibility] -- **Cancellation** of obsolete intents [Intent Controller responsibility] +- **Cancellation coordination** based on validation results [Intent Controller responsibility] - **Deduplication** and debouncing [Execution Scheduler responsibility] - **Single-flight execution** enforcement [Execution Scheduler responsibility] - **Starting background tasks** [Execution Scheduler responsibility] -- **Orchestrating the decision pipeline**: [Execution Scheduler responsibility] - 1. Invoke DecisionEngine - 2. If allowed, invoke Executor - 3. Handle cancellation +- **Orchestrating the validation-driven decision pipeline**: [Execution Scheduler responsibility] + 1. Invoke DecisionEngine (multi-stage analytical validation) + 2. If ALL stages pass validation, invoke Executor + 3. If validation rejects, skip execution (no-op) + 4. Handle cancellation when new validated rebalance is needed ### Component Responsibilities @@ -362,6 +369,7 @@ The Rebalance Intent Manager actor is responsible for: - Invalidates previous intent when new intent arrives - Does NOT perform scheduling or timing logic - Does NOT orchestrate execution pipeline +- Does NOT determine rebalance necessity (DecisionEngine's job) - **Lock-free implementation** using `Interlocked.Exchange` for atomic operations - **Thread-safe without locks** - no race conditions, no blocking - Validated by `ConcurrencyStabilityTests` under concurrent load @@ -370,29 +378,30 @@ The Rebalance Intent Manager actor is responsible for: - Receives intent + cancellation token from Intent Controller - Performs debounce delay - Checks intent validity before execution starts -- Orchestrates DecisionEngine → Executor pipeline +- Orchestrates DecisionEngine → Executor pipeline **based on validation results** - Ensures only one execution runs at a time (via cancellation) - Does NOT own intent identity or versioning -- Does NOT decide whether rebalance is logically required +- Does NOT decide whether rebalance is logically required (delegates to DecisionEngine) - Tracks background Task for deterministic synchronization (`WaitForIdleAsync()`) **Important**: RebalanceScheduler is intentionally stateless and does not own intent identity. All intent lifecycle, superseding, and cancellation semantics are delegated to the Intent Controller (IntentController). -The scheduler only receives a CancellationToken for each scheduled execution and checks its validity. +The scheduler only receives a CancellationToken for each scheduled execution and orchestrates the validation-driven pipeline. ### Key Decision Authority - **When to invoke decision logic** [Scheduler decides after debounce] -- **When to skip execution entirely** [DecisionEngine decides based on logic] +- **Whether rebalance is necessary** [DecisionEngine validates through multi-stage pipeline] +- **When to skip execution entirely** [DecisionEngine validation result] ### Owns - Intent versioning [Intent Controller] - Cancellation tokens [Intent Controller] - Scheduling logic [Execution Scheduler] -- Pipeline orchestration [Execution Scheduler] +- Pipeline orchestration based on validation results [Execution Scheduler] -### Pipeline Orchestration (Philosophy A) +### Pipeline Orchestration (Validation-Driven Model) ``` IntentManager (Intent Controller) @@ -402,26 +411,32 @@ IntentManager (Intent Controller) RebalanceScheduler (Execution Scheduler) ├── debounce delay ├── check validity - └── start pipeline + └── start validation-driven pipeline ↓ - DecisionEngine + DecisionEngine (AUTHORITY for necessity) + ├── Stage 1: Current Cache NoRebalanceRange validation + ├── Stage 2: Pending Desired Cache validation (anti-thrashing) + ├── Stage 3: DesiredCacheRange == CurrentCacheRange check + └── Decision: Execute or Skip ↓ - Executor + Executor (if ALL stages pass) ``` **Benefits:** -- Clear separation: lifecycle vs. execution +- Clear separation: lifecycle vs. execution vs. decision - Intent Controller pattern for versioned operations -- Decision remains pure and testable -- Executor simply executes +- Decision authority clearly assigned to DecisionEngine +- 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. +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. +perspective, this is a single unified actor that coordinates intent lifecycle, +validation pipeline, and execution timing. --- @@ -435,23 +450,41 @@ perspective, this is a single unified actor. ### Responsibilities -- Executes rebalance when authorized +- 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 +- 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. --- diff --git a/docs/cache-state-machine.md b/docs/cache-state-machine.md index 5a452ec..0599922 100644 --- a/docs/cache-state-machine.md +++ b/docs/cache-state-machine.md @@ -92,16 +92,19 @@ The cache exists in one of three states: - **Trigger:** User request (any scenario) - **Actor:** User Path (reads), Rebalance Executor (writes) - **Sequence:** - 1. User Path cancels any pending rebalance - 2. User Path reads from cache or fetches from IDataSource (NO cache mutation) - 3. User Path returns data to user immediately - 4. User Path publishes intent with delivered data - 5. Rebalance Execution writes to cache (background) + 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. Rebalance Decision Engine validates necessity via multi-stage pipeline + 5. If validation confirms necessity, pending rebalance is cancelled and new execution scheduled + 6. If validation rejects (NoRebalanceRange containment, Desired==Current), no cancellation occurs + 7. Rebalance Execution writes to cache (background, only if validated) - **Mutation:** Performed by Rebalance Execution ONLY - User Path does NOT mutate cache, LastRequested, or NoRebalanceRange - - Rebalance Execution normalizes cache to DesiredCacheRange + - Rebalance Execution normalizes cache to DesiredCacheRange (only if validated) - **Concurrency:** User Path is read-only; no race conditions -- **Postcondition:** Cache logically enters `Rebalancing` state (background process active) +- **Cancellation:** Driven by validation results, not automatic +- **Postcondition:** Cache logically enters `Rebalancing` state (background process active, only if validated) ### T3: Rebalancing → Initialized (Rebalance Completion) - **Trigger:** Rebalance execution completes successfully @@ -119,16 +122,18 @@ The cache exists in one of three states: ### T4: Rebalancing → Initialized (User Request Cancels Rebalance) - **Trigger:** User request arrives during rebalance execution (Scenarios C1, C2) -- **Actor:** User Path (cancels), Rebalance Execution (yields) +- **Actor:** User Path (publishes intent), Rebalance Decision Engine (validates), Rebalance Execution (yields if cancelled) - **Sequence:** - 1. **User Path cancels ongoing/pending rebalance** (Invariant 0a) - 2. User Path reads from cache or fetches from IDataSource (NO cache mutation) - 3. User Path returns data to user immediately - 4. User Path publishes new intent with delivered data - 5. New rebalance execution begins (background) -- **Critical Rule:** User Path does NOT mutate cache; only cancels to prevent interference (Invariant 0) -- **Priority:** User Path always has priority, ensured via cancellation not mutation exclusion -- **Note:** Cancelled rebalance yields; new rebalance uses new intent's delivered data + 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. Rebalance Decision Engine validates necessity of new rebalance + 5. **If validation confirms necessity**, pending rebalance is cancelled and new execution scheduled + 6. **If validation rejects**, pending rebalance continues (no cancellation) + 7. Cancelled rebalance yields; new rebalance uses new intent's delivered data (if validated) +- **Critical Rule:** User Path does NOT mutate cache; validation determines if cancellation occurs +- **Priority:** User Path priority enforced via validation-driven cancellation, not automatic cancellation +- **Note:** Cancellation is mechanical coordination (single-writer), not a decision mechanism --- diff --git a/docs/component-map.md b/docs/component-map.md index 1c0f964..e21efca 100644 --- a/docs/component-map.md +++ b/docs/component-map.md @@ -8,8 +8,9 @@ This document provides a comprehensive map of all components in the Sliding Wind - Read/write patterns - Data flow diagrams - Thread safety model +- Rebalance Decision Model and multi-stage validation pipeline -**Last Updated**: February 8, 2026 +**Last Updated**: February 16, 2026 --- @@ -101,6 +102,80 @@ This document provides a comprehensive map of all components in the Sliding Wind --- +## 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: `ThresholdRebalancePolicy.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 vs CurrentCacheRange Equality** + - Component: `RebalanceExecutor.ExecuteAsync()` (early exit optimization) + - Check: Does computed DesiredCacheRange == CurrentCacheRange? + - Purpose: Avoid no-op mutations + - Result: Skip if cache already in optimal configuration + +**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; coordinates cancellation | No decision authority | +| **RebalanceScheduler** | Orchestrates validation pipeline timing | No decision authority | +| **RebalanceDecisionEngine** | **SOLE AUTHORITY** for necessity determination | **Yes - THE authority** | +| **ThresholdRebalancePolicy** | Stage 1 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. + +**Work Avoidance Mechanisms:** +- Stage 1: Avoid rebalance if current cache sufficient +- Stage 2: Avoid redundant rebalance if pending execution covers request +- Stage 3: Avoid no-op mutations if cache already optimal + +**Trade-offs:** +- ✅ Prevents thrashing and oscillation +- ✅ Reduces redundant I/O operations +- ✅ Improves system stability under rapid access pattern changes +- ⚠️ May delay cache optimization by debounce period + +--- + ## Detailed Component Catalog ### 1. Configuration & Data Transfer Types @@ -817,7 +892,7 @@ internal sealed class RebalanceDecisionEngine **Type**: Class (sealed) -**Role**: Pure Decision Logic +**Role**: Pure Decision Logic - **SOLE AUTHORITY for Rebalance Necessity Determination** **Fields** (all readonly, value types): - `ThresholdRebalancePolicy _policy` (struct, copied) @@ -829,14 +904,15 @@ public RebalanceDecision ShouldExecuteRebalance( Range requestedRange, Range? noRebalanceRange) { - // Decision Path D1: Check NoRebalanceRange (fast path) + // Stage 1: Current Cache NoRebalanceRange validation (fast path) if (noRebalanceRange.HasValue && !_policy.ShouldRebalance(noRebalanceRange.Value, requestedRange)) { return RebalanceDecision.Skip(); } - // Decision Path D2/D3: Compute DesiredCacheRange + // Stage 3: Compute DesiredCacheRange and return for execution + // (Stage 2 may be handled by cancellation timing optimization) var desiredRange = _planner.Plan(requestedRange); return RebalanceDecision.Execute(desiredRange); @@ -844,15 +920,22 @@ public RebalanceDecision ShouldExecuteRebalance( ``` **Characteristics**: -- ✅ **Pure function** (no side effects) +- ✅ **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 - ❌ 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()` - check NoRebalanceRange containment -- ◇ `_planner.Plan()` - compute DesiredCacheRange +- ◇ `_policy.ShouldRebalance()` - Stage 1: NoRebalanceRange containment check +- ◇ `_planner.Plan()` - Compute DesiredCacheRange for execution **Returns**: `RebalanceDecision` (struct) @@ -861,15 +944,18 @@ public RebalanceDecision ShouldExecuteRebalance( **Execution Context**: Background / ThreadPool **Responsibilities**: -- Evaluate if rebalance is needed -- Check NoRebalanceRange -- Compute DesiredCacheRange +- **THE authority** for rebalance necessity determination +- Evaluate if rebalance is needed through multi-stage validation +- Stage 1: Check NoRebalanceRange (fast path rejection) +- Stage 3: Compute DesiredCacheRange (execution parameters) +- Produce analytical decision (execute or skip) **Invariants Enforced**: -- 24: Decision path is purely analytical -- 25: Never mutates cache state -- 26: No rebalance if inside NoRebalanceRange -- 27: No rebalance if DesiredCacheRange == CurrentCacheRange +- 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 validation) +- D.28: No rebalance if DesiredCacheRange == CurrentCacheRange (Stage 3 validation) +- D.29: Rebalance executes ONLY if ALL stages confirm necessity --- diff --git a/docs/concurrency-model.md b/docs/concurrency-model.md index 7b6ae69..24e510a 100644 --- a/docs/concurrency-model.md +++ b/docs/concurrency-model.md @@ -24,7 +24,8 @@ The cache implements a **single-writer** concurrency model: - **One Writer:** Rebalance Execution Path exclusively - **Read-Only User Path:** User Path never mutates cache state -- **No Locks:** Coordination via cancellation, not mutual exclusion +- **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 @@ -41,18 +42,33 @@ All other components have read-only access to cache state. User Path safely reads cache state without locks because: - User Path never writes to cache (read-only guarantee) - Rebalance Execution performs atomic updates via `Rematerialize()` -- Cancellation ensures Rebalance Execution yields before User Path operations +- Cancellation ensures Rebalance Execution yields when new validated rebalance is scheduled - Single-writer eliminates race conditions +### Rebalance Validation vs Cancellation + +**Key Distinction:** +- **Rebalance Validation** = Decision mechanism (analytical, CPU-only, determines necessity) +- **Cancellation** = Coordination mechanism (mechanical, prevents concurrent executions) + +**User Path Priority Model:** +1. User Path publishes intent with delivered data +2. Rebalance Decision Engine validates necessity via multi-stage pipeline +3. If validation confirms necessity, pending rebalance is cancelled and new execution scheduled +4. If validation rejects (NoRebalanceRange containment, Desired==Current), no cancellation occurs + +**Cancellation does NOT drive decisions; validated rebalance necessity drives cancellation.** + ### Eventual Consistency Model Cache state converges to optimal configuration asynchronously: 1. **User Path** returns correct data immediately (from cache or IDataSource) 2. **User Path** publishes intent with delivered data -3. **Cache state** updates occur in background via Rebalance Execution -4. **Debounce delay** controls convergence timing -5. **User correctness** never depends on cache state being up-to-date +3. **Rebalance Decision Engine** validates rebalance necessity (multi-stage pipeline) +4. **Cache state** updates occur in background via Rebalance Execution (only if validated) +5. **Debounce delay** controls convergence timing +6. **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. @@ -94,10 +110,11 @@ 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 -- cancellation of obsolete work -- "latest access wins" semantics -- eventual stabilization +- 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**. diff --git a/docs/invariants.md b/docs/invariants.md index fe55230..b7cf588 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -1,4 +1,4 @@ -# Sliding Window Cache — System Invariants (Classified) +# Sliding Window Cache — System Invariants --- @@ -126,10 +126,11 @@ deterministic, race-free synchronization without polling or timing dependencies. - *Enforced by*: Component ownership, cancellation protocol - *Architecture*: User Path cancels rebalance; rebalance checks cancellation -**A.0a** 🟢 **[Behavioral — Test: `Invariant_A_0a_UserRequestCancelsRebalance`]** Every User Request **MUST cancel** any ongoing or pending Rebalance Execution to ensure rebalance doesn't interfere with User Path data assembly. +**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. - *Observable via*: DEBUG instrumentation counters tracking cancellation -- *Test verifies*: Cancellation counter increments when new request arrives -- *Note*: Cancellation ensures User Path priority, not mutation safety (User Path is read-only) +- *Test verifies*: Cancellation counter increments when new request arrives and rebalance validation requires rescheduling +- *Clarification*: Cancellation is a mechanical coordination tool (single-writer architecture), not a decision mechanism. Rebalance necessity is determined by the Rebalance Decision Engine through analytical validation (NoRebalanceRange containment, DesiredRange vs CurrentRange comparison). User requests do NOT automatically trigger cancellation; validated rebalance necessity triggers cancellation + rescheduling. +- *Note*: Cancellation prevents concurrent rebalance executions, not duplicate decision-making ### A.2 User-Facing Guarantees @@ -221,9 +222,10 @@ deterministic, race-free synchronization without polling or timing dependencies. - *Observable via*: DEBUG counters showing intent published/cancelled - *Test verifies*: Multiple rapid requests show N published, N-1 cancelled -**C.18** 🟢 **[Behavioral — Test: `Invariant_C18_PreviousIntentBecomesObsolete`]** Any previously created rebalance intent is **considered obsolete** after a new intent is generated. +**C.18** 🟢 **[Behavioral — Test: `Invariant_C18_PreviousIntentBecomesObsolete`]** 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**. - *Observable via*: DEBUG counters tracking intent lifecycle - *Test verifies*: Old intent cancelled when new one published +- *Clarification*: Intents are access signals, not commands. An intent represents "user accessed this range," not "must execute rebalance." Execution decisions are governed by the Rebalance Decision Engine's analytical validation (Stage 1: Current Cache NoRebalanceRange check, Stage 2: Pending Desired Cache NoRebalanceRange check if applicable, Stage 3: DesiredCacheRange vs CurrentCacheRange equality check). Previously created intents may be superseded or cancelled, but the decision to execute is always based on current validation state, not intent age. **C.19** 🔵 **[Architectural]** Any rebalance execution can be **cancelled or have its results ignored**. - *Enforced by*: `CancellationToken` passed through execution pipeline @@ -266,6 +268,67 @@ deterministic, race-free synchronization without polling or timing dependencies. ## D. Rebalance Decision Path Invariants +### D.0 Rebalance Decision Model Overview + +The system uses a **multi-stage rebalance decision pipeline**, not a cancellation policy. Rebalance necessity is determined entirely in the User Path context via CPU-only analytical validation performed by the Rebalance Decision Engine. + +#### Key Conceptual Distinctions + +**Rebalance Decision vs Cancellation:** +- **Rebalance Decision** = Analytical validation determining if rebalance is necessary (decision mechanism) +- **Cancellation** = Mechanical coordination tool ensuring single-writer architecture (coordination mechanism) +- Cancellation is NOT a decision mechanism; it prevents concurrent executions, not duplicate decision-making + +**Intent Semantics:** +- Intent represents **observed access**, not mandatory work +- Intent = "user accessed this range" (signal), NOT "must execute rebalance" (command) +- Rebalance may be skipped because: + - NoRebalanceRange containment (Stage 1 validation) + - Pending rebalance already covers range (Stage 2 validation, anti-thrashing) + - Desired == Current range (Stage 3 validation) + - Intent superseded or cancelled before execution begins + +#### Multi-Stage Decision Pipeline + +The Rebalance Decision Engine validates rebalance necessity through three sequential stages: + +**Stage 1 — Current Cache NoRebalanceRange Validation** +- **Purpose**: Fast-path check against current cache state +- **Logic**: If RequestedRange ⊆ NoRebalanceRange(CurrentCacheRange), skip rebalance +- **Rationale**: Current cache already provides sufficient buffer around request +- **Performance**: O(1) range containment check, no computation needed + +**Stage 2 — Pending Desired Cache NoRebalanceRange Validation** (if pending rebalance exists) +- **Purpose**: Anti-thrashing mechanism preventing oscillation +- **Logic**: If RequestedRange ⊆ NoRebalanceRange(PendingDesiredCacheRange), skip rebalance +- **Rationale**: Pending rebalance execution will satisfy this request when it completes +- **Note**: This stage is conceptually part of the decision model but may be implemented as cancellation optimization in current architecture + +**Stage 3 — DesiredCacheRange vs CurrentCacheRange Equality Check** +- **Purpose**: Avoid no-op rebalance operations +- **Logic**: Compute DesiredCacheRange from RequestedRange + config; if DesiredCacheRange == CurrentCacheRange, skip rebalance +- **Rationale**: Cache is already in optimal configuration for this request +- **Performance**: Requires computing desired range but avoids I/O + +#### Decision Authority + +- **Rebalance Decision Engine** = Sole authority for rebalance necessity determination +- **User Path** = Read-only with respect to cache state; publishes intents with delivered data +- **Cancellation** = Coordination tool for single-writer architecture, NOT decision mechanism +- **Rebalance Execution** = Mechanically simple; assumes decision layer already validated necessity + +#### System Stability Principle + +The system prioritizes **decision correctness and work avoidance** over aggressive rebalance responsiveness. + +**Meaning:** +- Avoid thrashing (redundant rebalance operations) +- Avoid redundant I/O (fetching data already in cache or pending) +- Avoid oscillating cache geometry (constantly resizing based on rapid access pattern changes) +- Accept temporary cache inefficiency if background rebalance will correct it + +**Trade-off:** Slight delay in cache optimization vs. system stability and resource efficiency + **D.25** 🔵 **[Architectural]** The Rebalance Decision Path is **purely analytical** and has **no side effects**. - *Enforced by*: `RebalanceDecisionEngine` is stateless, uses value types - *Architecture*: Pure function: inputs → decision (no I/O, no mutations) @@ -283,9 +346,14 @@ deterministic, race-free synchronization without polling or timing dependencies. - *Test verifies*: Repeated request with same range increments skip counter - *Implementation*: Early exit in `RebalanceExecutor.ExecuteAsync` before I/O operations -**D.29** 🔵 **[Architectural]** Rebalance execution is triggered **only if the Decision Path confirms necessity**. +**D.29** 🔵 **[Architectural]** Rebalance execution is triggered **only if ALL stages of the multi-stage decision pipeline confirm necessity**. - *Enforced by*: `RebalanceScheduler` checks decision before calling executor - *Architecture*: Decision result gates execution +- *Decision Pipeline Stages*: + 1. **Stage 1 — Current Cache NoRebalanceRange Validation**: If RequestedRange is contained within the NoRebalanceRange computed from CurrentCacheRange, skip rebalance (fast path) + 2. **Stage 2 — Pending Desired Cache NoRebalanceRange Validation** (if pending rebalance exists): Validate against the NoRebalanceRange computed from the pending DesiredCacheRange to prevent thrashing/oscillation + 3. **Stage 3 — DesiredCacheRange vs CurrentCacheRange Equality Check**: If computed DesiredCacheRange equals CurrentCacheRange, skip rebalance (no change needed) +- *Critical Principle*: Rebalance executes ONLY if ALL stages pass validation. This multi-stage approach prevents unnecessary I/O, cache thrashing, and oscillating cache geometry while ensuring the system converges to optimal configuration. --- diff --git a/docs/scenario-model.md b/docs/scenario-model.md index bf6b147..c48cfd1 100644 --- a/docs/scenario-model.md +++ b/docs/scenario-model.md @@ -205,7 +205,7 @@ This path is always triggered by the User Path. --- -## Decision Scenario D1 — Rebalance Blocked by NoRebalanceRange +## Decision Scenario D1 — Rebalance Blocked by NoRebalanceRange (Stage 1 Validation) ### Condition @@ -213,46 +213,91 @@ This path is always triggered by the User Path. ### Sequence -1. Decision path starts -2. NoRebalanceRange is checked -3. Fast return — rebalance is skipped +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 +## Decision Scenario D2 — Rebalance Allowed but Desired Equals Current (Stage 3 Validation) ### Condition -- `NoRebalanceRange.Contains(RequestedRange) == false` +- `NoRebalanceRange.Contains(RequestedRange) == false` (Stage 1 passed) - `DesiredCacheRange == CurrentCacheRange` ### Sequence 1. Decision path starts -2. NoRebalanceRange is checked — no fast return -3. DesiredCacheRange is computed -4. Desired equals Current -5. Fast return — rebalance is skipped +2. Stage 1 validation: NoRebalanceRange check — no fast return +3. Stage 3 validation: DesiredCacheRange is computed from RequestedRange + config +4. 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 +## Decision Scenario D3 — Rebalance Required (All Validation Stages Passed) ### Condition -- `NoRebalanceRange.Contains(RequestedRange) == false` -- `DesiredCacheRange != CurrentCacheRange` +- `NoRebalanceRange.Contains(RequestedRange) == false` (Stage 1 passed) +- `DesiredCacheRange != CurrentCacheRange` (Stage 3 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 validation: DesiredCacheRange is computed from RequestedRange + config +5. 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. NoRebalanceRange is checked — no fast return -3. DesiredCacheRange is computed -4. Desired differs from Current -5. Rebalance necessity is confirmed -6. Execution Path is started asynchronously +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**: This stage is conceptually part of the decision model. Current implementation +may use cancellation timing as an optimization, but the principle remains: avoid +redundant rebalance operations when pending execution will satisfy the request. --- diff --git a/tests/SlidingWindowCache.Invariants.Tests/README.md b/tests/SlidingWindowCache.Invariants.Tests/README.md index bf0e14c..419f3d3 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/README.md +++ b/tests/SlidingWindowCache.Invariants.Tests/README.md @@ -3,7 +3,11 @@ ## Overview Comprehensive unit test suite for the WindowCache library verifying system invariants through the public API using DEBUG-only instrumentation counters. -**Architecture**: Single-Writer Model (User Path is read-only, Rebalance Execution is sole writer) +**Architecture**: Single-Writer Model with Multi-Stage Rebalance Validation +- User Path is read-only (never mutates cache) +- Rebalance Execution is sole writer (single-writer architecture) +- Rebalance necessity determined by multi-stage analytical validation pipeline +- Cancellation is coordination tool (prevents concurrent executions), not decision mechanism **Test Statistics**: - **Total Tests**: 27 automated tests (all passing) @@ -111,8 +115,8 @@ not actual cache mutations. Actual mutations only occur in Rebalance Execution v **C. Rebalance Intent & Temporal (4 tests)** - C.17: At most one active intent -- C.18: Previous intent becomes obsolete -- C.24: Intent doesn't guarantee execution (opportunistic) +- C.18: Previous intent becomes logically superseded (execution relevance determined by multi-stage validation) +- C.24: Intent doesn't guarantee execution (opportunistic, validation-driven) - C.23: System stabilizes under load **D. Rebalance Decision Path (2 tests + TODOs)** From 9b37106a70141f5753f341b2aacd27a85c760304 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 00:05:10 +0100 Subject: [PATCH 02/23] feat: improve rebalance decision reasoning and introduce stability validation stages, enhancing decision-making clarity and execution control, solving intent trashing issue --- .../Rebalance/Decision/RebalanceDecision.cs | 61 ++++++++++-- .../Decision/RebalanceDecisionEngine.cs | 71 ++++++++++---- .../Rebalance/Execution/RebalanceExecutor.cs | 34 +++---- .../Core/Rebalance/Intent/IntentController.cs | 92 +++++++++++++++---- .../Core/Rebalance/Intent/PendingRebalance.cs | 49 ++++++++++ .../Rebalance/Intent/RebalanceScheduler.cs | 63 ++++++------- src/SlidingWindowCache/Public/WindowCache.cs | 2 +- 7 files changed, 277 insertions(+), 95 deletions(-) create mode 100644 src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecision.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecision.cs index 3005244..b9a0f02 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecision.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecision.cs @@ -2,6 +2,32 @@ 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. /// @@ -12,28 +38,49 @@ internal readonly struct RebalanceDecision /// /// Gets a value indicating whether rebalance execution should proceed. /// - public bool ShouldExecute { get; } + public bool ShouldSchedule { get; } /// /// Gets the desired cache range if execution is allowed, otherwise null. /// public Range? DesiredRange { get; } - private RebalanceDecision(bool shouldExecute, Range? desiredRange) + /// + /// Gets the desired no-rebalance range for the target cache state, or null if skipping. + /// + public Range? DesiredNoRebalanceRange { get; } + + /// + /// Gets the reason for this decision outcome. + /// + public RebalanceReason Reason { get; } + + private RebalanceDecision( + bool shouldSchedule, + Range? desiredRange, + Range? desiredNoRebalanceRange, + RebalanceReason reason) { - ShouldExecute = shouldExecute; + ShouldSchedule = shouldSchedule; DesiredRange = desiredRange; + DesiredNoRebalanceRange = desiredNoRebalanceRange; + Reason = reason; } /// - /// Creates a decision to skip rebalance execution. + /// Creates a decision to skip rebalance execution with the specified reason. /// - public static RebalanceDecision Skip() => new(false, null); + /// The reason for skipping rebalance. + public static RebalanceDecision Skip(RebalanceReason reason) => + new(false, null, null, reason); /// /// Creates a decision to execute rebalance with the specified desired range. /// /// The target cache range for rebalancing. - public static RebalanceDecision Execute(Range desiredRange) => - new(true, desiredRange); + /// The no-rebalance range for the target cache state. + public static RebalanceDecision Execute( + Range desiredRange, + Range? desiredNoRebalanceRange) => + new(true, desiredRange, desiredNoRebalanceRange, RebalanceReason.RebalanceRequired); } diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs index 5fa6344..5681e38 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs @@ -2,6 +2,7 @@ using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Planning; using SlidingWindowCache.Core.Rebalance.Intent; +using SlidingWindowCache.Core.State; namespace SlidingWindowCache.Core.Rebalance.Decision; @@ -14,8 +15,16 @@ namespace SlidingWindowCache.Core.Rebalance.Decision; /// The type representing the domain of the ranges. /// /// Execution Context: Background / ThreadPool -/// Visibility: Not visible to User Path, invoked only by RebalanceScheduler +/// Visibility: Not visible to User Path, invoked only by IntentController /// Characteristics: Pure, deterministic, side-effect free +/// Decision Pipeline (5 Stages): +/// +/// Stage 1: Current Cache NoRebalanceRange stability check (fast path) +/// Stage 2: Pending Rebalance NoRebalanceRange stability check (anti-thrashing) +/// Stage 3: Compute DesiredCacheRange and DesiredNoRebalanceRange +/// Stage 4: Equality short-circuit (DesiredRange == CurrentRange) +/// Stage 5: Rebalance required - return full decision +/// /// internal sealed class RebalanceDecisionEngine where TRange : IComparable @@ -33,29 +42,57 @@ public RebalanceDecisionEngine( } /// - /// Evaluates whether rebalance execution should proceed based on the requested range - /// and current cache state. + /// Evaluates whether rebalance execution should proceed based on multi-stage validation. + /// This is the SOLE AUTHORITY for rebalance necessity determination. /// /// The range requested by the user. - /// The range within which no rebalancing should occur. - /// A decision indicating whether to execute rebalance and the desired range if applicable. - public RebalanceDecision ShouldExecuteRebalance( + /// The current cache state snapshot. + /// The pending rebalance state, if any. + /// A decision indicating whether to schedule rebalance with explicit reasoning. + /// + /// Multi-Stage Validation Pipeline: + /// + /// Each stage acts as a guard, potentially short-circuiting execution. + /// All stages must confirm necessity before rebalance is scheduled. + /// + /// + public RebalanceDecision Evaluate( Range requestedRange, - Range? noRebalanceRange) + CacheState currentCacheState, + PendingRebalance? pendingRebalance) { - // Decision Path D1: Check NoRebalanceRange (fast path) - // If RequestedRange is fully contained within NoRebalanceRange, skip rebalancing - if (noRebalanceRange.HasValue && - !_policy.ShouldRebalance(noRebalanceRange.Value, requestedRange)) + // 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)) { - return RebalanceDecision.Skip(); + return RebalanceDecision.Skip(RebalanceReason.WithinCurrentNoRebalanceRange); } - // Decision Path D2/D3: Compute DesiredCacheRange - var desiredRange = _planner.Plan(requestedRange); + // 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 (pendingRebalance?.DesiredNoRebalanceRange != null && + !_policy.ShouldRebalance(pendingRebalance.DesiredNoRebalanceRange.Value, requestedRange)) + { + return RebalanceDecision.Skip(RebalanceReason.WithinPendingNoRebalanceRange); + } + + // Stage 3: Desired Range Computation + // Compute the target cache geometry using policy + var desiredCacheRange = _planner.Plan(requestedRange); + var desiredNoRebalanceRange = _policy.GetNoRebalanceRange(desiredCacheRange); + + // 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); + } - // Decision is to execute - IntentManager will check if desiredRange differs from current - // before actually invoking the executor - return RebalanceDecision.Execute(desiredRange); + // Stage 5: Rebalance Required + // All validation stages passed - rebalance is necessary + return RebalanceDecision.Execute(desiredCacheRange, desiredNoRebalanceRange); } } diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs index 1dbc78a..b867c62 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs @@ -25,19 +25,16 @@ internal sealed class RebalanceExecutor { private readonly CacheState _state; private readonly CacheDataExtensionService _cacheExtensionService; - private readonly ThresholdRebalancePolicy _rebalancePolicy; private readonly ICacheDiagnostics _cacheDiagnostics; public RebalanceExecutor( CacheState state, CacheDataExtensionService cacheExtensionService, - ThresholdRebalancePolicy rebalancePolicy, ICacheDiagnostics cacheDiagnostics ) { _state = state; _cacheExtensionService = cacheExtensionService; - _rebalancePolicy = rebalancePolicy; _cacheDiagnostics = cacheDiagnostics; } @@ -47,6 +44,7 @@ ICacheDiagnostics cacheDiagnostics /// /// The intent with data that was actually assembled in UserPath and the requested range. /// The target cache range to normalize to. + /// The no-rebalance range for the target cache state. /// Cancellation token to support cancellation at all stages. /// A task representing the asynchronous rebalance operation. /// @@ -62,27 +60,21 @@ ICacheDiagnostics cacheDiagnostics /// The delivered data from the intent is used as the authoritative base source, /// avoiding duplicate fetches and ensuring consistency with what the user received. /// + /// + /// This executor is intentionally simple - no analytical decisions, no necessity checks. + /// Decision logic has been validated by DecisionEngine before invocation. + /// /// public async Task ExecuteAsync( Intent intent, Range desiredRange, + Range? desiredNoRebalanceRange, CancellationToken cancellationToken) { // Use delivered data as the base - this is what the user received var baseRangeData = intent.AvailableRangeData; - // Check if desired range equals delivered data range (Decision Path D2) - // This is a final check before expensive I/O operations - if (baseRangeData.Range == desiredRange) - { - _cacheDiagnostics.RebalanceSkippedSameRange(); - // Even though ranges match, we still need to update cache state since - // User Path no longer writes to cache. Use delivered data directly. - UpdateCacheState(baseRangeData, intent.RequestedRange); - return; - } - - // Cancellation check after decision but before expensive I/O + // Cancellation check before expensive I/O // Satisfies Invariant 34a: "Rebalance Execution MUST yield to User Path requests immediately" cancellationToken.ThrowIfCancellationRequested(); @@ -102,7 +94,7 @@ public async Task ExecuteAsync( cancellationToken.ThrowIfCancellationRequested(); // Phase 3: Apply cache state mutations - UpdateCacheState(baseRangeData, intent.RequestedRange); + UpdateCacheState(baseRangeData, intent.RequestedRange, desiredNoRebalanceRange); _cacheDiagnostics.RebalanceExecutionCompleted(); } @@ -113,7 +105,11 @@ public async Task ExecuteAsync( /// /// The normalized data to write to cache. /// The original range requested by the user, used to update LastRequested field. - private void UpdateCacheState(RangeData normalizedData, Range requestedRange) + /// 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 @@ -123,8 +119,8 @@ private void UpdateCacheState(RangeData normalizedData, // SINGLE-WRITER: Only Rebalance Execution writes to LastRequested _state.LastRequested = requestedRange; - // Phase 3: Update the no-rebalance range to prevent unnecessary rebalancing + // Phase 3: Update the no-rebalance range using pre-computed value from DecisionEngine // SINGLE-WRITER: Only Rebalance Execution writes to NoRebalanceRange - _state.NoRebalanceRange = _rebalancePolicy.GetNoRebalanceRange(_state.Cache.Range); + _state.NoRebalanceRange = desiredNoRebalanceRange; } } \ No newline at end of file diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index 2bfc257..4bf9fe4 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -50,6 +50,8 @@ internal sealed class IntentController where TDomain : IRangeDomain { private readonly RebalanceScheduler _scheduler; + private readonly RebalanceDecisionEngine _decisionEngine; + private readonly CacheState _state; private readonly ICacheDiagnostics _cacheDiagnostics; /// @@ -58,6 +60,12 @@ internal sealed class IntentController /// private CancellationTokenSource? _currentIntentCts; + /// + /// Snapshot of the pending rebalance's target state, used for Stage 2 stability validation. + /// Updated atomically when a new rebalance is scheduled. + /// + private PendingRebalance? _pendingRebalance; + /// /// Initializes a new instance of the class. /// @@ -78,11 +86,11 @@ public IntentController( ICacheDiagnostics cacheDiagnostics ) { + _state = state; + _decisionEngine = decisionEngine; _cacheDiagnostics = cacheDiagnostics; // Compose with scheduler component _scheduler = new RebalanceScheduler( - state, - decisionEngine, executor, debounceDelay, cacheDiagnostics @@ -126,6 +134,9 @@ public void CancelPendingRebalance() cancellationTokenSource.Cancel(); cancellationTokenSource.Dispose(); + + // Clear pending rebalance snapshot since no rebalance is scheduled + Volatile.Write(ref _pendingRebalance, null); } /// @@ -136,9 +147,10 @@ public void CancelPendingRebalance() /// /// /// Every user access produces a rebalance intent. This method implements the - /// Intent Controller pattern by: + /// decision-driven Intent Controller pattern by: /// - /// Invalidating the previous intent (if any) + /// Evaluating rebalance necessity via DecisionEngine + /// Conditionally canceling previous intent only if new rebalance should schedule /// Creating a new intent with unique identity (CancellationTokenSource) /// Delegating to scheduler for debounce and execution /// @@ -149,38 +161,86 @@ public void CancelPendingRebalance() /// avoiding duplicate fetches and ensuring consistency. /// /// - /// This implements Invariant C.18: "Any previously created rebalance intent is obsolete - /// after a new intent is generated." + /// This implements the decision-driven model: Intent → Decision → Scheduling → Execution. + /// No implicit triggers, no blind cancellations, no decision leakage across components. /// /// - /// Responsibility separation: Intent lifecycle management is handled here, - /// while scheduling/execution is delegated to RebalanceScheduler. + /// Responsibility separation: Decision logic in DecisionEngine, intent lifecycle here, + /// scheduling/execution delegated to RebalanceScheduler. /// /// public void PublishIntent(Intent intent) { + // Step 1: Evaluate rebalance necessity (Decision Engine is SOLE AUTHORITY) + // Capture pending rebalance state for Stage 2 validation (atomic read) + var pendingSnapshot = Volatile.Read(ref _pendingRebalance); + + var decision = _decisionEngine.Evaluate( + requestedRange: intent.RequestedRange, + currentCacheState: _state, + pendingRebalance: pendingSnapshot + ); + + // Track skip reason for observability + RecordReason(decision.Reason); + + // Step 2: If decision says skip, publish diagnostic and return early + if (!decision.ShouldSchedule) + { + return; + } + + // Step 3: Decision confirmed rebalance is necessary - create new intent identity var newCts = new CancellationTokenSource(); var intentToken = newCts.Token; - // SAFE PATH - - // Atomically replace the current intent with the new one and capture the old one for cancellation + // Step 4: Cancel pending rebalance (mechanical safeguard for state transition) + // This is NOT a blind cancellation - it only happens when DecisionEngine validated necessity var oldCts = Interlocked.Exchange(ref _currentIntentCts, newCts); - - // Invalidate previous intent (Invariant C.18: "Any previously created rebalance intent is obsolete") if (oldCts is not null) { oldCts.Cancel(); oldCts.Dispose(); } - // SAFE PATH END - // Delegate to scheduler for debounce and execution - // The scheduler owns timing, debounce, and pipeline orchestration - _scheduler.ScheduleRebalance(intent, intentToken); + // Step 5: Update pending rebalance snapshot for next Stage 2 validation + // todo make this object as a return result of the _scheduler.ScheduleRebalance(). Let's scheduler be a keeper of rebalance execution infrastructure like threading, debounle delay, catching and handling exceptions, cancellations + var newPending = new PendingRebalance( + decision.DesiredRange!.Value, + decision.DesiredNoRebalanceRange + ); + Volatile.Write(ref _pendingRebalance, newPending); + + // Step 6: Delegate to scheduler with decision for debounce and execution + _scheduler.ScheduleRebalance(intent, decision, intentToken); _cacheDiagnostics.RebalanceIntentPublished(); } + /// + /// Records the skip reason for diagnostic and observability purposes. + /// Maps decision reasons to diagnostic events. + /// + private void RecordReason(RebalanceReason reason) + { + switch (reason) + { + case RebalanceReason.WithinCurrentNoRebalanceRange: + // todo add specific log for this reason + case RebalanceReason.WithinPendingNoRebalanceRange: + _cacheDiagnostics.RebalanceSkippedNoRebalanceRange(); + break; + case RebalanceReason.DesiredEqualsCurrent: + _cacheDiagnostics.RebalanceSkippedSameRange(); + break; + case RebalanceReason.RebalanceRequired: + // todo add specific log for this reason + break; + default: + throw new ArgumentOutOfRangeException(nameof(reason), reason, null); + } + } + /// /// Waits for the latest scheduled rebalance background Task to complete. /// Provides deterministic synchronization for infrastructure scenarios. diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs new file mode 100644 index 0000000..0a656f7 --- /dev/null +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs @@ -0,0 +1,49 @@ +using Intervals.NET; + +namespace SlidingWindowCache.Core.Rebalance.Intent; + +/// +/// Represents an immutable snapshot of a pending rebalance operation's target state. +/// Used by the decision engine to evaluate stability without coupling to execution details. +/// +/// The type representing the range boundaries. +/// +/// Architectural Role: +/// +/// This class provides a stable, immutable view of a scheduled rebalance's intended outcome, +/// allowing the decision engine to perform Stage 2 anti-thrashing validation (pending desired +/// cache stability check) without creating dependencies on scheduler or executor internals. +/// +/// Lifetime: +/// +/// Created when a rebalance is scheduled, captured atomically by IntentController, +/// and passed to DecisionEngine for subsequent decision evaluations. +/// +/// +/// todo add ct here in ordr to call .Cancel() on this object - cancels actually pending rebalance. I guess it will be more DDD like +/// todo also define rebalance execution task property here, so using it we can wait for idle in blocking rebalance scenarious. +internal sealed class PendingRebalance + where TRange : IComparable +{ + /// + /// Gets the desired cache range that the pending rebalance will establish. + /// + public Range DesiredRange { get; } + + /// + /// Gets the no-rebalance range that will be active after the pending rebalance completes. + /// May be null if not yet computed or if rebalance was skipped. + /// + public Range? DesiredNoRebalanceRange { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The desired cache range for the pending rebalance. + /// The no-rebalance range for the target state. + public PendingRebalance(Range desiredRange, Range? desiredNoRebalanceRange) + { + DesiredRange = desiredRange; + DesiredNoRebalanceRange = desiredNoRebalanceRange; + } +} diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs index 0011b8b..2ae7db6 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs @@ -1,7 +1,6 @@ using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.Rebalance.Execution; -using SlidingWindowCache.Core.State; using SlidingWindowCache.Infrastructure.Instrumentation; namespace SlidingWindowCache.Core.Rebalance.Intent; @@ -39,8 +38,6 @@ internal sealed class RebalanceScheduler where TRange : IComparable where TDomain : IRangeDomain { - private readonly CacheState _state; - private readonly RebalanceDecisionEngine _decisionEngine; private readonly RebalanceExecutor _executor; private readonly TimeSpan _debounceDelay; private readonly ICacheDiagnostics _cacheDiagnostics; @@ -56,21 +53,15 @@ internal sealed class RebalanceScheduler /// /// Initializes a new instance of the class. /// - /// The cache state. - /// The decision engine for rebalance logic. /// The executor for performing rebalance operations. /// The debounce delay before executing rebalance. /// The diagnostics interface for recording rebalance-related metrics and events. public RebalanceScheduler( - CacheState state, - RebalanceDecisionEngine decisionEngine, RebalanceExecutor executor, TimeSpan debounceDelay, ICacheDiagnostics cacheDiagnostics ) { - _state = state; - _decisionEngine = decisionEngine; _executor = executor; _debounceDelay = debounceDelay; _cacheDiagnostics = cacheDiagnostics; @@ -81,7 +72,8 @@ ICacheDiagnostics cacheDiagnostics /// Checks intent validity before starting execution. /// /// The intent with data that was actually assembled in UserPath and the requested range. - /// Cancellation token for this specific intent (owned by IntentManager). + /// The pre-validated rebalance decision from DecisionEngine. + /// Cancellation token for this specific intent (owned by IntentController). /// /// /// This method is fire-and-forget. It schedules execution in the background thread pool @@ -92,8 +84,15 @@ ICacheDiagnostics cacheDiagnostics /// When a new intent arrives, the Intent Controller cancels the previous token, causing /// any pending or executing rebalance to be cancelled. /// + /// + /// Decision logic has already been evaluated by IntentController. This method performs + /// mechanical scheduling and execution orchestration only. + /// /// - public void ScheduleRebalance(Intent intent, CancellationToken intentToken) + public void ScheduleRebalance( + Intent intent, + RebalanceDecision decision, + CancellationToken intentToken) { // Fire-and-forget: schedule execution in background thread pool // Fixing ambiguous invocation by explicitly specifying the type for Task.Run @@ -102,7 +101,7 @@ public void ScheduleRebalance(Intent intent, Cancellatio try { await ExecuteAfterAsync( - executePipelineAsync: () => ExecutePipelineAsync(intent, intentToken), + executePipelineAsync: () => ExecutePipelineAsync(intent, decision, intentToken), intentToken: intentToken ); } @@ -159,22 +158,27 @@ private async Task ExecuteAfterAsync(Func executePipelineAsync, Cancellati } /// - /// Executes the decision-execution pipeline in the background. + /// Executes the mechanical rebalance pipeline in the background. /// /// The intent with data that was actually assembled in UserPath and the requested range. + /// The pre-validated rebalance decision with target ranges. /// Cancellation token to support cancellation. /// /// Pipeline Flow: /// /// Check if intent is still valid (cancellation check) - /// Invoke DecisionEngine to determine if rebalance is needed - /// If needed, invoke Executor to perform rebalance using delivered data + /// Invoke Executor with decision parameters (DesiredRange, DesiredNoRebalanceRange) /// + /// + /// Decision logic has already been evaluated. This method performs mechanical execution only. + /// /// - private async Task ExecutePipelineAsync(Intent intent, + private async Task ExecutePipelineAsync( + Intent intent, + RebalanceDecision decision, CancellationToken cancellationToken) { - // Final cancellation check before decision logic + // Final cancellation check before execution // Ensures we don't do work for an obsolete intent if (cancellationToken.IsCancellationRequested) { @@ -182,28 +186,17 @@ private async Task ExecutePipelineAsync(Intent intent, return; } - // Step 1: Invoke DecisionEngine (pure decision logic) - // This checks NoRebalanceRange and computes DesiredCacheRange - var decision = _decisionEngine.ShouldExecuteRebalance( - requestedRange: intent.RequestedRange, - noRebalanceRange: _state.NoRebalanceRange - ); - - // Step 2: If decision says skip, return early (no-op) - if (!decision.ShouldExecute) - { - _cacheDiagnostics.RebalanceSkippedNoRebalanceRange(); - return; - } - _cacheDiagnostics.RebalanceExecutionStarted(); - // Step 3: If execution is allowed, invoke Executor with delivered data - // The executor will use delivered data as authoritative source, merge with existing cache, - // expand to desired range, trim excess, and update cache state + // Invoke Executor with pre-validated decision parameters + // Executor performs mechanical mutations without decision logic try { - await _executor.ExecuteAsync(intent, decision.DesiredRange!.Value, cancellationToken); + await _executor.ExecuteAsync( + intent, + decision.DesiredRange!.Value, + decision.DesiredNoRebalanceRange, + cancellationToken); } catch (OperationCanceledException) { diff --git a/src/SlidingWindowCache/Public/WindowCache.cs b/src/SlidingWindowCache/Public/WindowCache.cs index 6f0605e..8fa38b9 100644 --- a/src/SlidingWindowCache/Public/WindowCache.cs +++ b/src/SlidingWindowCache/Public/WindowCache.cs @@ -124,7 +124,7 @@ public WindowCache( var decisionEngine = new RebalanceDecisionEngine(rebalancePolicy, rangePlanner); var executor = - new RebalanceExecutor(state, cacheFetcher, rebalancePolicy, cacheDiagnostics); + new RebalanceExecutor(state, cacheFetcher, cacheDiagnostics); // IntentController composes with Execution Scheduler to form the Rebalance Intent Manager actor _intentController = new IntentController( From 6bebc2cfb4cdb4c1f736a9431db57d0a25b183c1 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 02:27:36 +0100 Subject: [PATCH 03/23] feat: refactor intent handling and diagnostics for improved DDD alignment, encapsulating cancellation and execution logic within PendingRebalance, and enhancing diagnostics for rebalance events. --- .../Core/Rebalance/Intent/IntentController.cs | 142 +++++++++------ .../Core/Rebalance/Intent/PendingRebalance.cs | 46 ++++- .../Rebalance/Intent/RebalanceScheduler.cs | 109 +++-------- .../Core/UserPath/UserRequestHandler.cs | 4 - .../EventCounterCacheDiagnostics.cs | 23 ++- .../Instrumentation/ICacheDiagnostics.cs | 63 ++++++- .../Instrumentation/NoOpDiagnostics.cs | 12 +- .../RebalanceExceptionHandlingTests.cs | 111 +++++++++--- .../TestInfrastructure/TestHelpers.cs | 97 +++++++++- .../WindowCacheInvariantTests.cs | 171 +++++++++++++++--- .../Instrumentation/NoOpDiagnosticsTests.cs | 3 +- 11 files changed, 570 insertions(+), 211 deletions(-) diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index 4bf9fe4..51b2cce 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -26,12 +26,14 @@ namespace SlidingWindowCache.Core.Rebalance.Intent; /// Intent Controller Responsibilities: /// /// Receives rebalance intents on every user access -/// Owns intent identity and versioning (CancellationTokenSource) -/// Cancels and invalidates obsolete intents +/// Evaluates rebalance necessity via DecisionEngine +/// Cancels obsolete pending rebalances via PendingRebalance.Cancel() +/// Delegates scheduling to RebalanceScheduler /// Exposes cancellation interface to User Path /// /// Explicit Non-Responsibilities: /// +/// ❌ Does NOT manage CancellationTokenSource lifecycle (Scheduler's responsibility) /// ❌ Does NOT perform scheduling or timing logic (Scheduler's responsibility) /// ❌ Does NOT decide whether rebalance is logically required (DecisionEngine's job) /// ❌ Does NOT orchestrate execution pipeline (Scheduler's responsibility) @@ -54,12 +56,6 @@ internal sealed class IntentController private readonly CacheState _state; private readonly ICacheDiagnostics _cacheDiagnostics; - /// - /// The current rebalance cancellation token source. - /// Represents the identity and lifecycle of the latest rebalance intent. - /// - private CancellationTokenSource? _currentIntentCts; - /// /// Snapshot of the pending rebalance's target state, used for Stage 2 stability validation. /// Updated atomically when a new rebalance is scheduled. @@ -111,29 +107,24 @@ ICacheDiagnostics cacheDiagnostics /// User Path never waits for rebalance to fully complete - it just ensures /// the cancellation signal is sent before proceeding with its own mutations. /// - /// Lock-Free Implementation: + /// DDD-Style Cancellation: /// - /// Uses atomic exchange to clear the current intent - /// without requiring locks. This ensures thread-safety and prevents race conditions - /// while maintaining non-blocking semantics. + /// Uses the PendingRebalance domain object's Cancel() method rather than directly + /// managing CancellationTokenSource. This provides better encapsulation and aligns + /// with domain-driven design principles. /// /// public void CancelPendingRebalance() { - var cancellationTokenSource = Interlocked.Exchange(ref _currentIntentCts, null); + var pending = Volatile.Read(ref _pendingRebalance); - if (cancellationTokenSource == null) + if (pending == null) { return; } - if (cancellationTokenSource.IsCancellationRequested) - { - return; - } - - cancellationTokenSource.Cancel(); - cancellationTokenSource.Dispose(); + // DDD-style cancellation through domain object + pending.Cancel(); // Clear pending rebalance snapshot since no rebalance is scheduled Volatile.Write(ref _pendingRebalance, null); @@ -171,10 +162,12 @@ public void CancelPendingRebalance() /// public void PublishIntent(Intent intent) { + _cacheDiagnostics.RebalanceIntentPublished(); + // Step 1: Evaluate rebalance necessity (Decision Engine is SOLE AUTHORITY) // Capture pending rebalance state for Stage 2 validation (atomic read) var pendingSnapshot = Volatile.Read(ref _pendingRebalance); - + var decision = _decisionEngine.Evaluate( requestedRange: intent.RequestedRange, currentCacheState: _state, @@ -190,31 +183,18 @@ public void PublishIntent(Intent intent) return; } - // Step 3: Decision confirmed rebalance is necessary - create new intent identity - var newCts = new CancellationTokenSource(); - var intentToken = newCts.Token; - - // Step 4: Cancel pending rebalance (mechanical safeguard for state transition) + // Step 3: Cancel pending rebalance (mechanical safeguard for state transition) + // Use DDD-style cancellation through PendingRebalance domain object // This is NOT a blind cancellation - it only happens when DecisionEngine validated necessity - var oldCts = Interlocked.Exchange(ref _currentIntentCts, newCts); - if (oldCts is not null) - { - oldCts.Cancel(); - oldCts.Dispose(); - } + var oldPending = Volatile.Read(ref _pendingRebalance); + oldPending?.Cancel(); + + // Step 4: Delegate to scheduler and capture returned PendingRebalance + // Scheduler fully owns execution infrastructure (CTS, Task, debounce, exceptions) + var newPending = _scheduler.ScheduleRebalance(intent, decision); // Step 5: Update pending rebalance snapshot for next Stage 2 validation - // todo make this object as a return result of the _scheduler.ScheduleRebalance(). Let's scheduler be a keeper of rebalance execution infrastructure like threading, debounle delay, catching and handling exceptions, cancellations - var newPending = new PendingRebalance( - decision.DesiredRange!.Value, - decision.DesiredNoRebalanceRange - ); Volatile.Write(ref _pendingRebalance, newPending); - - // Step 6: Delegate to scheduler with decision for debounce and execution - _scheduler.ScheduleRebalance(intent, decision, intentToken); - - _cacheDiagnostics.RebalanceIntentPublished(); } /// @@ -226,15 +206,16 @@ private void RecordReason(RebalanceReason reason) switch (reason) { case RebalanceReason.WithinCurrentNoRebalanceRange: - // todo add specific log for this reason + _cacheDiagnostics.RebalanceSkippedCurrentNoRebalanceRange(); + break; case RebalanceReason.WithinPendingNoRebalanceRange: - _cacheDiagnostics.RebalanceSkippedNoRebalanceRange(); + _cacheDiagnostics.RebalanceSkippedPendingNoRebalanceRange(); break; case RebalanceReason.DesiredEqualsCurrent: _cacheDiagnostics.RebalanceSkippedSameRange(); break; case RebalanceReason.RebalanceRequired: - // todo add specific log for this reason + _cacheDiagnostics.RebalanceScheduled(); break; default: throw new ArgumentOutOfRangeException(nameof(reason), reason, null); @@ -247,20 +228,73 @@ private void RecordReason(RebalanceReason reason) /// /// /// Maximum time to wait for idle state. Defaults to 30 seconds. + /// Throws if the Task does not stabilize within this period. /// /// A Task that completes when the background rebalance has finished. /// - /// Idle Proxy Responsibility: + /// Infrastructure API: + /// + /// This method provides deterministic synchronization with background rebalance operations. + /// It is useful for testing, graceful shutdown, health checks, integration scenarios, + /// and any situation requiring coordination with cache background work. + /// + /// Observe-and-Stabilize Pattern: + /// + /// Read current _pendingRebalance via Volatile.Read (safe observation) + /// Await the ExecutionTask from the snapshot + /// Re-check if _pendingRebalance changed (new rebalance scheduled) + /// Loop until snapshot stabilizes and task completes + /// /// - /// This method delegates to which owns - /// the background Task lifecycle. IntentController acts as a proxy, exposing the idle - /// synchronization mechanism without implementing Task tracking itself. + /// This ensures that no rebalance execution is running when the method returns, + /// even under concurrent intent cancellation and rescheduling. /// + /// Implementation Note: /// - /// This is an infrastructure API useful for testing, graceful shutdown, health checks, - /// and other scenarios requiring synchronization with background rebalance operations. - /// Intent lifecycle and cancellation logic remain unchanged. + /// Uses PendingRebalance.ExecutionTask directly rather than maintaining a separate _idleTask field. + /// This eliminates duplication and aligns with the DDD approach where the domain object + /// (PendingRebalance) is the single source of truth for execution state. /// /// - public Task WaitForIdleAsync(TimeSpan? timeout = null) => _scheduler.WaitForIdleAsync(timeout); + /// + /// Thrown if the background Task does not stabilize within the specified timeout. + /// + public async Task WaitForIdleAsync(TimeSpan? timeout = null) + { + var maxWait = timeout ?? TimeSpan.FromSeconds(30); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + while (stopwatch.Elapsed < maxWait) + { + // Observe current pending rebalance (Volatile.Read ensures visibility) + var observedPending = Volatile.Read(ref _pendingRebalance); + + // If no pending rebalance, we're idle + if (observedPending?.ExecutionTask == null) + { + return; + } + + // Await the observed task + await observedPending.ExecutionTask; + + // Check if _pendingRebalance changed while we were waiting + var currentPending = Volatile.Read(ref _pendingRebalance); + + if (ReferenceEquals(observedPending, currentPending)) + { + // Snapshot stabilized and task completed - we're idle + return; + } + + // Snapshot changed - a new rebalance was scheduled, loop again + } + + // Timeout - provide diagnostic information + var finalPending = Volatile.Read(ref _pendingRebalance); + var finalTask = finalPending?.ExecutionTask; + throw new TimeoutException( + $"WaitForIdleAsync() timed out after {maxWait.TotalSeconds:F1}s. " + + $"Final task state: {finalTask?.Status.ToString() ?? "null"}"); + } } \ No newline at end of file diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs index 0a656f7..2d15bdf 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs @@ -19,9 +19,12 @@ namespace SlidingWindowCache.Core.Rebalance.Intent; /// Created when a rebalance is scheduled, captured atomically by IntentController, /// and passed to DecisionEngine for subsequent decision evaluations. /// +/// DDD Enhancement: +/// +/// Includes encapsulated cancellation token and execution task tracking, +/// enabling direct cancellation and wait-for-idle scenarios without proxy methods. +/// /// -/// todo add ct here in ordr to call .Cancel() on this object - cancels actually pending rebalance. I guess it will be more DDD like -/// todo also define rebalance execution task property here, so using it we can wait for idle in blocking rebalance scenarious. internal sealed class PendingRebalance where TRange : IComparable { @@ -36,14 +39,49 @@ internal sealed class PendingRebalance /// public Range? DesiredNoRebalanceRange { get; } + /// + /// Gets the cancellation token for this pending rebalance operation. + /// External callers can monitor this token for cancellation status. + /// + public CancellationToken CancellationToken { get; } + + /// + /// Gets the execution task for this pending rebalance operation. + /// External callers can await this task to wait for rebalance completion. + /// Set by scheduler after scheduling background execution. + /// + public Task? ExecutionTask { get; internal set; } + + private readonly CancellationTokenSource? _cts; + /// /// Initializes a new instance of the class. /// /// The desired cache range for the pending rebalance. /// The no-rebalance range for the target state. - public PendingRebalance(Range desiredRange, Range? desiredNoRebalanceRange) + /// Optional cancellation token source for this rebalance. + public PendingRebalance( + Range desiredRange, + Range? desiredNoRebalanceRange, + CancellationTokenSource? cancellationTokenSource = null) { DesiredRange = desiredRange; DesiredNoRebalanceRange = desiredNoRebalanceRange; + _cts = cancellationTokenSource; + CancellationToken = cancellationTokenSource?.Token ?? CancellationToken.None; + } + + /// + /// Cancels this pending rebalance operation. + /// DDD-style behavior encapsulation for direct cancellation. + /// + /// + /// This method provides a more DDD-aligned approach where the domain object + /// encapsulates its own behavior (cancellation) rather than requiring external + /// management through the IntentController. + /// + public void Cancel() + { + _cts?.Cancel(); } -} +} \ No newline at end of file diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs index 2ae7db6..5fa4314 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs @@ -42,14 +42,6 @@ internal sealed class RebalanceScheduler private readonly TimeSpan _debounceDelay; private readonly ICacheDiagnostics _cacheDiagnostics; - /// - /// Tracks the latest scheduled rebalance background Task for deterministic idle synchronization. - /// Used by WaitForIdleAsync() to provide race-free infrastructure API for testing, graceful shutdown, - /// and health checks. This field exists in all builds to support the public WaitForIdleAsync() API. - /// Memory overhead: one Task reference per cache instance. - /// - private Task _idleTask = Task.CompletedTask; - /// /// Initializes a new instance of the class. /// @@ -73,29 +65,42 @@ ICacheDiagnostics cacheDiagnostics /// /// The intent with data that was actually assembled in UserPath and the requested range. /// The pre-validated rebalance decision from DecisionEngine. - /// Cancellation token for this specific intent (owned by IntentController). + /// A PendingRebalance snapshot representing the scheduled rebalance operation. /// /// /// This method is fire-and-forget. It schedules execution in the background thread pool - /// and returns immediately. + /// and returns immediately with a snapshot of the pending rebalance state. /// + /// Complete Infrastructure Ownership: /// - /// The scheduler ensures single-flight execution through the intent cancellation token. - /// When a new intent arrives, the Intent Controller cancels the previous token, causing - /// any pending or executing rebalance to be cancelled. + /// The scheduler now owns the COMPLETE execution infrastructure: + /// - Creates and manages CancellationTokenSource internally + /// - Manages background Task lifecycle + /// - Handles debounce timing + /// - Orchestrates exception handling + /// IntentController only works with the returned PendingRebalance domain object. /// /// /// Decision logic has already been evaluated by IntentController. This method performs /// mechanical scheduling and execution orchestration only. /// /// - public void ScheduleRebalance( + public PendingRebalance ScheduleRebalance( Intent intent, - RebalanceDecision decision, - CancellationToken intentToken) + RebalanceDecision decision) { + // Create CancellationTokenSource - scheduler owns complete execution infrastructure + var pendingCts = new CancellationTokenSource(); + var intentToken = pendingCts.Token; + + // Create PendingRebalance snapshot with encapsulated CTS + var pendingRebalance = new PendingRebalance( + decision.DesiredRange!.Value, + decision.DesiredNoRebalanceRange, + pendingCts + ); + // Fire-and-forget: schedule execution in background thread pool - // Fixing ambiguous invocation by explicitly specifying the type for Task.Run var backgroundTask = Task.Run(async () => { try @@ -125,9 +130,10 @@ await ExecuteAfterAsync( // NOTE: Do NOT pass intentToken to Task.Run ^ - it should only be used inside the lambda // to ensure the try-catch properly handles all OperationCanceledExceptions - // Track the latest background task for deterministic idle synchronization - // This supports the public WaitForIdleAsync() infrastructure API - _idleTask = backgroundTask; + // Set execution task on PendingRebalance for direct await scenarios + pendingRebalance.ExecutionTask = backgroundTask; + + return pendingRebalance; } /// @@ -213,67 +219,4 @@ await _executor.ExecuteAsync( throw; } } - - /// - /// Waits for the latest scheduled rebalance background Task to complete. - /// Provides deterministic synchronization without relying on instrumentation counters. - /// - /// - /// Maximum time to wait for idle state. Defaults to 30 seconds. - /// Throws if the Task does not stabilize within this period. - /// - /// A Task that completes when the background rebalance has finished. - /// - /// Infrastructure API: - /// - /// This method provides deterministic synchronization with background rebalance operations. - /// It is useful for testing, graceful shutdown, health checks, integration scenarios, - /// and any situation requiring coordination with cache background work. - /// - /// Observe-and-Stabilize Pattern: - /// - /// Read current _idleTask via Volatile.Read (safe observation) - /// Await the observed Task - /// Re-check if _idleTask changed (new rebalance scheduled) - /// Loop until Task reference stabilizes and completes - /// - /// - /// This ensures that no rebalance execution is running when the method returns, - /// even under concurrent intent cancellation and rescheduling. - /// - /// - /// - /// Thrown if the background Task does not stabilize within the specified timeout. - /// - public async Task WaitForIdleAsync(TimeSpan? timeout = null) - { - var maxWait = timeout ?? TimeSpan.FromSeconds(30); - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - - while (stopwatch.Elapsed < maxWait) - { - // Observe current idle task (Volatile.Read ensures visibility) - var observedTask = Volatile.Read(ref _idleTask); - - // Await the observed task - await observedTask; - - // Check if _idleTask changed while we were waiting - var currentTask = Volatile.Read(ref _idleTask); - - if (ReferenceEquals(observedTask, currentTask)) - { - // Task reference stabilized and completed - we're idle - return; - } - - // Task changed - a new rebalance was scheduled, loop again - } - - // Timeout - provide diagnostic information - var finalTask = Volatile.Read(ref _idleTask); - throw new TimeoutException( - $"WaitForIdleAsync() timed out after {maxWait.TotalSeconds:F1}s. " + - $"Final task state: {finalTask.Status}"); - } } \ No newline at end of file diff --git a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs index f08f7ea..b4cd4fd 100644 --- a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs +++ b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs @@ -110,10 +110,6 @@ public async ValueTask> HandleRequestAsync( Range requestedRange, CancellationToken cancellationToken) { - // CRITICAL: Cancel any pending/ongoing rebalance FIRST (Invariant A.0: User Path priority) - // This ensures rebalance execution doesn't interfere even though User Path no longer mutates - _intentManager.CancelPendingRebalance(); - // Check if cache is cold (never used) - use ToRangeData to detect empty cache var cacheStorage = _state.Cache; var isColdStart = !_state.LastRequested.HasValue; diff --git a/src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs b/src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs index b6254ce..7aa6fff 100644 --- a/src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs +++ b/src/SlidingWindowCache/Infrastructure/Instrumentation/EventCounterCacheDiagnostics.cs @@ -15,8 +15,10 @@ public class EventCounterCacheDiagnostics : ICacheDiagnostics private int _rebalanceExecutionStarted; private int _rebalanceExecutionCompleted; private int _rebalanceExecutionCancelled; - private int _rebalanceSkippedNoRebalanceRange; + private int _rebalanceSkippedCurrentNoRebalanceRange; + private int _rebalanceSkippedPendingNoRebalanceRange; private int _rebalanceSkippedSameRange; + private int _rebalanceScheduled; private int _userRequestFullCacheHit; private int _userRequestPartialCacheHit; private int _userRequestFullCacheMiss; @@ -37,8 +39,10 @@ public class EventCounterCacheDiagnostics : ICacheDiagnostics public int RebalanceExecutionStarted => _rebalanceExecutionStarted; public int RebalanceExecutionCompleted => _rebalanceExecutionCompleted; public int RebalanceExecutionCancelled => _rebalanceExecutionCancelled; - public int RebalanceSkippedNoRebalanceRange => _rebalanceSkippedNoRebalanceRange; + public int RebalanceSkippedCurrentNoRebalanceRange => _rebalanceSkippedCurrentNoRebalanceRange; + public int RebalanceSkippedPendingNoRebalanceRange => _rebalanceSkippedPendingNoRebalanceRange; public int RebalanceSkippedSameRange => _rebalanceSkippedSameRange; + public int RebalanceScheduled => _rebalanceScheduled; public int RebalanceExecutionFailed => _rebalanceExecutionFailed; /// @@ -70,12 +74,19 @@ void ICacheDiagnostics.DataSourceFetchMissingSegments() => void ICacheDiagnostics.RebalanceIntentPublished() => Interlocked.Increment(ref _rebalanceIntentPublished); /// - void ICacheDiagnostics.RebalanceSkippedNoRebalanceRange() => - Interlocked.Increment(ref _rebalanceSkippedNoRebalanceRange); + void ICacheDiagnostics.RebalanceSkippedCurrentNoRebalanceRange() => + Interlocked.Increment(ref _rebalanceSkippedCurrentNoRebalanceRange); + + /// + void ICacheDiagnostics.RebalanceSkippedPendingNoRebalanceRange() => + Interlocked.Increment(ref _rebalanceSkippedPendingNoRebalanceRange); /// void ICacheDiagnostics.RebalanceSkippedSameRange() => Interlocked.Increment(ref _rebalanceSkippedSameRange); + /// + void ICacheDiagnostics.RebalanceScheduled() => Interlocked.Increment(ref _rebalanceScheduled); + /// void ICacheDiagnostics.RebalanceExecutionFailed(Exception ex) { @@ -117,8 +128,10 @@ public void Reset() _rebalanceExecutionStarted = 0; _rebalanceExecutionCompleted = 0; _rebalanceExecutionCancelled = 0; - _rebalanceSkippedNoRebalanceRange = 0; + _rebalanceSkippedCurrentNoRebalanceRange = 0; + _rebalanceSkippedPendingNoRebalanceRange = 0; _rebalanceSkippedSameRange = 0; + _rebalanceScheduled = 0; _userRequestFullCacheHit = 0; _userRequestPartialCacheHit = 0; _userRequestFullCacheMiss = 0; diff --git a/src/SlidingWindowCache/Infrastructure/Instrumentation/ICacheDiagnostics.cs b/src/SlidingWindowCache/Infrastructure/Instrumentation/ICacheDiagnostics.cs index 3683be4..556f22b 100644 --- a/src/SlidingWindowCache/Infrastructure/Instrumentation/ICacheDiagnostics.cs +++ b/src/SlidingWindowCache/Infrastructure/Instrumentation/ICacheDiagnostics.cs @@ -146,13 +146,39 @@ public interface ICacheDiagnostics // ============================================================================ /// - /// Records a rebalance skipped due to RequestedRange being within NoRebalanceRange. - /// Called when DecisionEngine determines rebalance is unnecessary because RequestedRange falls inside the no-rebalance threshold zone. - /// Indicates policy-based skip decision before expensive operations (Decision Scenario D1). - /// Location: RebalanceScheduler.ExecutePipelineAsync (after DecisionEngine returns ShouldExecute=false) - /// Related: Invariant D.26 (No rebalance if inside NoRebalanceRange), Invariant D.27 (Policy-based skip tracking) + /// Records a rebalance skipped due to RequestedRange being within the CURRENT cache's NoRebalanceRange (Stage 1). + /// Called when DecisionEngine Stage 1 validation determines that the requested range is fully covered + /// by the current cache's no-rebalance threshold zone, making rebalance unnecessary. + /// This is the fast-path optimization that prevents unnecessary decision computation. /// - void RebalanceSkippedNoRebalanceRange(); + /// + /// Decision Pipeline Stage: Stage 1 - Current Cache Stability Check + /// Location: IntentController.RecordReason (RebalanceReason.WithinCurrentNoRebalanceRange) + /// Related Invariants: + /// + /// D.26: No rebalance if RequestedRange ⊆ CurrentNoRebalanceRange + /// Stage 1 is the primary fast-path optimization + /// + /// + void RebalanceSkippedCurrentNoRebalanceRange(); + + /// + /// Records a rebalance skipped due to RequestedRange being within the PENDING rebalance's DesiredNoRebalanceRange (Stage 2). + /// Called when DecisionEngine Stage 2 validation determines that the requested range will be covered + /// by a pending rebalance's target no-rebalance zone, preventing cancellation storms and thrashing. + /// This is the anti-thrashing optimization that protects scheduled-but-not-yet-executed rebalances. + /// + /// + /// Decision Pipeline Stage: Stage 2 - Pending Rebalance Stability Check (Anti-Thrashing) + /// Location: IntentController.RecordReason (RebalanceReason.WithinPendingNoRebalanceRange) + /// Related Invariants: + /// + /// Stage 2 prevents cancellation storms + /// Validates that pending rebalance will satisfy the request + /// Key metric for measuring anti-thrashing effectiveness + /// + /// + void RebalanceSkippedPendingNoRebalanceRange(); /// /// Records a rebalance skipped because CurrentCacheRange equals DesiredCacheRange. @@ -163,6 +189,31 @@ public interface ICacheDiagnostics /// void RebalanceSkippedSameRange(); + /// + /// Records that a rebalance was scheduled for execution after passing all decision pipeline stages (Stage 5). + /// Called when DecisionEngine completes all validation stages and determines rebalance is necessary, + /// and IntentController successfully schedules the rebalance with the scheduler. + /// This event occurs AFTER decision validation but BEFORE actual execution starts. + /// + /// + /// Decision Pipeline Stage: Stage 5 - Rebalance Required (Scheduling) + /// Location: IntentController.RecordReason (RebalanceReason.RebalanceRequired) + /// Lifecycle Position: + /// + /// RebalanceIntentPublished - User request published intent + /// **RebalanceScheduled** - Decision validated, scheduled (THIS EVENT) + /// RebalanceExecutionStarted - After debounce, execution begins + /// RebalanceExecutionCompleted - Execution finished successfully + /// + /// Key Metrics: + /// + /// Measures how many intents pass ALL decision stages + /// Ratio vs RebalanceIntentPublished shows decision efficiency + /// Ratio vs RebalanceExecutionStarted shows debounce/cancellation rate + /// + /// + void RebalanceScheduled(); + /// /// Records a rebalance execution failure due to an exception during execution. /// Called when an unhandled exception occurs during RebalanceExecutor.ExecuteAsync. diff --git a/src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs b/src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs index 25713d0..ac3cad9 100644 --- a/src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs +++ b/src/SlidingWindowCache/Infrastructure/Instrumentation/NoOpDiagnostics.cs @@ -51,7 +51,12 @@ public void RebalanceIntentPublished() } /// - public void RebalanceSkippedNoRebalanceRange() + public void RebalanceSkippedCurrentNoRebalanceRange() + { + } + + /// + public void RebalanceSkippedPendingNoRebalanceRange() { } @@ -60,6 +65,11 @@ public void RebalanceSkippedSameRange() { } + /// + public void RebalanceScheduled() + { + } + /// public void RebalanceExecutionFailed(Exception ex) { diff --git a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs index eea5681..c19c1ed 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs @@ -42,6 +42,7 @@ public async Task RebalanceExecutionFailed_IsRecorded_WhenDataSourceThrowsDuring // First call (user request) succeeds return GenerateTestData(range); } + // Second call (rebalance) fails throw new InvalidOperationException("Simulated data source failure during rebalance"); } @@ -51,7 +52,7 @@ public async Task RebalanceExecutionFailed_IsRecorded_WhenDataSourceThrowsDuring leftCacheSize: 1.0, rightCacheSize: 1.0, readMode: UserCacheReadMode.Snapshot, - leftThreshold: 0.0, // Trigger rebalance immediately + leftThreshold: 0.0, // Trigger rebalance immediately rightThreshold: 0.0, debounceDelay: TimeSpan.FromMilliseconds(10) ); @@ -64,7 +65,8 @@ public async Task RebalanceExecutionFailed_IsRecorded_WhenDataSourceThrowsDuring ); // Act: Make a request that will trigger a rebalance - var data = await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(100, 110), CancellationToken.None); + var data = await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(100, 110), + CancellationToken.None); // Wait for background rebalance to fail await cache.WaitForIdleAsync(TimeSpan.FromSeconds(5)); @@ -73,8 +75,8 @@ public async Task RebalanceExecutionFailed_IsRecorded_WhenDataSourceThrowsDuring Assert.Equal(1, _diagnostics.UserRequestServed); Assert.Equal(1, _diagnostics.RebalanceIntentPublished); Assert.Equal(1, _diagnostics.RebalanceExecutionStarted); - Assert.Equal(1, _diagnostics.RebalanceExecutionFailed); // ⚠️ This is the critical event - Assert.Equal(0, _diagnostics.RebalanceExecutionCompleted); // Should not complete + Assert.Equal(1, _diagnostics.RebalanceExecutionFailed); // ⚠️ This is the critical event + Assert.Equal(0, _diagnostics.RebalanceExecutionCompleted); // Should not complete } /// @@ -95,6 +97,7 @@ public async Task UserRequests_ContinueToWork_AfterRebalanceFailure() // Second call (rebalance) fails throw new InvalidOperationException("Rebalance fetch failed"); } + // Other calls succeed return GenerateTestData(range); } @@ -117,11 +120,13 @@ public async Task UserRequests_ContinueToWork_AfterRebalanceFailure() ); // Act: First request succeeds, triggers failed rebalance - var data1 = await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(100, 110), CancellationToken.None); + var data1 = await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(100, 110), + CancellationToken.None); await cache.WaitForIdleAsync(TimeSpan.FromSeconds(5)); // Second request should still work (user path bypasses failed rebalance) - var data2 = await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(200, 210), CancellationToken.None); + var data2 = await cache.GetDataAsync(Intervals.NET.Factories.Range.Closed(200, 210), + CancellationToken.None); await cache.WaitForIdleAsync(TimeSpan.FromSeconds(5)); // Assert: Both requests succeeded despite rebalance failure @@ -156,6 +161,7 @@ public async Task ProductionDiagnostics_ProperlyLogsRebalanceFailures() // First call (user request) succeeds return GenerateTestData(range); } + // Second call (rebalance) fails throw new InvalidOperationException("Data source is unhealthy"); } @@ -212,7 +218,8 @@ public Task> FetchAsync(Range range, CancellationToke return Task.FromResult(data); } - public Task> FetchAsync(IEnumerable> ranges, CancellationToken cancellationToken) + public Task> FetchAsync(IEnumerable> ranges, + CancellationToken cancellationToken) { var allData = new List(); foreach (var range in ranges) @@ -220,6 +227,7 @@ public Task> FetchAsync(IEnumerable> ranges, Ca var data = _fetchSingleRange(range); allData.AddRange(data); } + return Task.FromResult>(allData); } } @@ -237,6 +245,10 @@ public LoggingCacheDiagnostics(Action logError) _logError = logError; } + public void RebalanceScheduled() + { + } + public void RebalanceExecutionFailed(Exception ex) { // ⚠️ CRITICAL: This is the minimum requirement for production @@ -244,21 +256,73 @@ public void RebalanceExecutionFailed(Exception ex) } // All other methods can be no-op if you only care about failures - public void UserRequestServed() { } - public void CacheExpanded() { } - public void CacheReplaced() { } - public void UserRequestFullCacheHit() { } - public void UserRequestPartialCacheHit() { } - public void UserRequestFullCacheMiss() { } - public void DataSourceFetchSingleRange() { } - public void DataSourceFetchMissingSegments() { } - public void RebalanceIntentPublished() { } - public void RebalanceIntentCancelled() { } - public void RebalanceExecutionStarted() { } - public void RebalanceExecutionCompleted() { } - public void RebalanceExecutionCancelled() { } - public void RebalanceSkippedNoRebalanceRange() { } - public void RebalanceSkippedSameRange() { } + public void UserRequestServed() + { + } + + public void CacheExpanded() + { + } + + public void CacheReplaced() + { + } + + public void UserRequestFullCacheHit() + { + } + + public void UserRequestPartialCacheHit() + { + } + + public void UserRequestFullCacheMiss() + { + } + + public void DataSourceFetchSingleRange() + { + } + + public void DataSourceFetchMissingSegments() + { + } + + public void RebalanceIntentPublished() + { + } + + public void RebalanceIntentCancelled() + { + } + + public void RebalanceExecutionStarted() + { + } + + public void RebalanceExecutionCompleted() + { + } + + public void RebalanceExecutionCancelled() + { + } + + public void RebalanceSkippedNoRebalanceRange() + { + } + + public void RebalanceSkippedCurrentNoRebalanceRange() + { + } + + public void RebalanceSkippedPendingNoRebalanceRange() + { + } + + public void RebalanceSkippedSameRange() + { + } } private static IEnumerable GenerateTestData(Intervals.NET.Range range) @@ -268,8 +332,9 @@ private static IEnumerable GenerateTestData(Intervals.NET.Range ran { data.Add($"Item-{i}"); } + return data; } #endregion -} +} \ No newline at end of file diff --git a/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs b/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs index 1d631f3..0e138d0 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs @@ -339,14 +339,40 @@ public static void AssertRebalanceLifecycleIntegrity(EventCounterCacheDiagnostic } /// - /// Asserts that rebalance was skipped due to NoRebalanceRange policy. + /// Asserts that rebalance was skipped due to current cache NoRebalanceRange (Stage 1). /// - public static void AssertRebalanceSkippedDueToPolicy(EventCounterCacheDiagnostics cacheDiagnostics) + public static void AssertRebalanceSkippedDueToPolicyStage1(EventCounterCacheDiagnostics cacheDiagnostics) + { + var skipped = cacheDiagnostics.RebalanceSkippedCurrentNoRebalanceRange; + Assert.True(skipped > 0, + $"Expected at least one rebalance to be skipped due to current NoRebalanceRange (Stage 1), but found {skipped}."); + Assert.Equal(0, cacheDiagnostics.RebalanceExecutionStarted); + Assert.Equal(0, cacheDiagnostics.RebalanceExecutionCompleted); + } + + /// + /// Asserts that rebalance was skipped due to pending rebalance NoRebalanceRange (Stage 2). + /// + public static void AssertRebalanceSkippedDueToPolicyStage2(EventCounterCacheDiagnostics cacheDiagnostics) { - var skipped = cacheDiagnostics.RebalanceSkippedNoRebalanceRange; + var skipped = cacheDiagnostics.RebalanceSkippedPendingNoRebalanceRange; Assert.True(skipped > 0, - $"Expected at least one rebalance to be skipped due to NoRebalanceRange policy, but found {skipped}."); + $"Expected at least one rebalance to be skipped due to pending NoRebalanceRange (Stage 2), but found {skipped}."); + Assert.Equal(0, cacheDiagnostics.RebalanceExecutionStarted); + Assert.Equal(0, cacheDiagnostics.RebalanceExecutionCompleted); + } + /// + /// Asserts that rebalance was skipped due to NoRebalanceRange policy (either stage). + /// + public static void AssertRebalanceSkippedDueToPolicy(EventCounterCacheDiagnostics cacheDiagnostics) + { + var skippedStage1 = cacheDiagnostics.RebalanceSkippedCurrentNoRebalanceRange; + var skippedStage2 = cacheDiagnostics.RebalanceSkippedPendingNoRebalanceRange; + var totalSkipped = skippedStage1 + skippedStage2; + + Assert.True(totalSkipped > 0, + $"Expected at least one rebalance to be skipped due to NoRebalanceRange policy, but found Stage1={skippedStage1}, Stage2={skippedStage2}."); Assert.Equal(0, cacheDiagnostics.RebalanceExecutionStarted); Assert.Equal(0, cacheDiagnostics.RebalanceExecutionCompleted); } @@ -401,4 +427,65 @@ public static void AssertDataSourceFetchedMissingSegments(EventCounterCacheDiagn { Assert.Equal(expectedCount, cacheDiagnostics.DataSourceFetchMissingSegments); } -} \ No newline at end of file + + /// + /// Asserts that rebalance was scheduled (decision engine validated rebalance as necessary). + /// + /// The diagnostics instance to check. + /// Expected number of times rebalance was scheduled (default: 1). + public static void AssertRebalanceScheduled(EventCounterCacheDiagnostics cacheDiagnostics, int expectedCount = 1) + { + Assert.Equal(expectedCount, cacheDiagnostics.RebalanceScheduled); + } + + /// + /// Asserts that rebalance was skipped because DesiredCacheRange equals CurrentCacheRange (Stage 3 / D.28). + /// + /// The diagnostics instance to check. + /// Minimum number of same-range skips expected (default: 1). + public static void AssertRebalanceSkippedSameRange(EventCounterCacheDiagnostics cacheDiagnostics, + int minExpected = 1) + { + Assert.True(cacheDiagnostics.RebalanceSkippedSameRange >= minExpected, + $"Expected at least {minExpected} rebalance skip(s) due to same range (DesiredCacheRange == CurrentCacheRange), " + + $"but found {cacheDiagnostics.RebalanceSkippedSameRange}."); + Assert.Equal(0, cacheDiagnostics.RebalanceExecutionStarted); + Assert.Equal(0, cacheDiagnostics.RebalanceExecutionCompleted); + } + + /// + /// Asserts the complete rebalance decision-to-execution pipeline lifecycle integrity. + /// Validates that all intents are accounted for across decision stages and execution. + /// + /// + /// Decision Pipeline Stages: + /// - Stage 1: Current NoRebalanceRange check → SkippedCurrentNoRebalanceRange + /// - 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) + /// + public static void AssertRebalancePipelineIntegrity(EventCounterCacheDiagnostics cacheDiagnostics) + { + var intentPublished = cacheDiagnostics.RebalanceIntentPublished; + var scheduled = cacheDiagnostics.RebalanceScheduled; + 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; + Assert.True(totalDecisionOutcomes <= intentPublished, + $"Decision outcomes ({totalDecisionOutcomes}) cannot exceed intents published ({intentPublished}). " + + $"Breakdown: Scheduled={scheduled}, SkippedStage1={skippedStage1}, SkippedStage2={skippedStage2}, " + + $"SkippedStage3={skippedStage3}, IntentCancelled={intentCancelled}"); + + // Execution phase: Verify lifecycle integrity + AssertRebalanceLifecycleIntegrity(cacheDiagnostics); + } +} diff --git a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs index 8d82f14..aff2016 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs @@ -313,6 +313,11 @@ public async Task Invariant_C17_AtMostOneActiveIntent() // ASSERT: Each new request publishes intent and cancels previous (at least 2 cancelled) TestHelpers.AssertIntentPublished(_cacheDiagnostics, 3); TestHelpers.AssertRebalancePathCancelled(_cacheDiagnostics, 2); + + // Verify that at least one rebalance was scheduled and completed + Assert.True(_cacheDiagnostics.RebalanceScheduled >= 1, + $"Expected at least 1 rebalance to be scheduled, but found {_cacheDiagnostics.RebalanceScheduled}"); + TestHelpers.AssertRebalanceCompleted(_cacheDiagnostics, 1); } /// @@ -340,6 +345,11 @@ public async Task Invariant_C18_PreviousIntentBecomesObsolete() // ASSERT: New intent published, old one cancelled Assert.True(_cacheDiagnostics.RebalanceIntentPublished > publishedBefore); TestHelpers.AssertRebalancePathCancelled(_cacheDiagnostics); + + // Verify that at least one rebalance was scheduled and completed + Assert.True(_cacheDiagnostics.RebalanceScheduled >= 1, + $"Expected at least 1 rebalance to be scheduled, but found {_cacheDiagnostics.RebalanceScheduled}"); + TestHelpers.AssertRebalanceCompleted(_cacheDiagnostics, 1); } /// @@ -366,7 +376,9 @@ public async Task Invariant_C24_IntentDoesNotGuaranteeExecution() // ASSERT: Intent published but execution may be skipped due to NoRebalanceRange TestHelpers.AssertIntentPublished(_cacheDiagnostics); - if (_cacheDiagnostics.RebalanceSkippedNoRebalanceRange > 0) + var totalSkipped = _cacheDiagnostics.RebalanceSkippedCurrentNoRebalanceRange + + _cacheDiagnostics.RebalanceSkippedPendingNoRebalanceRange; + if (totalSkipped > 0) { Assert.Equal(0, _cacheDiagnostics.RebalanceExecutionCompleted); } @@ -431,43 +443,142 @@ public async Task Invariant_D27_NoRebalanceIfRequestInNoRebalanceRange() TestHelpers.AssertRebalanceSkippedDueToPolicy(_cacheDiagnostics); } + /// + /// Tests Invariant D.27 Stage 1: Rebalance skipped when request is within current cache's NoRebalanceRange. + /// Stage 1 (current cache stability check) is the fast-path optimization that prevents unnecessary + /// rebalance when the requested range is fully covered by the existing cache's no-rebalance threshold zone. + /// This validates the first stage of the multi-stage decision pipeline. + /// Related: D.27 (NoRebalanceRange policy), C.24a (execution skipped due to policy). + /// + [Fact] + public async Task Invariant_D27_Stage1_SkipsWhenWithinCurrentNoRebalanceRange() + { + // ARRANGE: Set up cache with threshold configuration + var options = TestHelpers.CreateDefaultOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + leftThreshold: 0.3, // 30% threshold + rightThreshold: 0.3, + debounceDelay: TimeSpan.FromMilliseconds(50)); + var (cache, _) = TrackCache(TestHelpers.CreateCacheWithDefaults(_domain, _cacheDiagnostics, options)); + + // ACT: Establish cache with range [100, 120] (size 21) + await TestHelpers.ExecuteRequestAndWaitForRebalance(cache, TestHelpers.CreateRange(100, 120)); + _cacheDiagnostics.Reset(); + + // NoRebalanceRange should be approximately [106, 114] (shrunk by 30% on each side) + // Request within this range should trigger Stage 1 skip + await cache.GetDataAsync(TestHelpers.CreateRange(108, 112), CancellationToken.None); + await cache.WaitForIdleAsync(); + + // ASSERT: Stage 1 skip occurred + Assert.Equal(1, _cacheDiagnostics.RebalanceIntentPublished); + Assert.Equal(1, _cacheDiagnostics.RebalanceSkippedCurrentNoRebalanceRange); + Assert.Equal(0, _cacheDiagnostics.RebalanceSkippedPendingNoRebalanceRange); + Assert.Equal(0, _cacheDiagnostics.RebalanceExecutionStarted); + Assert.Equal(0, _cacheDiagnostics.RebalanceExecutionCompleted); + } + + /// + /// Tests Invariant D.29 Stage 2: Rebalance skipped when request is within pending rebalance's NoRebalanceRange. + /// Stage 2 (pending rebalance stability check) is the anti-thrashing optimization that prevents + /// cancellation storms when a scheduled rebalance will already satisfy the incoming request. + /// This validates the second stage of the multi-stage decision pipeline. + /// Related: D.29 (multi-stage validation), C.18 (intent supersession with validation). + /// + [Fact] + public async Task Invariant_D29_Stage2_SkipsWhenWithinPendingNoRebalanceRange() + { + // ARRANGE: Set up cache with threshold and debounce to allow multiple intents + var options = TestHelpers.CreateDefaultOptions( + leftCacheSize: 1.0, // Large expansion + rightCacheSize: 1.0, + leftThreshold: 0.2, + rightThreshold: 0.2, + debounceDelay: TimeSpan.FromMilliseconds(2000)); // Long debounce to create pending state + var (cache, _) = TrackCache(TestHelpers.CreateCacheWithDefaults(_domain, _cacheDiagnostics, options)); + var initialRange = TestHelpers.CreateRange(200, 300); // Span 101 + // Initial cache range [98, 400] size 303, NoRebalanceRange 20% from 301 = 60 on left side, so [159, 340] + var initialCacheRange = initialRange.ExpandByRatio(_domain, options.LeftCacheSize, options.RightCacheSize); + var initialNoRebalanceRange = initialCacheRange.ExpandByRatio( + domain: _domain, + leftRatio: -(options.LeftThreshold ?? 0), // Negate to shrink + rightRatio: -(options.RightThreshold ?? 0) // Negate to shrink + ); + var requestRange = TestHelpers.CreateRange(300, 400); // Span 101 + // Desired cache range for request would be [198, 500], NoRebalanceRange would be [258, 440] + var desiredCacheRange = requestRange.ExpandByRatio(_domain, options.LeftCacheSize, options.RightCacheSize); + var desiredNoRebalanceRange = desiredCacheRange.ExpandByRatio( + domain: _domain, + leftRatio: -(options.LeftThreshold ?? 0), + rightRatio: -(options.RightThreshold ?? 0) + ); + var nextRequestRange = TestHelpers.CreateRange(320, 420); // Span 11, within pending NoRebalanceRange but outside current NoRebalanceRange + + // ACT: Establish initial cache + await TestHelpers.ExecuteRequestAndWaitForRebalance(cache, initialRange); + _cacheDiagnostics.Reset(); + + // Request 1: Trigger rebalance outside NoRebalanceRange - will be pending due to debounce + _ = await cache.GetDataAsync(requestRange, CancellationToken.None); + await Task.Delay(500); // Allow intent to be published but not executed (still in debounce) + + // Request 2: Make another request that would be covered by pending rebalance's NoRebalanceRange + // This should trigger Stage 2 skip since the pending rebalance will satisfy this request + _ = await cache.GetDataAsync(nextRequestRange, CancellationToken.None); + + // Wait to complete + await cache.WaitForIdleAsync(); + + // ASSERT: Stage 2 skip occurred for second request + Assert.Equal(2, _cacheDiagnostics.RebalanceIntentPublished); + Assert.True(_cacheDiagnostics.RebalanceSkippedPendingNoRebalanceRange >= 1, + $"Expected at least one Stage 2 skip due to pending rebalance NoRebalanceRange, but found {_cacheDiagnostics.RebalanceSkippedPendingNoRebalanceRange}"); + // First rebalance should execute, second should be skipped by Stage 2 + Assert.Equal(1, _cacheDiagnostics.RebalanceExecutionCompleted); + } + /// /// Tests Invariant D.28 (🟢 Behavioral): If DesiredCacheRange == CurrentCacheRange, rebalance execution - /// not required. When cache already matches desired state, system skips execution as optimization. - /// Uses DEBUG counter RebalanceSkippedSameRange to verify early-exit in RebalanceExecutor. - /// Corresponds to sub-invariant C.24c (execution skipped due to optimization). + /// is not required (Stage 3 validation / same-range optimization). This is the final decision stage that + /// prevents no-op rebalance operations when the cache is already in optimal configuration for the request. + /// Verifies the RebalanceSkippedSameRange counter tracks this optimization. + /// Related: C.24c (execution skipped due to same range), D.29 (multi-stage decision pipeline). /// [Fact] public async Task Invariant_D28_SkipWhenDesiredEqualsCurrentRange() { - // ARRANGE - var options = TestHelpers.CreateDefaultOptions(leftCacheSize: 1.0, rightCacheSize: 1.0, - leftThreshold: 0.4, rightThreshold: 0.4, debounceDelay: TimeSpan.FromMilliseconds(100)); + // ARRANGE: Use zero thresholds to eliminate NoRebalanceRange effects (isolate same-range logic) + var options = TestHelpers.CreateDefaultOptions( + leftCacheSize: 1.0, + rightCacheSize: 1.0, + leftThreshold: 0.9, // Very small NoRebalanceRange - forces decision to Stage 3 + rightThreshold: 0.0, + debounceDelay: TimeSpan.FromMilliseconds(50)); var (cache, _) = TrackCache(TestHelpers.CreateCacheWithDefaults(_domain, _cacheDiagnostics, options)); - // ACT: First request establishes cache at desired range - var firstRange = TestHelpers.CreateRange(100, 110); - await TestHelpers.ExecuteRequestAndWaitForRebalance(cache, firstRange); + // ACT: Establish cache with specific range [100, 110] + var initialRange = TestHelpers.CreateRange(100, 110); + await TestHelpers.ExecuteRequestAndWaitForRebalance(cache, initialRange); + _cacheDiagnostics.Reset(); - // Second request: same range should trigger intent, pass decision logic, starts executions, but skip before mutating data due to same-range optimization - await cache.GetDataAsync(firstRange, CancellationToken.None); + // Request the exact same expanded range that should already be cached + // This creates scenario where DesiredCacheRange (computed from request) == CurrentCacheRange (existing cache) + var data = await cache.GetDataAsync(initialRange, CancellationToken.None); await cache.WaitForIdleAsync(); - // ASSERT: Intent published but execution optimized away - Assert.Equal(1, _cacheDiagnostics.RebalanceIntentPublished); + // ASSERT: Verify same-range skip occurred (Stage 3 validation) + TestHelpers.AssertUserDataCorrect(data, initialRange); + TestHelpers.AssertIntentPublished(_cacheDiagnostics, 1); + TestHelpers.AssertRebalanceSkippedSameRange(_cacheDiagnostics, 1); - // Execution should either be skipped entirely or not completed - // (skipped due to same-range optimization or never started) + // Verify no execution occurred (optimization prevented unnecessary rebalance) + Assert.Equal(0, _cacheDiagnostics.RebalanceScheduled); + Assert.Equal(0, _cacheDiagnostics.RebalanceExecutionStarted); Assert.Equal(0, _cacheDiagnostics.RebalanceExecutionCompleted); - Assert.Equal(1, _cacheDiagnostics.RebalanceSkippedSameRange); } - // NOTE: Invariant D.25, D.26, D.28, D.29: Decision Path is purely analytical, - // never mutates cache state, checks DesiredCacheRange == CurrentCacheRange - // Cannot be directly tested via public API - requires internal state access - // or integration tests with mock decision engine - #endregion #region E. Cache Geometry & Policy Invariants @@ -616,7 +727,8 @@ public async Task Invariant_F35_G46_RebalanceCancellationBehavior() await cache.WaitForIdleAsync(); // ASSERT: Verify cancellation occurred (F.35, G.46) - TestHelpers.AssertRebalancePathCancelled(_cacheDiagnostics, 2); // 2 cancels for the 2 new requests after the first + TestHelpers.AssertRebalancePathCancelled(_cacheDiagnostics, + 2); // 2 cancels for the 2 new requests after the first // Verify Rebalance lifecycle integrity: every started execution reaches terminal state (F.35a) TestHelpers.AssertRebalanceLifecycleIntegrity(_cacheDiagnostics); @@ -642,6 +754,7 @@ public async Task Invariant_F36a_RebalanceNormalizesCache() // ASSERT: Rebalance executed successfully TestHelpers.AssertRebalanceCompleted(_cacheDiagnostics); TestHelpers.AssertRebalanceLifecycleIntegrity(_cacheDiagnostics); + TestHelpers.AssertRebalanceScheduled(_cacheDiagnostics, 1); // Cache should be normalized - verify by requesting from expected expanded range var extendedData = await cache.GetDataAsync(TestHelpers.CreateRange(95, 115), CancellationToken.None); @@ -667,6 +780,9 @@ public async Task Invariant_F40_F41_F42_PostExecutionGuarantees() 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, TestHelpers.CreateRange(90, 120)); @@ -721,7 +837,8 @@ public async Task Invariant_G43_G44_G45_ExecutionContextSeparation() public async Task Invariant_G46_UserCancellationDuringFetch() { // ARRANGE: Slow mock data source to allow cancellation during fetch - var (cache, _) = TrackCache(TestHelpers.CreateCacheWithDefaults(_domain, _cacheDiagnostics, TestHelpers.CreateDefaultOptions(), + var (cache, _) = TrackCache(TestHelpers.CreateCacheWithDefaults(_domain, _cacheDiagnostics, + TestHelpers.CreateDefaultOptions(), fetchDelay: TimeSpan.FromMilliseconds(300))); // Act & Assert: Cancel token during fetch operation @@ -786,6 +903,8 @@ public async Task CompleteScenario_MultipleRequestsWithRebalancing() // Verify key behavioral properties Assert.Equal(5, _cacheDiagnostics.UserRequestServed); Assert.True(_cacheDiagnostics.RebalanceIntentPublished >= 5); + Assert.True(_cacheDiagnostics.RebalanceScheduled >= 1, + $"Expected at least 1 rebalance to be scheduled, but found {_cacheDiagnostics.RebalanceScheduled}"); TestHelpers.AssertRebalanceCompleted(_cacheDiagnostics); } @@ -826,7 +945,9 @@ public async Task ConcurrencyScenario_RapidRequestsBurstWithCancellation() Assert.Equal(20, _cacheDiagnostics.UserRequestServed); Assert.True(_cacheDiagnostics.RebalanceIntentPublished == 20); - TestHelpers.AssertRebalancePathCancelled(_cacheDiagnostics, 19); // Each new request cancels the previous intent, so expect 19 cancellations + TestHelpers.AssertRebalancePathCancelled(_cacheDiagnostics, + 19); // Each new request cancels the previous intent, so expect 19 cancellations + Assert.Equal(1, _cacheDiagnostics.RebalanceScheduled); Assert.Equal(1, _cacheDiagnostics.RebalanceExecutionCompleted); } diff --git a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Instrumentation/NoOpDiagnosticsTests.cs b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Instrumentation/NoOpDiagnosticsTests.cs index 7e2a7fc..5e08490 100644 --- a/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Instrumentation/NoOpDiagnosticsTests.cs +++ b/tests/SlidingWindowCache.Unit.Tests/Infrastructure/Instrumentation/NoOpDiagnosticsTests.cs @@ -27,7 +27,8 @@ public void AllMethods_WhenCalled_DoNotThrowExceptions() diagnostics.RebalanceExecutionStarted(); diagnostics.RebalanceIntentCancelled(); diagnostics.RebalanceIntentPublished(); - diagnostics.RebalanceSkippedNoRebalanceRange(); + diagnostics.RebalanceSkippedCurrentNoRebalanceRange(); + diagnostics.RebalanceSkippedPendingNoRebalanceRange(); diagnostics.RebalanceSkippedSameRange(); diagnostics.RebalanceExecutionFailed(testException); diagnostics.UserRequestFullCacheHit(); From 2aa46646a516ea24451ce70cf74529518f1479e7 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 03:06:19 +0100 Subject: [PATCH 04/23] docs/tests: refactor invariants documentation for clarity and precision, enhancing architectural and behavioral descriptions of rebalance intent and cancellation mechanisms. --- docs/invariants.md | 35 ++-- .../TestInfrastructure/TestHelpers.cs | 6 + .../WindowCacheInvariantTests.cs | 163 ++++++++++-------- 3 files changed, 119 insertions(+), 85 deletions(-) diff --git a/docs/invariants.md b/docs/invariants.md index b7cf588..57be0aa 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -218,14 +218,14 @@ deterministic, race-free synchronization without polling or timing dependencies. ## C. Rebalance Intent & Temporal Invariants -**C.17** 🟢 **[Behavioral — Test: `Invariant_C17_AtMostOneActiveIntent`]** At any point in time, there is **at most one active rebalance intent**. -- *Observable via*: DEBUG counters showing intent published/cancelled -- *Test verifies*: Multiple rapid requests show N published, N-1 cancelled +**C.17** 🔵 **[Architectural]** At most one rebalance intent may be active at any time. +- *Enforced by*: Single-writer architecture, cancellation coordination in IntentController +- *Architecture*: IntentController cancels previous pending rebalance before scheduling new one +- *Note*: This is a structural constraint enforced by component design, not a behavioral guarantee testable via public API -**C.18** 🟢 **[Behavioral — Test: `Invariant_C18_PreviousIntentBecomesObsolete`]** 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**. -- *Observable via*: DEBUG counters tracking intent lifecycle -- *Test verifies*: Old intent cancelled when new one published -- *Clarification*: Intents are access signals, not commands. An intent represents "user accessed this range," not "must execute rebalance." Execution decisions are governed by the Rebalance Decision Engine's analytical validation (Stage 1: Current Cache NoRebalanceRange check, Stage 2: Pending Desired Cache NoRebalanceRange check if applicable, Stage 3: DesiredCacheRange vs CurrentCacheRange equality check). Previously created intents may be superseded or cancelled, but the decision to execute is always based on current validation state, not intent age. +**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 +- *Clarification*: Intents are access signals, not commands. An intent represents "user accessed this range," not "must execute rebalance." Execution decisions are governed by the Rebalance Decision Engine's analytical validation (Stage 1: Current Cache NoRebalanceRange check, Stage 2: Pending Desired Cache NoRebalanceRange check if applicable, Stage 3: DesiredCacheRange vs CurrentCacheRange equality check). Previously created intents may be superseded or cancelled, but the decision to execute is always based on current validation state, not intent age. Cancellation occurs ONLY when Decision Engine validation confirms a new rebalance is necessary. **C.19** 🔵 **[Architectural]** Any rebalance execution can be **cancelled or have its results ignored**. - *Enforced by*: `CancellationToken` passed through execution pipeline @@ -385,11 +385,15 @@ The system prioritizes **decision correctness and work avoidance** over aggressi ### F.1 Execution Control & Cancellation -**F.35** 🟢 **[Behavioral — Test: `Invariant_F35_RebalanceExecutionSupportsCancellation`]** Rebalance Execution **MUST support cancellation** at all stages (before I/O, during I/O, before mutations). -- *Observable via*: DEBUG counters showing execution cancelled, lifecycle tracking (Started == Completed + Cancelled) -- *Test verifies*: Rapid requests cancel pending rebalance, execution lifecycle properly tracked +**F.35** 🟢 **[Behavioral — Test: `Invariant_F35_G46_RebalanceCancellationBehavior`]** Rebalance Execution **MUST be cancellation-safe** at all stages (before I/O, during I/O, before mutations). +- *Observable via*: Lifecycle tracking integrity (Started == Completed + Cancelled), system stability under concurrent requests +- *Test verifies*: + - Deterministic termination: Every started execution reaches terminal state + - No partial mutations: Cache consistency maintained after cancellation + - Lifecycle integrity: Accounting remains correct under cancellation - *Implementation details*: `ThrowIfCancellationRequested()` at multiple checkpoints in execution pipeline -- *Related*: C.24d (execution skipped due to cancellation), A.0a (User Path triggers cancellation), G.46 (high-level guarantee) +- *Note*: Cancellation is triggered by scheduling decisions (Decision Engine validation), not automatically by user requests +- *Related*: C.24d (execution skipped due to cancellation), A.0a (User Path priority via validation-driven cancellation), G.46 (high-level guarantee) **F.35a** 🔵 **[Architectural]** Rebalance Execution **MUST yield** to User Path requests immediately upon cancellation. - *Enforced by*: `ThrowIfCancellationRequested()` at multiple checkpoints @@ -460,14 +464,15 @@ The system prioritizes **decision correctness and work avoidance** over aggressi **G.46** 🟢 **[Behavioral — Tests: `Invariant_G46_UserCancellationDuringFetch`, `Invariant_G46_RebalanceCancellation`]** 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**: New user requests cancel pending/ongoing rebalance execution +2. **Background rebalance cancellation**: System supports cancellation of pending/ongoing rebalance execution - *Observable via*: - User cancellation: OperationCanceledException thrown during IDataSource fetch - - Rebalance cancellation: DEBUG counters showing intent/execution cancelled + - 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 (high-level guarantee) -- *Related*: F.35 (detailed rebalance execution cancellation mechanics), A.0a (User Path priority via cancellation) + - `Invariant_G46_RebalanceCancellation`: 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/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs b/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs index 0e138d0..7a9f4ad 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs @@ -293,6 +293,12 @@ public static void AssertIntentPublished(EventCounterCacheDiagnostics cacheDiagn /// /// /// + /// Cancellation is a coordination mechanism triggered by scheduling decisions, not automatic + /// request-driven behavior. Cancellation occurs ONLY when the Decision Engine validates that + /// a new rebalance is necessary. This method verifies that IF cancellation occurred, it was properly + /// tracked in the lifecycle. + /// + /// /// Due to timing, cancellation can occur at two distinct lifecycle points: /// /// diff --git a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs index aff2016..a57c7ab 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs @@ -51,9 +51,9 @@ public async ValueTask DisposeAsync() #region A.1 Concurrency & Priority /// - /// Tests Invariant A.0a (🟢 Behavioral): Every User Request MUST cancel any ongoing or pending - /// Rebalance Execution to ensure rebalance doesn't interfere with User Path data assembly. - /// Verifies cancellation via DEBUG instrumentation counters. + /// Tests Invariant A.0a (🟢 Behavioral): User Request MAY cancel ongoing or pending Rebalance Execution + /// ONLY when a new rebalance is validated as necessary by the multi-stage decision pipeline. + /// Verifies cancellation is validation-driven coordination, not automatic request-driven behavior. /// Related: A.0 (Architectural - User Path has higher priority than Rebalance Execution) /// [Fact] @@ -69,14 +69,23 @@ public async Task Invariant_A_0a_UserRequestCancelsRebalance() var intentPublishedBefore = _cacheDiagnostics.RebalanceIntentPublished; Assert.Equal(1, intentPublishedBefore); - // Second request cancels the first rebalance intent + // Second request (non-overlapping range) - Decision Engine validates if new rebalance is necessary await cache.GetDataAsync(TestHelpers.CreateRange(120, 130), CancellationToken.None); // Wait for background rebalance to settle before checking counters await cache.WaitForIdleAsync(); - // ASSERT: Verify cancellation occurred - TestHelpers.AssertRebalancePathCancelled(_cacheDiagnostics); + // ASSERT: Priority mechanism enforced via validation-driven cancellation + // Cancellation occurs ONLY when Decision Engine validates new rebalance as necessary + // System does NOT guarantee automatic cancellation on every new request + TestHelpers.AssertIntentPublished(_cacheDiagnostics, 2); + + // Verify lifecycle integrity and system stability (not deterministic cancellation counts) + TestHelpers.AssertRebalanceLifecycleIntegrity(_cacheDiagnostics); + + // At least one rebalance should complete successfully + Assert.True(_cacheDiagnostics.RebalanceExecutionCompleted >= 1, + $"Expected at least 1 rebalance to complete, but found {_cacheDiagnostics.RebalanceExecutionCompleted}"); } #endregion @@ -292,8 +301,9 @@ public async Task Invariant_B15_CancelledRebalanceDoesNotViolateConsistency() #region C. Rebalance Intent & Temporal Invariants /// - /// Tests Invariant C.17 (🟢 Behavioral): At any point in time, at most one active rebalance intent exists. - /// Verifies rapid requests cause each new intent to cancel previous ones, preventing intent queue buildup. + /// Tests Invariant C.17 (🔵 Architectural): At most one rebalance intent may be active at any time. + /// This is an architectural constraint enforced by single-writer design. Test verifies system stability + /// and lifecycle integrity under rapid concurrent requests, not deterministic cancellation counts. /// [Fact] public async Task Invariant_C17_AtMostOneActiveIntent() @@ -310,20 +320,21 @@ public async Task Invariant_C17_AtMostOneActiveIntent() // Wait for background rebalance to settle before checking counters await cache.WaitForIdleAsync(); - // ASSERT: Each new request publishes intent and cancels previous (at least 2 cancelled) - TestHelpers.AssertIntentPublished(_cacheDiagnostics, 3); - TestHelpers.AssertRebalancePathCancelled(_cacheDiagnostics, 2); + // ASSERT: System stability - verify lifecycle integrity (not deterministic cancellation counts) + // Architectural invariant: at most one active intent enforced by design + TestHelpers.AssertRebalanceLifecycleIntegrity(_cacheDiagnostics); // Verify that at least one rebalance was scheduled and completed Assert.True(_cacheDiagnostics.RebalanceScheduled >= 1, $"Expected at least 1 rebalance to be scheduled, but found {_cacheDiagnostics.RebalanceScheduled}"); - TestHelpers.AssertRebalanceCompleted(_cacheDiagnostics, 1); + Assert.True(_cacheDiagnostics.RebalanceExecutionCompleted >= 1, + $"Expected at least 1 rebalance to complete, but found {_cacheDiagnostics.RebalanceExecutionCompleted}"); } /// - /// Tests Invariant C.18 (🟢 Behavioral): Any previously created rebalance intent is considered - /// obsolete after a new intent is generated. Prevents stale rebalance operations from executing - /// with outdated information. + /// Tests Invariant C.18 (🟡 Conceptual): Previously created intents may become logically superseded. + /// This is a conceptual design intent. Test verifies system stability and cache consistency when + /// multiple intents are published, not deterministic cancellation behavior (obsolescence ≠ cancellation). /// [Fact] public async Task Invariant_C18_PreviousIntentBecomesObsolete() @@ -336,20 +347,24 @@ public async Task Invariant_C18_PreviousIntentBecomesObsolete() await cache.GetDataAsync(TestHelpers.CreateRange(100, 110), CancellationToken.None); var publishedBefore = _cacheDiagnostics.RebalanceIntentPublished; - // Second request publishes new intent and cancels old one + // Second request publishes new intent (may supersede old one depending on Decision Engine validation) await cache.GetDataAsync(TestHelpers.CreateRange(200, 210), CancellationToken.None); // Wait for background rebalance to settle before checking counters await cache.WaitForIdleAsync(); - // ASSERT: New intent published, old one cancelled + // ASSERT: System stability - new intent published, system remains consistent Assert.True(_cacheDiagnostics.RebalanceIntentPublished > publishedBefore); - TestHelpers.AssertRebalancePathCancelled(_cacheDiagnostics); + + // Conceptual invariant: obsolescence ≠ guaranteed cancellation + // Cancellation depends on Decision Engine validation, not automatic on new requests + TestHelpers.AssertRebalanceLifecycleIntegrity(_cacheDiagnostics); // Verify that at least one rebalance was scheduled and completed Assert.True(_cacheDiagnostics.RebalanceScheduled >= 1, $"Expected at least 1 rebalance to be scheduled, but found {_cacheDiagnostics.RebalanceScheduled}"); - TestHelpers.AssertRebalanceCompleted(_cacheDiagnostics, 1); + Assert.True(_cacheDiagnostics.RebalanceExecutionCompleted >= 1, + $"Expected at least 1 rebalance to complete, but found {_cacheDiagnostics.RebalanceExecutionCompleted}"); } /// @@ -704,12 +719,12 @@ public async Task CacheHitMiss_AllScenarios() /// /// Tests Invariants F.35 (🟢 Behavioral), F.35a (🔵 Architectural), and G.46 (🟢 Behavioral): - /// Rebalance Execution MUST support cancellation at all stages and yield to User Path immediately. - /// Validates detailed cancellation mechanics, lifecycle tracking (Started == Completed + Cancelled), - /// and high-level guarantee that cancellation works in all scenarios. + /// Rebalance Execution MUST be cancellation-safe at all stages (before I/O, during I/O, before mutations). + /// Validates deterministic termination, no partial mutations, lifecycle integrity, and that cancellation + /// support works as a high-level guarantee (not deterministic per-request behavior). /// Uses slow data source to allow cancellation during execution. Verifies DEBUG instrumentation counters - /// ensure proper lifecycle tracking. Related: A.0a (User Path cancels rebalance), C.24d (execution - /// skipped due to cancellation). + /// ensure proper lifecycle tracking. Related: A.0a (User Path priority via validation-driven cancellation), + /// C.24d (execution skipped due to cancellation). /// [Fact] public async Task Invariant_F35_G46_RebalanceCancellationBehavior() @@ -720,18 +735,22 @@ public async Task Invariant_F35_G46_RebalanceCancellationBehavior() var (cache, _) = TrackCache(TestHelpers.CreateCacheWithDefaults(_domain, _cacheDiagnostics, options, fetchDelay: TimeSpan.FromMilliseconds(200))); - // ACT: First request triggers rebalance, then immediately cancel with multiple new requests + // ACT: First request triggers rebalance, then immediately make new requests await cache.GetDataAsync(TestHelpers.CreateRange(100, 110), CancellationToken.None); await cache.GetDataAsync(TestHelpers.CreateRange(105, 115), CancellationToken.None); await cache.GetDataAsync(TestHelpers.CreateRange(110, 120), CancellationToken.None); await cache.WaitForIdleAsync(); - // ASSERT: Verify cancellation occurred (F.35, G.46) - TestHelpers.AssertRebalancePathCancelled(_cacheDiagnostics, - 2); // 2 cancels for the 2 new requests after the first - - // Verify Rebalance lifecycle integrity: every started execution reaches terminal state (F.35a) + // ASSERT: Verify cancellation-safety (F.35, G.46) + // Focus on lifecycle integrity and system stability, not deterministic cancellation counts + // Cancellation is triggered by Decision Engine scheduling, not automatically by requests + + // Verify Rebalance lifecycle integrity: every started execution reaches terminal state (F.35) TestHelpers.AssertRebalanceLifecycleIntegrity(_cacheDiagnostics); + + // Verify system stability: at least one rebalance completed successfully + Assert.True(_cacheDiagnostics.RebalanceExecutionCompleted >= 1, + $"Expected at least 1 rebalance to complete, but found {_cacheDiagnostics.RebalanceExecutionCompleted}"); } /// @@ -908,49 +927,53 @@ public async Task CompleteScenario_MultipleRequestsWithRebalancing() TestHelpers.AssertRebalanceCompleted(_cacheDiagnostics); } - /// - /// Comprehensive concurrency test with rapid burst of 20 concurrent requests verifying intent cancellation - /// and system stability under high load. Validates: All requests served correctly (A.1, A.10), - /// Intent cancellation works (C.17, C.18), At most one active intent (C.17), - /// Cache remains consistent (B.11, B.15). Ensures single-consumer model with cancellation-based - /// coordination handles realistic high-load scenarios without data corruption or request failures. - /// - [Fact] - public async Task ConcurrencyScenario_RapidRequestsBurstWithCancellation() - { - // ARRANGE - var options = TestHelpers.CreateDefaultOptions(debounceDelay: TimeSpan.FromSeconds(1)); - var (cache, _) = TrackCache(TestHelpers.CreateCacheWithDefaults(_domain, _cacheDiagnostics, options)); - - // ACT: Fire 20 rapid concurrent requests - var tasks = new List>>(); - for (var i = 0; i < 20; i++) + /// + /// Comprehensive concurrency test with rapid burst of 20 concurrent requests verifying intent cancellation + /// and system stability under high load. Validates: All requests served correctly (A.1, A.10), + /// Intent cancellation works (C.17, C.18), At most one active intent (C.17), + /// Cache remains consistent (B.11, B.15). Ensures single-consumer model with cancellation-based + /// coordination handles realistic high-load scenarios without data corruption or request failures. + /// + [Fact] + public async Task ConcurrencyScenario_RapidRequestsBurstWithCancellation() { - var start = 100 + i * 5; - tasks.Add(cache.GetDataAsync(TestHelpers.CreateRange(start, start + 10), CancellationToken.None).AsTask()); + // ARRANGE + var options = TestHelpers.CreateDefaultOptions(debounceDelay: TimeSpan.FromSeconds(1)); + var (cache, _) = TrackCache(TestHelpers.CreateCacheWithDefaults(_domain, _cacheDiagnostics, options)); + + // ACT: Fire 20 rapid concurrent requests + var tasks = new List>>(); + for (var i = 0; i < 20; i++) + { + var start = 100 + i * 5; + tasks.Add(cache.GetDataAsync(TestHelpers.CreateRange(start, start + 10), CancellationToken.None).AsTask()); + } + + var results = await Task.WhenAll(tasks); + + // Wait for background rebalance to settle before checking counters + await cache.WaitForIdleAsync(); + + // ASSERT: All requests completed successfully with correct data + Assert.Equal(20, results.Length); + for (var i = 0; i < results.Length; i++) + { + var expectedRange = TestHelpers.CreateRange(100 + i * 5, 110 + i * 5); + TestHelpers.AssertUserDataCorrect(results[i], expectedRange); + } + + Assert.Equal(20, _cacheDiagnostics.UserRequestServed); + Assert.True(_cacheDiagnostics.RebalanceIntentPublished == 20); + + // Verify system stability: lifecycle integrity and successful completion + // Cancellation is coordination mechanism triggered by scheduling decisions, not deterministic per-request + TestHelpers.AssertRebalanceLifecycleIntegrity(_cacheDiagnostics); + Assert.True(_cacheDiagnostics.RebalanceScheduled >= 1, + $"Expected at least 1 rebalance scheduled, but found {_cacheDiagnostics.RebalanceScheduled}"); + Assert.True(_cacheDiagnostics.RebalanceExecutionCompleted >= 1, + $"Expected at least 1 rebalance completed, but found {_cacheDiagnostics.RebalanceExecutionCompleted}"); } - var results = await Task.WhenAll(tasks); - - // Wait for background rebalance to settle before checking counters - await cache.WaitForIdleAsync(); - - // ASSERT: All requests completed successfully with correct data - Assert.Equal(20, results.Length); - for (var i = 0; i < results.Length; i++) - { - var expectedRange = TestHelpers.CreateRange(100 + i * 5, 110 + i * 5); - TestHelpers.AssertUserDataCorrect(results[i], expectedRange); - } - - Assert.Equal(20, _cacheDiagnostics.UserRequestServed); - Assert.True(_cacheDiagnostics.RebalanceIntentPublished == 20); - TestHelpers.AssertRebalancePathCancelled(_cacheDiagnostics, - 19); // Each new request cancels the previous intent, so expect 19 cancellations - Assert.Equal(1, _cacheDiagnostics.RebalanceScheduled); - Assert.Equal(1, _cacheDiagnostics.RebalanceExecutionCompleted); - } - /// /// Tests read mode behavior. Snapshot mode: zero-allocation reads via direct ReadOnlyMemory access. /// CopyOnRead mode: defensive copies for memory safety when callers hold references beyond the call. From b113a3cb35cf6a69aecbd28c45f62b66fb398ce2 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 03:22:06 +0100 Subject: [PATCH 05/23] refactor: feature/add NoRebalanceRangePlanner for cache stability zone computation --- .../Core/Planning/NoRebalanceRangePlanner.cs | 53 +++++++++++++++++++ .../Decision/RebalanceDecisionEngine.cs | 7 ++- .../Decision/ThresholdRebalancePolicy.cs | 30 +++++++++++ .../Core/Rebalance/Intent/Intent.cs | 30 ----------- .../Core/Rebalance/Intent/IntentController.cs | 29 +++++++++- .../Intent/ThresholdRebalancePolicy.cs | 30 ----------- src/SlidingWindowCache/Public/WindowCache.cs | 5 +- 7 files changed, 119 insertions(+), 65 deletions(-) create mode 100644 src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs create mode 100644 src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs delete mode 100644 src/SlidingWindowCache/Core/Rebalance/Intent/Intent.cs delete mode 100644 src/SlidingWindowCache/Core/Rebalance/Intent/ThresholdRebalancePolicy.cs diff --git a/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs b/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs new file mode 100644 index 0000000..4813e33 --- /dev/null +++ b/src/SlidingWindowCache/Core/Planning/NoRebalanceRangePlanner.cs @@ -0,0 +1,53 @@ +using Intervals.NET; +using Intervals.NET.Domain.Abstractions; +using SlidingWindowCache.Infrastructure.Extensions; +using SlidingWindowCache.Public.Configuration; + +namespace SlidingWindowCache.Core.Planning; + +/// +/// Plans the no-rebalance range by shrinking the cache range using threshold ratios. +/// This defines the stability zone within which user requests do not trigger rebalancing. +/// +/// The type representing the range boundaries. +/// The type representing the domain of the ranges. +/// +/// Role: Cache Geometry Planning - Threshold Zone Computation +/// Characteristics: Pure function, stateless, configuration-driven +/// +/// Works in tandem with to define +/// complete cache geometry: desired cache range (expansion) and no-rebalance zone (shrinkage). +/// +/// +internal readonly struct NoRebalanceRangePlanner + where TRange : IComparable + where TDomain : IRangeDomain +{ + private readonly WindowCacheOptions _options; + private readonly TDomain _domain; + + public NoRebalanceRangePlanner(WindowCacheOptions options, TDomain domain) + { + _options = options; + _domain = domain; + } + + /// + /// Computes the no-rebalance range by shrinking the cache range using threshold ratios. + /// + /// The current cache range to compute thresholds from. + /// + /// The no-rebalance range, or null if thresholds would result in an invalid range. + /// + /// + /// The no-rebalance range is computed by contracting the cache range: + /// - 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. + /// + public Range? Plan(Range cacheRange) => cacheRange.ExpandByRatio( + domain: _domain, + leftRatio: -(_options.LeftThreshold ?? 0), // Negate to shrink + rightRatio: -(_options.RightThreshold ?? 0) // Negate to shrink + ); +} diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs index 5681e38..fedaae6 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs @@ -32,13 +32,16 @@ internal sealed class RebalanceDecisionEngine { private readonly ThresholdRebalancePolicy _policy; private readonly ProportionalRangePlanner _planner; + private readonly NoRebalanceRangePlanner _noRebalancePlanner; public RebalanceDecisionEngine( ThresholdRebalancePolicy policy, - ProportionalRangePlanner planner) + ProportionalRangePlanner planner, + NoRebalanceRangePlanner noRebalancePlanner) { _policy = policy; _planner = planner; + _noRebalancePlanner = noRebalancePlanner; } /// @@ -81,7 +84,7 @@ public RebalanceDecision Evaluate( // Stage 3: Desired Range Computation // Compute the target cache geometry using policy var desiredCacheRange = _planner.Plan(requestedRange); - var desiredNoRebalanceRange = _policy.GetNoRebalanceRange(desiredCacheRange); + var desiredNoRebalanceRange = _noRebalancePlanner.Plan(desiredCacheRange); // Stage 4: Equality Short Circuit // If desired range matches current cache range, no mutation needed diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs new file mode 100644 index 0000000..2c2337e --- /dev/null +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs @@ -0,0 +1,30 @@ +using Intervals.NET; +using Intervals.NET.Extensions; + +namespace SlidingWindowCache.Core.Rebalance.Intent; + +/// +/// Evaluates whether rebalancing should occur based on no-rebalance range containment. +/// This is a pure decision evaluator - planning logic has been separated to +/// . +/// +/// 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 +/// +internal readonly struct ThresholdRebalancePolicy + where TRange : IComparable +{ + /// + /// Determines whether rebalancing should occur based on whether the requested range + /// is contained within the no-rebalance zone. + /// + /// The stability zone within which rebalancing is suppressed. + /// The range requested by the user. + /// 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/Intent/Intent.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/Intent.cs deleted file mode 100644 index ad7b0fd..0000000 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/Intent.cs +++ /dev/null @@ -1,30 +0,0 @@ -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 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; \ No newline at end of file diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index 51b2cce..2191a5c 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -1,4 +1,6 @@ -using Intervals.NET.Domain.Abstractions; +using Intervals.NET; +using Intervals.NET.Data; +using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.Rebalance.Execution; using SlidingWindowCache.Core.State; @@ -6,6 +8,31 @@ 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. /// This is the Intent Controller component within the Rebalance Intent Manager actor. diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/ThresholdRebalancePolicy.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/ThresholdRebalancePolicy.cs deleted file mode 100644 index 2b925fc..0000000 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/ThresholdRebalancePolicy.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Intervals.NET; -using Intervals.NET.Domain.Abstractions; -using Intervals.NET.Extensions; -using SlidingWindowCache.Infrastructure.Extensions; -using SlidingWindowCache.Public.Configuration; - -namespace SlidingWindowCache.Core.Rebalance.Intent; - -internal readonly struct ThresholdRebalancePolicy - where TRange : IComparable - where TDomain : IRangeDomain -{ - private readonly WindowCacheOptions _options; - private readonly TDomain _domain; - - public ThresholdRebalancePolicy(WindowCacheOptions options, TDomain domain) - { - _options = options; - _domain = domain; - } - - public bool ShouldRebalance(Range noRebalanceRange, Range requested) => - !noRebalanceRange.Contains(requested); - - public Range? GetNoRebalanceRange(Range cacheRange) => cacheRange.ExpandByRatio( - domain: _domain, - leftRatio: -(_options.LeftThreshold ?? 0), // Negate to shrink - rightRatio: -(_options.RightThreshold ?? 0) // Negate to shrink - ); -} \ No newline at end of file diff --git a/src/SlidingWindowCache/Public/WindowCache.cs b/src/SlidingWindowCache/Public/WindowCache.cs index 8fa38b9..b9c00d9 100644 --- a/src/SlidingWindowCache/Public/WindowCache.cs +++ b/src/SlidingWindowCache/Public/WindowCache.cs @@ -118,11 +118,12 @@ public WindowCache( var state = new CacheState(cacheStorage, domain); // Initialize all internal actors following corrected execution context model - var rebalancePolicy = new ThresholdRebalancePolicy(options, domain); + var rebalancePolicy = new ThresholdRebalancePolicy(); var rangePlanner = new ProportionalRangePlanner(options, domain); + var noRebalancePlanner = new NoRebalanceRangePlanner(options, domain); var cacheFetcher = new CacheDataExtensionService(dataSource, domain, cacheDiagnostics); - var decisionEngine = new RebalanceDecisionEngine(rebalancePolicy, rangePlanner); + var decisionEngine = new RebalanceDecisionEngine(rebalancePolicy, rangePlanner, noRebalancePlanner); var executor = new RebalanceExecutor(state, cacheFetcher, cacheDiagnostics); From dd8a5c295b0397e8831501be86df211265956819 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 03:22:39 +0100 Subject: [PATCH 06/23] refactor: refactor rebalance namespace from 'Intent' to 'Decision' for improved clarity and consistency across related classes. --- .../Core/Rebalance/Decision/RebalanceDecisionEngine.cs | 2 +- .../Core/Rebalance/Decision/ThresholdRebalancePolicy.cs | 2 +- .../Core/Rebalance/Execution/RebalanceExecutor.cs | 2 +- .../Core/Rebalance/Intent/IntentController.cs | 2 +- .../Core/Rebalance/Intent/PendingRebalance.cs | 2 +- .../Core/Rebalance/Intent/RebalanceScheduler.cs | 2 +- src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs | 2 +- src/SlidingWindowCache/Public/WindowCache.cs | 1 - 8 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs index fedaae6..0b7564d 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs @@ -1,7 +1,7 @@ using Intervals.NET; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Planning; -using SlidingWindowCache.Core.Rebalance.Intent; +using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.State; namespace SlidingWindowCache.Core.Rebalance.Decision; diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs index 2c2337e..4a2d8dd 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/ThresholdRebalancePolicy.cs @@ -1,7 +1,7 @@ using Intervals.NET; using Intervals.NET.Extensions; -namespace SlidingWindowCache.Core.Rebalance.Intent; +namespace SlidingWindowCache.Core.Rebalance.Decision; /// /// Evaluates whether rebalancing should occur based on no-rebalance range containment. diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs index b867c62..5caf633 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs @@ -1,7 +1,7 @@ using Intervals.NET; using Intervals.NET.Data; using Intervals.NET.Domain.Abstractions; -using SlidingWindowCache.Core.Rebalance.Intent; +using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.State; using SlidingWindowCache.Infrastructure.Instrumentation; diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index 2191a5c..f38531c 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -6,7 +6,7 @@ using SlidingWindowCache.Core.State; using SlidingWindowCache.Infrastructure.Instrumentation; -namespace SlidingWindowCache.Core.Rebalance.Intent; +namespace SlidingWindowCache.Core.Rebalance.Decision; /// /// Represents the intent to rebalance the cache based on a requested range and the currently available range data. diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs index 2d15bdf..22f9da9 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs @@ -1,6 +1,6 @@ using Intervals.NET; -namespace SlidingWindowCache.Core.Rebalance.Intent; +namespace SlidingWindowCache.Core.Rebalance.Decision; /// /// Represents an immutable snapshot of a pending rebalance operation's target state. diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs index 5fa4314..0e61777 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs @@ -3,7 +3,7 @@ using SlidingWindowCache.Core.Rebalance.Execution; using SlidingWindowCache.Infrastructure.Instrumentation; -namespace SlidingWindowCache.Core.Rebalance.Intent; +namespace SlidingWindowCache.Core.Rebalance.Decision; /// /// Responsible for scheduling and executing rebalance operations in the background. diff --git a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs index b4cd4fd..a49a6f4 100644 --- a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs +++ b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs @@ -4,7 +4,7 @@ using Intervals.NET.Domain.Abstractions; using Intervals.NET.Extensions; using SlidingWindowCache.Core.Rebalance.Execution; -using SlidingWindowCache.Core.Rebalance.Intent; +using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.State; using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; diff --git a/src/SlidingWindowCache/Public/WindowCache.cs b/src/SlidingWindowCache/Public/WindowCache.cs index b9c00d9..7172ce5 100644 --- a/src/SlidingWindowCache/Public/WindowCache.cs +++ b/src/SlidingWindowCache/Public/WindowCache.cs @@ -3,7 +3,6 @@ using SlidingWindowCache.Core.Planning; using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.Rebalance.Execution; -using SlidingWindowCache.Core.Rebalance.Intent; using SlidingWindowCache.Core.State; using SlidingWindowCache.Core.UserPath; using SlidingWindowCache.Infrastructure.Instrumentation; From 0a5d16896ece1c6362e20de53ed0393dab6f3e55 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 03:27:44 +0100 Subject: [PATCH 07/23] refactor: clean up whitespace in RebalanceScheduler and test files for improved readability --- .../Rebalance/Intent/RebalanceScheduler.cs | 2 +- .../TestInfrastructure/TestHelpers.cs | 56 +++++------ .../WindowCacheInvariantTests.cs | 98 +++++++++---------- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs index 0e61777..8fcafa4 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs @@ -92,7 +92,7 @@ public PendingRebalance ScheduleRebalance( // Create CancellationTokenSource - scheduler owns complete execution infrastructure var pendingCts = new CancellationTokenSource(); var intentToken = pendingCts.Token; - + // Create PendingRebalance snapshot with encapsulated CTS var pendingRebalance = new PendingRebalance( decision.DesiredRange!.Value, diff --git a/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs b/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs index 7a9f4ad..21eb552 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/TestInfrastructure/TestHelpers.cs @@ -90,44 +90,44 @@ public static void VerifyDataMatchesRange(ReadOnlyMemory data, Range e { // For closed ranges [start, end], data should be sequential from start case { IsStartInclusive: true, IsEndInclusive: true }: - { - for (var i = 0; i < span.Length; i++) { - Assert.Equal(start + i, span[i]); - } + for (var i = 0; i < span.Length; i++) + { + Assert.Equal(start + i, span[i]); + } - break; - } + break; + } case { IsStartInclusive: true, IsEndInclusive: false }: - { - // [start, end) - start inclusive, end exclusive - for (var i = 0; i < span.Length; i++) { - Assert.Equal(start + i, span[i]); - } + // [start, end) - start inclusive, end exclusive + for (var i = 0; i < span.Length; i++) + { + Assert.Equal(start + i, span[i]); + } - break; - } + break; + } case { IsStartInclusive: false, IsEndInclusive: true }: - { - // (start, end] - start exclusive, end inclusive - for (var i = 0; i < span.Length; i++) { - Assert.Equal(start + 1 + i, span[i]); - } + // (start, end] - start exclusive, end inclusive + for (var i = 0; i < span.Length; i++) + { + Assert.Equal(start + 1 + i, span[i]); + } - break; - } + break; + } default: - { - // (start, end) - both exclusive - for (var i = 0; i < span.Length; i++) { - Assert.Equal(start + 1 + i, span[i]); - } + // (start, end) - both exclusive + for (var i = 0; i < span.Length; i++) + { + Assert.Equal(start + 1 + i, span[i]); + } - break; - } + break; + } } } @@ -376,7 +376,7 @@ public static void AssertRebalanceSkippedDueToPolicy(EventCounterCacheDiagnostic var skippedStage1 = cacheDiagnostics.RebalanceSkippedCurrentNoRebalanceRange; var skippedStage2 = cacheDiagnostics.RebalanceSkippedPendingNoRebalanceRange; var totalSkipped = skippedStage1 + skippedStage2; - + Assert.True(totalSkipped > 0, $"Expected at least one rebalance to be skipped due to NoRebalanceRange policy, but found Stage1={skippedStage1}, Stage2={skippedStage2}."); Assert.Equal(0, cacheDiagnostics.RebalanceExecutionStarted); diff --git a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs index a57c7ab..f835bc0 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs @@ -79,10 +79,10 @@ public async Task Invariant_A_0a_UserRequestCancelsRebalance() // Cancellation occurs ONLY when Decision Engine validates new rebalance as necessary // System does NOT guarantee automatic cancellation on every new request TestHelpers.AssertIntentPublished(_cacheDiagnostics, 2); - + // Verify lifecycle integrity and system stability (not deterministic cancellation counts) TestHelpers.AssertRebalanceLifecycleIntegrity(_cacheDiagnostics); - + // At least one rebalance should complete successfully Assert.True(_cacheDiagnostics.RebalanceExecutionCompleted >= 1, $"Expected at least 1 rebalance to complete, but found {_cacheDiagnostics.RebalanceExecutionCompleted}"); @@ -355,7 +355,7 @@ public async Task Invariant_C18_PreviousIntentBecomesObsolete() // ASSERT: System stability - new intent published, system remains consistent Assert.True(_cacheDiagnostics.RebalanceIntentPublished > publishedBefore); - + // Conceptual invariant: obsolescence ≠ guaranteed cancellation // Cancellation depends on Decision Engine validation, not automatic on new requests TestHelpers.AssertRebalanceLifecycleIntegrity(_cacheDiagnostics); @@ -744,10 +744,10 @@ public async Task Invariant_F35_G46_RebalanceCancellationBehavior() // ASSERT: Verify cancellation-safety (F.35, G.46) // Focus on lifecycle integrity and system stability, not deterministic cancellation counts // Cancellation is triggered by Decision Engine scheduling, not automatically by requests - + // Verify Rebalance lifecycle integrity: every started execution reaches terminal state (F.35) TestHelpers.AssertRebalanceLifecycleIntegrity(_cacheDiagnostics); - + // Verify system stability: at least one rebalance completed successfully Assert.True(_cacheDiagnostics.RebalanceExecutionCompleted >= 1, $"Expected at least 1 rebalance to complete, but found {_cacheDiagnostics.RebalanceExecutionCompleted}"); @@ -927,53 +927,53 @@ public async Task CompleteScenario_MultipleRequestsWithRebalancing() TestHelpers.AssertRebalanceCompleted(_cacheDiagnostics); } - /// - /// Comprehensive concurrency test with rapid burst of 20 concurrent requests verifying intent cancellation - /// and system stability under high load. Validates: All requests served correctly (A.1, A.10), - /// Intent cancellation works (C.17, C.18), At most one active intent (C.17), - /// Cache remains consistent (B.11, B.15). Ensures single-consumer model with cancellation-based - /// coordination handles realistic high-load scenarios without data corruption or request failures. - /// - [Fact] - public async Task ConcurrencyScenario_RapidRequestsBurstWithCancellation() + /// + /// Comprehensive concurrency test with rapid burst of 20 concurrent requests verifying intent cancellation + /// and system stability under high load. Validates: All requests served correctly (A.1, A.10), + /// Intent cancellation works (C.17, C.18), At most one active intent (C.17), + /// Cache remains consistent (B.11, B.15). Ensures single-consumer model with cancellation-based + /// coordination handles realistic high-load scenarios without data corruption or request failures. + /// + [Fact] + public async Task ConcurrencyScenario_RapidRequestsBurstWithCancellation() + { + // ARRANGE + var options = TestHelpers.CreateDefaultOptions(debounceDelay: TimeSpan.FromSeconds(1)); + var (cache, _) = TrackCache(TestHelpers.CreateCacheWithDefaults(_domain, _cacheDiagnostics, options)); + + // ACT: Fire 20 rapid concurrent requests + var tasks = new List>>(); + for (var i = 0; i < 20; i++) { - // ARRANGE - var options = TestHelpers.CreateDefaultOptions(debounceDelay: TimeSpan.FromSeconds(1)); - var (cache, _) = TrackCache(TestHelpers.CreateCacheWithDefaults(_domain, _cacheDiagnostics, options)); - - // ACT: Fire 20 rapid concurrent requests - var tasks = new List>>(); - for (var i = 0; i < 20; i++) - { - var start = 100 + i * 5; - tasks.Add(cache.GetDataAsync(TestHelpers.CreateRange(start, start + 10), CancellationToken.None).AsTask()); - } - - var results = await Task.WhenAll(tasks); - - // Wait for background rebalance to settle before checking counters - await cache.WaitForIdleAsync(); - - // ASSERT: All requests completed successfully with correct data - Assert.Equal(20, results.Length); - for (var i = 0; i < results.Length; i++) - { - var expectedRange = TestHelpers.CreateRange(100 + i * 5, 110 + i * 5); - TestHelpers.AssertUserDataCorrect(results[i], expectedRange); - } - - Assert.Equal(20, _cacheDiagnostics.UserRequestServed); - Assert.True(_cacheDiagnostics.RebalanceIntentPublished == 20); - - // Verify system stability: lifecycle integrity and successful completion - // Cancellation is coordination mechanism triggered by scheduling decisions, not deterministic per-request - TestHelpers.AssertRebalanceLifecycleIntegrity(_cacheDiagnostics); - Assert.True(_cacheDiagnostics.RebalanceScheduled >= 1, - $"Expected at least 1 rebalance scheduled, but found {_cacheDiagnostics.RebalanceScheduled}"); - Assert.True(_cacheDiagnostics.RebalanceExecutionCompleted >= 1, - $"Expected at least 1 rebalance completed, but found {_cacheDiagnostics.RebalanceExecutionCompleted}"); + var start = 100 + i * 5; + tasks.Add(cache.GetDataAsync(TestHelpers.CreateRange(start, start + 10), CancellationToken.None).AsTask()); } + var results = await Task.WhenAll(tasks); + + // Wait for background rebalance to settle before checking counters + await cache.WaitForIdleAsync(); + + // ASSERT: All requests completed successfully with correct data + Assert.Equal(20, results.Length); + for (var i = 0; i < results.Length; i++) + { + var expectedRange = TestHelpers.CreateRange(100 + i * 5, 110 + i * 5); + TestHelpers.AssertUserDataCorrect(results[i], expectedRange); + } + + Assert.Equal(20, _cacheDiagnostics.UserRequestServed); + Assert.True(_cacheDiagnostics.RebalanceIntentPublished == 20); + + // Verify system stability: lifecycle integrity and successful completion + // Cancellation is coordination mechanism triggered by scheduling decisions, not deterministic per-request + TestHelpers.AssertRebalanceLifecycleIntegrity(_cacheDiagnostics); + Assert.True(_cacheDiagnostics.RebalanceScheduled >= 1, + $"Expected at least 1 rebalance scheduled, but found {_cacheDiagnostics.RebalanceScheduled}"); + Assert.True(_cacheDiagnostics.RebalanceExecutionCompleted >= 1, + $"Expected at least 1 rebalance completed, but found {_cacheDiagnostics.RebalanceExecutionCompleted}"); + } + /// /// Tests read mode behavior. Snapshot mode: zero-allocation reads via direct ReadOnlyMemory access. /// CopyOnRead mode: defensive copies for memory safety when callers hold references beyond the call. From 9fdf914632ad1f0853aa4aa1227552c999991550 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 03:57:01 +0100 Subject: [PATCH 08/23] docs: enhance rebalance decision documentation to clarify validation stages and execution authority, emphasizing work avoidance and smart eventual consistency principles. --- README.md | 279 +++++++++++++++++++++------ docs/actors-and-responsibilities.md | 88 ++++++--- docs/actors-to-components-mapping.md | 186 ++++++++++++------ docs/cache-state-machine.md | 35 ++-- docs/component-map.md | 231 ++++++++++++++-------- docs/concurrency-model.md | 96 ++++++--- docs/scenario-model.md | 35 +++- 7 files changed, 660 insertions(+), 290 deletions(-) diff --git a/README.md b/README.md index 4cd03de..3462aa8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Sliding Window Cache -**A read-only, range-based, sequential-optimized cache with background rebalancing and intelligent prefetching.** +**A read-only, range-based, sequential-optimized cache with decision-driven background rebalancing, smart eventual +consistency, and intelligent work avoidance.** --- @@ -16,6 +17,8 @@ ## 📑 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) @@ -32,24 +35,103 @@ ## 📦 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. +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 -- **Background Rebalancing**: Asynchronously adjusts the cache window when access patterns change, with debouncing to avoid thrashing -- **Multi-Stage Rebalance Validation**: CPU-only analytical decision pipeline determines rebalance necessity through NoRebalanceRange validation and cache geometry analysis -- **Opportunistic Execution**: Rebalance operations may be skipped when validation determines they are unnecessary (intent represents observed access, not mandatory work) -- **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 +- **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 +- **Full Cancellation Support**: User-provided `CancellationToken` propagates through the async pipeline; rebalance + operations support cancellation at all stages + +### Decision-Driven Rebalance Execution + +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 (User Thread - 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 synchronously** - validation is CPU-only (microseconds), happens in user thread 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:** + +- [Concurrency Model](docs/concurrency-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 --- ## 🎯 Sliding Window Cache Concept -Traditional caches work with individual keys. A sliding window cache, in contrast, operates on **continuous ranges** of data: +Traditional caches work with individual keys. A sliding window cache, in contrast, operates on **continuous ranges** of +data: 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 @@ -80,7 +162,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. +The **left** and **right buffers** are calculated as multiples of the requested range size using the `leftCacheSize` and +`rightCacheSize` coefficients. ### Visual: Rebalance Trigger @@ -120,7 +203,8 @@ 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. +**Key insight:** Threshold percentages are calculated based on the **total cache window size**, not individual buffer +sizes. --- @@ -128,7 +212,8 @@ rightThreshold = 0.2 (20% of 400 = 80 items) ### 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: +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) @@ -145,16 +230,19 @@ The cache supports two materialization strategies, configured at creation time v **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 **Disadvantages:** + - ❌ **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) **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 @@ -166,16 +254,19 @@ The cache supports two materialization strategies, configured at creation time v **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 @@ -183,16 +274,19 @@ The cache supports two materialization strategies, configured at creation time v ### Choosing a Read Mode -| Scenario | Recommended Mode | -|-----------------------------------------------------|------------------| -| High read-to-rebalance ratio (e.g., 100:1) | **Snapshot** | -| Frequent rebalancing (e.g., random access patterns) | **CopyOnRead** | -| Large cache sizes (>85KB arrays) | **CopyOnRead** | -| Read-once patterns | **CopyOnRead** | -| Repeated reads of the same range | **Snapshot** | -| Memory-constrained systems | **CopyOnRead** | +**Quick Decision Guide:** -**For detailed comparison and multi-level cache composition patterns, see [Storage Strategies Guide](docs/storage-strategies.md).** +| 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 | + +**For detailed comparison, performance benchmarks, multi-level cache composition patterns, and staging buffer +implementation details, see [Storage Strategies Guide](docs/storage-strategies.md).** --- @@ -237,19 +331,22 @@ foreach (var item in data.Span) ## ⚙️ Configuration -The `WindowCacheOptions` class provides fine-grained control over cache behavior. Understanding these parameters is essential for optimal performance. +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 @@ -258,22 +355,29 @@ The `WindowCacheOptions` class provides fine-grained control over cache behavior #### Threshold Policies **`leftThreshold`** (double, default: 0.2) + - **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 +- **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) **`rightThreshold`** (double, default: 0.2) + - **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 +- **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) -**⚠️ 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. +**⚠️ 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. #### Debouncing **`debounceDelay`** (TimeSpan, default: 50ms) + - **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 @@ -283,6 +387,7 @@ The `WindowCacheOptions` class provides fine-grained control over cache behavior ### Configuration Examples **Forward-heavy scrolling** (e.g., log viewer, video player): + ```csharp var options = new WindowCacheOptions( leftCacheSize: 0.5, // Minimal backward buffer @@ -293,6 +398,7 @@ var options = new WindowCacheOptions( ``` **Bidirectional navigation** (e.g., paginated data grid): + ```csharp var options = new WindowCacheOptions( leftCacheSize: 1.5, // Balanced backward buffer @@ -303,6 +409,7 @@ var options = new WindowCacheOptions( ``` **Aggressive prefetching with stability** (e.g., high-latency data source): + ```csharp var options = new WindowCacheOptions( leftCacheSize: 2.0, @@ -317,7 +424,9 @@ var options = new WindowCacheOptions( ## 📊 Optional Diagnostics -The cache supports optional diagnostics for monitoring behavior, measuring performance, and validating system invariants. This is useful for: +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 @@ -327,7 +436,8 @@ The cache supports optional diagnostics for monitoring behavior, measuring perfo **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`: +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`: - ❌ Silent failures in background operations - ❌ Cache stops rebalancing with no indication @@ -357,6 +467,7 @@ public class LoggingCacheDiagnostics : ICacheDiagnostics ``` 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 @@ -385,9 +496,12 @@ Console.WriteLine($"Rebalances completed: {diagnostics.RebalanceExecutionComplet ### 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. +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).** +**For complete metric descriptions, custom implementations, and advanced patterns, +see [Diagnostics Guide](docs/diagnostics.md).** --- @@ -396,41 +510,81 @@ If no diagnostics instance is provided (default), the cache uses `NoOpDiagnostic For detailed architectural documentation, see: ### Mathematical Foundations -- **[Intervals.NET](https://github.com/blaze6950/Intervals.NET)** - Robust interval and range handling library that underpins cache logic. See README and documentation for core concepts like `Range`, `Domain`, `RangeData`, and interval operations. + +- **[Intervals.NET](https://github.com/blaze6950/Intervals.NET)** - Robust interval and range handling library that + underpins cache logic. See README and documentation for core concepts like `Range`, `Domain`, `RangeData`, and + interval operations. ### Core Architecture - **[Invariants](docs/invariants.md)** - Complete list of system invariants and guarantees -- **[Scenario Model](docs/scenario-model.md)** - Temporal behavior scenarios (User Path, Decision Path, Rebalance Execution) +- **[Scenario Model](docs/scenario-model.md)** - Temporal behavior scenarios (User Path, Decision Path, Rebalance + Execution) - **[Actors & Responsibilities](docs/actors-and-responsibilities.md)** - System actors and invariant ownership mapping -- **[Actors to Components Mapping](docs/actors-to-components-mapping.md)** - How architectural actors map to concrete components -- **[Cache State Machine](docs/cache-state-machine.md)** - Formal state machine with mutation ownership and concurrency semantics +- **[Actors to Components Mapping](docs/actors-to-components-mapping.md)** - How architectural actors map to concrete + components +- **[Cache State Machine](docs/cache-state-machine.md)** - Formal state machine with mutation ownership and concurrency + semantics - **[Concurrency Model](docs/concurrency-model.md)** - Single-writer architecture and eventual consistency model ### Implementation Details - **[Component Map](docs/component-map.md)** - Comprehensive component catalog with responsibilities and interactions -- **[Storage Strategies](docs/storage-strategies.md)** - Detailed comparison of Snapshot vs. CopyOnRead modes and multi-level cache patterns +- **[Storage Strategies](docs/storage-strategies.md)** - Detailed comparison of Snapshot vs. CopyOnRead modes and + multi-level cache patterns - **[Diagnostics](docs/diagnostics.md)** - Optional instrumentation and observability guide ### Testing Infrastructure -- **[Invariant Test Suite README](tests/SlidingWindowCache.Invariants.Tests/README.md)** - Comprehensive invariant test suite with deterministic synchronization -- **[Benchmark Suite README](benchmarks/SlidingWindowCache.Benchmarks/README.md)** - BenchmarkDotNet performance benchmarks - - **RebalanceFlowBenchmarks** - Behavior-driven rebalance cost analysis (Fixed/Growing/Shrinking span patterns) - - **UserFlowBenchmarks** - User-facing API latency (Full hit, Partial hit, Full miss scenarios) - - **ScenarioBenchmarks** - End-to-end cold start performance - - **Storage Strategy Comparison** - Snapshot vs CopyOnRead allocation and performance tradeoffs across all suites -- **Deterministic Testing**: `WaitForIdleAsync()` API provides race-free synchronization with background rebalance operations for testing, graceful shutdown, health checks, and integration scenarios +- **[Invariant Test Suite README](tests/SlidingWindowCache.Invariants.Tests/README.md)** - Comprehensive invariant test + suite with deterministic synchronization +- **[Benchmark Suite README](benchmarks/SlidingWindowCache.Benchmarks/README.md)** - BenchmarkDotNet performance + benchmarks + - **RebalanceFlowBenchmarks** - Behavior-driven rebalance cost analysis (Fixed/Growing/Shrinking span patterns) + - **UserFlowBenchmarks** - User-facing API latency (Full hit, Partial hit, Full miss scenarios) + - **ScenarioBenchmarks** - End-to-end cold start performance + - **Storage Strategy Comparison** - Snapshot vs CopyOnRead allocation and performance tradeoffs across all suites +- **Deterministic Testing**: `WaitForIdleAsync()` API provides race-free synchronization with background rebalance + operations for testing, graceful shutdown, health checks, and integration scenarios ### Key Architectural Principles -1. **Cache Contiguity**: Cache data must always remain contiguous (no gaps). Non-intersecting requests fully replace the cache. -2. **Multi-Stage Rebalance Validation**: Rebalance necessity determined by CPU-only analytical decision pipeline (NoRebalanceRange validation, cache geometry analysis). Rebalance is opportunistic and may be skipped when validation determines it's unnecessary. -3. **Intent Semantics**: Intents represent observed access patterns (signals), not mandatory work (commands). Publishing an intent does not guarantee rebalance execution. -4. **Single-Writer Architecture**: Only Rebalance Execution writes to cache state; User Path is read-only. Cancellation serves as mechanical coordination tool (prevents concurrent executions), not a decision mechanism. -5. **User Path Priority**: User requests always served immediately. When rebalance validation confirms necessity, pending rebalance is cancelled and rescheduled with new validated parameters. -6. **Lock-Free Concurrency**: Intent management uses `Interlocked.Exchange` for atomic operations - no locks, no race conditions, guaranteed progress. Validated under concurrent load in test suite. +1. **Single-Writer Architecture**: Only Rebalance Execution writes to cache state; User Path is read-only. This + eliminates race conditions through architectural constraints rather than locks. + See [Concurrency Model](docs/concurrency-model.md). + +2. **Decision-Driven Execution**: Rebalance necessity determined by synchronous CPU-only analytical validation in user + thread (microseconds). Enables immediate work avoidance and prevents intent thrashing. + See [Invariants - Section D](docs/invariants.md#d-rebalance-decision-path-invariants). + +3. **Multi-Stage Validation Pipeline**: + - Stage 1: NoRebalanceRange containment check (fast-path rejection) + - Stage 2: Pending rebalance coverage check (anti-thrashing) + - Stage 3: Desired == Current check (no-op prevention) + + Rebalance executes ONLY if ALL stages confirm necessity. + See [Scenario Model - Decision Path](docs/scenario-model.md#ii-rebalance-decision-path--decision-scenarios). + +4. **Smart Eventual Consistency**: Cache converges to optimal configuration asynchronously while avoiding unnecessary + work through validation. System prioritizes decision correctness and work avoidance over aggressive rebalance + responsiveness. + See [Concurrency Model - Smart Eventual Consistency](docs/concurrency-model.md#smart-eventual-consistency-model). + +5. **Intent Semantics**: Intents represent observed access patterns (signals), not mandatory work (commands). Publishing + an intent does not guarantee rebalance execution - validation determines necessity. + See [Invariants C.24](docs/invariants.md). + +6. **Cache Contiguity Rule**: Cache data must always remain contiguous (no gaps allowed). Non-intersecting requests + fully replace the cache rather than creating partial/gapped states. See [Invariants A.9a](docs/invariants.md). + +7. **User Path Priority**: User requests always served immediately. When validation confirms new rebalance is necessary, + pending rebalance is cancelled and rescheduled. Cancellation is mechanical coordination (prevents concurrent + executions), not a decision mechanism. See [Cache State Machine](docs/cache-state-machine.md). + +8. **Lock-Free Concurrency**: Intent management uses `Volatile.Read/Write` for safe memory visibility - no locks, no + race conditions, guaranteed progress. Thread-safety achieved through architectural constraints (single-writer) and + atomic reference operations. + See [Concurrency Model - Lock-Free Implementation](docs/concurrency-model.md#lock-free-implementation). --- @@ -451,15 +605,15 @@ For detailed architectural documentation, see: 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 + - 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 + - Packages library with symbols and source link + - Publishes to NuGet.org with skip-duplicate + - Stores package artifacts in workflow runs ### WebAssembly Support @@ -483,12 +637,14 @@ Install-Package SlidingWindowCache ``` **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**: + - Intervals.NET.Data (>= 0.0.1) - Intervals.NET.Domain.Default (>= 0.0.2) - Intervals.NET.Domain.Extensions (>= 0.0.3) @@ -498,7 +654,9 @@ Install-Package SlidingWindowCache ## 🤝 Contributing & Feedback -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. +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. ### We Welcome @@ -511,11 +669,14 @@ This project is a **personal R&D and engineering exploration** focused on cache ### How to Contribute -- **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 +- **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 -This project benefits from community feedback while maintaining a focused research direction. All constructive input helps improve the library's design, implementation, and documentation. +This project benefits from community feedback while maintaining a focused research direction. All constructive input +helps improve the library's design, implementation, and documentation. --- diff --git a/docs/actors-and-responsibilities.md b/docs/actors-and-responsibilities.md index eaf1dda..08e5397 100644 --- a/docs/actors-and-responsibilities.md +++ b/docs/actors-and-responsibilities.md @@ -54,26 +54,44 @@ The UserRequestHandler NEVER invokes directly decision logic - it just publishes ## 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. +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 / ThreadPool** +**Lives in: User Thread** (invoked synchronously by IntentController during intent publication) + +**Critical Execution Model:** +``` +Decision Engine executes SYNCHRONOUSLY in user thread. +This is intentional and critical for handling bursts and preventing intent thrashing. +Decision logic is CPU-only, side-effect free, lightweight (microseconds). +``` **Visibility:** -- **Not visible to User Path** -- Invoked only by RebalanceScheduler -- May execute many times, results may be discarded +- **Not visible to external users** +- **Owned and invoked by IntentController** (not by Scheduler) +- Invoked synchronously during IntentController.PublishIntent() +- Executes inline with user request (before Task.Run) +- May execute many times, work avoidance allows skipping scheduling entirely **Critical Rule:** ``` -DecisionEngine lives strictly inside the background contour. -DecisionEngine is the ONLY authority for rebalance necessity determination. +DecisionEngine lives in the user thread synchronous execution path. +DecisionEngine is THE ONLY authority for rebalance necessity determination. +All execution decisions flow from this component's analytical validation. +Decision happens BEFORE background scheduling, preventing work buildup. +IntentController OWNS the DecisionEngine instance. ``` -**Multi-Stage Validation Pipeline:** -1. **Stage 1**: Current Cache NoRebalanceRange containment check (fast path) +**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, conceptual) -3. **Stage 3**: DesiredCacheRange vs CurrentCacheRange equality check +3. **Stage 3**: 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) @@ -82,9 +100,9 @@ DecisionEngine is the ONLY authority for rebalance necessity determination. - 27. No rebalance if DesiredCacheRange == CurrentCacheRange (Stage 3 validation) - 28. Rebalance triggered only if ALL validation stages confirm necessity -**Responsibility Type:** ensures correctness of rebalance necessity decisions through analytical validation +**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. +**Note:** Not a top-level actor — internal tool of IntentManager/Executor pipeline, but THE authority for necessity determination and work avoidance. --- @@ -123,30 +141,44 @@ Manages lifecycle of rebalance intents, orchestrates decision pipeline, and coor **Implementation:** This logical actor is internally decomposed into two components for separation of concerns: -- **IntentController** (Intent Controller) - intent identity, lifecycle, cancellation -- **RebalanceScheduler** (Execution Scheduler) - timing, debounce, pipeline orchestration (stateless, plus Task tracking for infrastructure/testing) +- **IntentController** (Intent Controller) - owns DecisionEngine, intent lifecycle, cancellation coordination, decision invocation +- **RebalanceScheduler** (Execution Scheduler) - timing, debounce, background execution orchestration (owned by IntentController) **Execution Context:** -**Lives in: Background / ThreadPool** +**Mixed:** +- **User Thread**: PublishIntent(), decision evaluation, cancellation, scheduling setup (all synchronous) +- **Background / ThreadPool**: Only the scheduled execution task (after Task.Run in Scheduler) + +**Ownership Hierarchy:** +``` +IntentController (User Thread) +├── owns DecisionEngine (invokes synchronously) +├── owns RebalanceScheduler (creates in constructor) +│ └── owns RebalanceExecutor (passed to Scheduler) +└── owns _pendingRebalance snapshot (Volatile.Read/Write) +``` -**Enhanced Role (Corrected Model):** +**Enhanced Role (Decision-Driven Model):** Now responsible for: -- **Receiving intents** (on every user request) [Intent Controller] -- **Intent identity and versioning** [Intent Controller] -- **Cancellation coordination** based on validation results [Intent Controller] -- **Deduplication** and debouncing [Execution Scheduler] -- **Single-flight execution** enforcement [Execution Scheduler] +- **Receiving intents** (on every user request) [Intent Controller - User Thread] +- **Owning and invoking DecisionEngine** [Intent Controller - User Thread, synchronous] +- **Intent identity and versioning** via PendingRebalance snapshot [Intent Controller] +- **Cancellation coordination** based on validation results from owned DecisionEngine [Intent Controller] +- **Deduplication** via synchronous decision evaluation [Intent Controller - User Thread] +- **Debouncing** [Execution Scheduler - Background] +- **Single-flight execution** enforcement [Both components via cancellation] - **Starting background tasks** [Execution Scheduler] -- **Orchestrating the decision pipeline**: [Execution Scheduler] - 1. Invoke DecisionEngine (multi-stage validation) - 2. If ALL stages pass validation, invoke Executor - 3. If validation rejects, skip execution (no-op) - 4. Handle cancellation when new validated rebalance is needed +- **Orchestrating the validation-driven decision pipeline**: [Intent Controller - User Thread, synchronous] + 1. **IntentController.PublishIntent()** invokes owned DecisionEngine synchronously (User Thread) + 2. If ALL validation stages pass → cancel old pending, schedule new via Scheduler + 3. If validation rejects → return immediately (work avoidance, no Task.Run) + 4. **Scheduler.ScheduleRebalance()** creates PendingRebalance, schedules Task.Run (returns synchronously) + 5. **Background Task** performs debounce delay + ExecuteAsync (only this part is async) -**Authority:** *Owns time and concurrency, orchestrates validation-driven execution.* +**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 determines rebalance necessity; the Intent Manager coordinates execution based on those decisions. +**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 synchronously in user thread, enabling immediate 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 diff --git a/docs/actors-to-components-mapping.md b/docs/actors-to-components-mapping.md index e4cd53c..39cb0f7 100644 --- a/docs/actors-to-components-mapping.md +++ b/docs/actors-to-components-mapping.md @@ -37,39 +37,72 @@ User Thread ▼ ═══════════════════════════════════════════════════════════ -Background / ThreadPool +═══════════════════════════════════════════════════════════ +User Thread (Synchronous) ═══════════════════════════════════════════════════════════ +┌───────────────────────┐ +│ SlidingWindowCache │ ← Public Facade +└───────────┬───────────┘ + │ + ▼ +┌───────────────────────┐ +│ UserRequestHandler │ ← Fast user-facing logic +└───────────┬───────────┘ + │ + │ publish rebalance intent (synchronous) + │ + ▼ ┌───────────────────────────┐ -│ RebalanceIntentManager │ ← Temporal Authority -│ │ • debounce / cancel obsolete -│ │ • enforce single-flight -└───────────┬───────────────┘ • schedule execution +│ IntentController │ ← Intent Lifecycle & Orchestration +│ (Rebalance Intent Mgr) │ • owns DecisionEngine +│ │ • owns RebalanceScheduler +└───────────┬───────────────┘ • invokes decision synchronously │ - │ invoke decision pipeline + │ invoke DecisionEngine (synchronous, CPU-only) │ ▼ ┌───────────────────────────┐ -│ RebalanceDecisionEngine │ ← Pure Decision Logic +│ RebalanceDecisionEngine │ ← Pure Decision Logic (User Thread!) │ │ • NoRebalanceRange check │ + CacheGeometryPolicy │ • DesiredCacheRange computation └───────────┬───────────────┘ • allow/block execution │ - │ if execution allowed + │ if validation confirms necessity + │ + ▼ +┌───────────────────────────┐ +│ ScheduleRebalance() │ ← Creates Task.Run (returns synchronously) +└───────────┬───────────────┘ + │ + │ Task.Run() - HERE background starts ⚡→🔄 + │ + ▼ + +═══════════════════════════════════════════════════════════ +Background / ThreadPool (After Task.Run) +═══════════════════════════════════════════════════════════ + + ▼ +┌───────────────────────────┐ +│ Debounce Delay │ ← Wait before execution +└───────────┬───────────────┘ │ ▼ ┌───────────────────────────┐ -│ RebalanceExecutor │ ← Mutating Actor +│ RebalanceExecutor │ ← Mutating Actor (I/O operations) └───────────┬───────────────┘ │ │ atomic mutation │ ▼ ┌───────────────────────────┐ -│ CacheStateManager │ ← Consistency Guardian +│ CacheState │ ← Consistency (single-writer) └───────────────────────────┘ ``` +**Critical:** Everything up to Task.Run happens **synchronously in user thread**. Only debounce + actual execution happen in background. + --- ## 1. SlidingWindowCache (Public Facade) @@ -183,30 +216,42 @@ return _userRequestHandler.HandleRequestAsync(requestedRange, cancellationToken) ### Execution Context -**Lives in: Background / ThreadPool** +**Lives in: User Thread** (invoked synchronously by IntentController) + +**Critical:** Decision evaluation happens SYNCHRONOUSLY in user thread before any background scheduling. ### Visibility -- **Not visible to User Path** -- Invoked only by RebalanceScheduler -- May execute many times, results may be discarded +- **Not visible to external users** +- **Owned by IntentController** (composed in constructor) +- Invoked synchronously by IntentController.PublishIntent() +- Executes inline with user request (before Task.Run) +- 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 lives strictly inside the background contour. -DecisionEngine is the SOLE AUTHORITY for rebalance necessity determination. +DecisionEngine executes SYNCHRONOUSLY in user thread. +DecisionEngine is THE SOLE AUTHORITY for rebalance necessity determination. +Decision happens BEFORE background scheduling (prevents work buildup, intent thrashing). ``` ### Responsibilities -- **THE authority for rebalance necessity determination** -- Evaluates whether rebalance is required through multi-stage validation: - - **Stage 1**: NoRebalanceRange containment check (fast path) - - **Stage 2**: Conceptual anti-thrashing validation (pending desired cache) - - **Stage 3**: DesiredCacheRange vs CurrentCacheRange equality check -- Produces analytical decision (execute or skip) -- Rebalance executes ONLY if ALL validation stages confirm necessity +- **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**: Conceptual anti-thrashing validation (pending desired cache coverage) + - **Stage 3**: 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 @@ -263,7 +308,9 @@ shape to target). ### Execution Context -**Lives in: Background / ThreadPool** (invoked by RebalanceDecisionEngine) +**Mixed:** +- **User Thread**: PublishIntent(), DecisionEngine.Evaluate(), ScheduleRebalance() (all synchronous) +- **Background / ThreadPool**: Only the Task.Run lambda in Scheduler (debounce + execution) (invoked by RebalanceDecisionEngine) ### Component Responsibilities @@ -322,55 +369,65 @@ but externally appears as a unified policy concept. **Implemented as:** Two internal components working together as a unified actor: 1. **IntentController** - - `internal class IntentController` - - File: `src/SlidingWindowCache/CacheRebalance/IntentController.cs` - - Owns intent identity and cancellation lifecycle - - Exposes `CancelPendingRebalance()` and `PublishIntent()` to User Path + - `internal sealed class IntentController` + - File: `src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs` + - **Owns DecisionEngine** (composes in constructor) + - **Owns RebalanceScheduler** (creates in constructor) + - Manages intent lifecycle and cancellation via PendingRebalance snapshot + - Invokes DecisionEngine synchronously in PublishIntent() + - Exposes `CancelPendingRebalance()` and `PublishIntent()` methods 2. **RebalanceScheduler (Execution Scheduler)** - - `internal class RebalanceScheduler` - - File: `src/SlidingWindowCache/CacheRebalance/RebalanceScheduler.cs` - - Owns debounce timing and background execution - - Orchestrates DecisionEngine → Executor pipeline based on validation results - - Ensures single-flight execution + - `internal sealed class RebalanceScheduler` + - File: `src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs` + - **Owned by IntentController** (created in constructor) + - Handles debounce timing and background execution + - ScheduleRebalance() is synchronous (creates Task.Run, returns PendingRebalance) + - Ensures single-flight execution via Task lifecycle - **Intentionally stateless** - does not own intent identity - - **Task tracking** - provides `WaitForIdleAsync()` for deterministic synchronization (infrastructure/testing) + - **Task tracking** - provides ExecutionTask on PendingRebalance for deterministic synchronization (infrastructure/testing) -**Key Principle:** The logical actor (Rebalance Intent Manager) is decomposed into -two cooperating components for separation of concerns, but externally appears as -a single unified actor. +**Key Principle:** IntentController is the owner/orchestrator. It owns both DecisionEngine and RebalanceScheduler, invokes DecisionEngine synchronously, and delegates background execution to Scheduler. ### Execution Context **Lives in: Background / ThreadPool** -### Enhanced Role (Corrected Model) +### Enhanced Role (Decision-Driven Model) The Rebalance Intent Manager actor is responsible for: -- **Receiving intents** (on every user request) [Intent Controller responsibility] -- **Intent lifecycle management** (identity, versioning) [Intent Controller responsibility] -- **Cancellation coordination** based on validation results [Intent Controller responsibility] -- **Deduplication** and debouncing [Execution Scheduler responsibility] -- **Single-flight execution** enforcement [Execution Scheduler responsibility] -- **Starting background tasks** [Execution Scheduler responsibility] -- **Orchestrating the validation-driven decision pipeline**: [Execution Scheduler responsibility] - 1. Invoke DecisionEngine (multi-stage analytical validation) - 2. If ALL stages pass validation, invoke Executor - 3. If validation rejects, skip execution (no-op) - 4. Handle cancellation when new validated rebalance is needed +- **Receiving intents** (on every user request) [IntentController.PublishIntent() - User Thread, synchronous] +- **Owning and invoking DecisionEngine** [IntentController owns, invokes synchronously] +- **Intent lifecycle management** via PendingRebalance snapshot [IntentController - Volatile.Read/Write] +- **Cancellation coordination** based on validation results from owned DecisionEngine [IntentController - User Thread] +- **Immediate work avoidance** through synchronous decision evaluation [IntentController - User Thread] +- **Debouncing** [Execution Scheduler - Background, after Task.Run] +- **Single-flight execution** enforcement [Both components via cancellation + Task lifecycle] +- **Starting background tasks** [Execution Scheduler - ScheduleRebalance creates Task.Run] +- **Orchestrating the validation-driven decision pipeline**: [IntentController - User Thread, SYNCHRONOUS] + 1. **IntentController.PublishIntent()** invokes owned DecisionEngine synchronously (User Thread, CPU-only) + 2. **DecisionEngine.Evaluate()** performs multi-stage validation (User Thread, CPU-only) + 3. If validation rejects → return immediately (work avoidance, no Task.Run scheduled) + 4. If validation confirms → cancel old pending, call Scheduler.ScheduleRebalance() + 5. **Scheduler.ScheduleRebalance()** creates PendingRebalance, schedules Task.Run (returns synchronously to user thread) + 6. **Background Task** (only part that's async) performs debounce delay + ExecuteAsync + +**Key Principle:** IntentController is the owner/orchestrator. It **owns DecisionEngine** and invokes it **synchronously in user thread** during PublishIntent(), enabling immediate work avoidance and preventing intent thrashing. The **DecisionEngine (owned by IntentController) is THE sole authority** for necessity determination. This separation enables **smart eventual consistency** through work avoidance: the system converges to optimal configuration while avoiding unnecessary operations. ### Component Responsibilities #### Intent Controller (IntentController) -- Owns `CancellationTokenSource` for current intent +- Owns pending rebalance snapshot (`_pendingRebalance` field accessed via `Volatile.Read/Write`) - Provides `CancelPendingRebalance()` for User Path priority - Provides `PublishIntent()` to receive new intents -- Invalidates previous intent when new intent arrives +- Invalidates previous intent when new intent arrives (via PendingRebalance.Cancel()) - Does NOT perform scheduling or timing logic -- Does NOT orchestrate execution pipeline +- Does NOT orchestrate execution pipeline - Does NOT determine rebalance necessity (DecisionEngine's job) -- **Lock-free implementation** using `Interlocked.Exchange` for atomic operations +- Does NOT own CancellationTokenSource lifecycle (PendingRebalance domain object does) +- **Lock-free implementation** using `Volatile.Read/Write` for safe memory visibility +- **DDD-style cancellation** - PendingRebalance domain object encapsulates CancellationTokenSource - **Thread-safe without locks** - no race conditions, no blocking - Validated by `ConcurrencyStabilityTests` under concurrent load @@ -559,19 +616,22 @@ RebalanceExecutor ### Key Principle -🔑 **DecisionEngine lives strictly within the background contour.** +🔑 **DecisionEngine executes SYNCHRONOUSLY in user thread (before Task.Run), enabling immediate work avoidance and preventing intent thrashing.** ### Actor Execution Contexts -| Actor | Execution Context | Invoked By | -|----------------------------|-----------------------|--------------------------| -| UserRequestHandler | User Thread | User (public API) | -| IntentController | Background/ThreadPool | UserRequestHandler | -| RebalanceScheduler | Background/ThreadPool | IntentController | -| RebalanceDecisionEngine | Background/ThreadPool | RebalanceScheduler | -| CacheGeometryPolicy | Background/ThreadPool | RebalanceDecisionEngine | -| RebalanceExecutor | Background/ThreadPool | RebalanceScheduler | -| CacheStateManager | Both (with locking) | Both paths (coordinated) | +| Actor | Execution Context | Invoked By | +|----------------------------|---------------------------------------|-------------------------------| +| UserRequestHandler | User Thread | User (public API) | +| IntentController | **User Thread (synchronous)** | UserRequestHandler | +| RebalanceDecisionEngine | **User Thread (synchronous)** | IntentController | +| CacheGeometryPolicy | **User Thread (synchronous)** | RebalanceDecisionEngine | +| RebalanceScheduler | **User Thread** (scheduling) | IntentController | +| RebalanceScheduler (Task) | Background/ThreadPool (execution) | Task.Run | +| RebalanceExecutor | Background/ThreadPool | RebalanceScheduler background | +| CacheStateManager | Both (User: reads, Background: writes)| Both paths (single-writer) | + +**Critical:** Everything up to `Task.Run` happens synchronously in user thread. Only debounce + actual execution happen in background. ### Responsibilities Refixed diff --git a/docs/cache-state-machine.md b/docs/cache-state-machine.md index 0599922..7334fa0 100644 --- a/docs/cache-state-machine.md +++ b/docs/cache-state-machine.md @@ -95,16 +95,16 @@ The cache exists in one of three states: 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. Rebalance Decision Engine validates necessity via multi-stage pipeline - 5. If validation confirms necessity, pending rebalance is cancelled and new execution scheduled - 6. If validation rejects (NoRebalanceRange containment, Desired==Current), no cancellation occurs - 7. Rebalance Execution writes to cache (background, only if validated) -- **Mutation:** Performed by Rebalance Execution ONLY - - User Path does NOT mutate cache, LastRequested, or NoRebalanceRange + 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, LastRequested, or NoRebalanceRange (read-only) - Rebalance Execution normalizes cache to DesiredCacheRange (only if validated) - **Concurrency:** User Path is read-only; no race conditions -- **Cancellation:** Driven by validation results, not automatic -- **Postcondition:** Cache logically enters `Rebalancing` state (background process active, only if validated) +- **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 @@ -120,20 +120,21 @@ The cache exists in one of three states: - **Atomicity:** Changes applied atomically (Invariant 12) - **Postcondition:** Cache returns to stable `Initialized` state -### T4: Rebalancing → Initialized (User Request Cancels Rebalance) +### 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), Rebalance Execution (yields if cancelled) +- **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. Rebalance Decision Engine validates necessity of new rebalance - 5. **If validation confirms necessity**, pending rebalance is cancelled and new execution scheduled - 6. **If validation rejects**, pending rebalance continues (no cancellation) - 7. Cancelled rebalance yields; new rebalance uses new intent's delivered data (if validated) -- **Critical Rule:** User Path does NOT mutate cache; validation determines if cancellation occurs -- **Priority:** User Path priority enforced via validation-driven cancellation, not automatic cancellation -- **Note:** Cancellation is mechanical coordination (single-writer), not a decision mechanism + 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 --- diff --git a/docs/component-map.md b/docs/component-map.md index e21efca..fc9ea38 100644 --- a/docs/component-map.md +++ b/docs/component-map.md @@ -161,18 +161,31 @@ The system uses a **multi-stage rebalance decision pipeline**, not a cancellatio ### System Stability Principle -The system prioritizes **decision correctness and work avoidance** over aggressive rebalance responsiveness. +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 -- Stage 2: Avoid redundant rebalance if pending execution covers request -- Stage 3: Avoid no-op mutations if cache already optimal +- 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) + +**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 -- ✅ Reduces redundant I/O operations -- ✅ Improves system stability under rapid access pattern changes -- ⚠️ May delay cache optimization by debounce period +- ✅ 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 [Concurrency Model - Smart Eventual Consistency](concurrency-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 --- @@ -688,24 +701,32 @@ internal sealed class IntentController **Fields**: - `RebalanceScheduler _scheduler` (readonly) -- ✏️ `CancellationTokenSource? _currentIntentCts` - **Mutable**, tracks current intent +- `RebalanceDecisionEngine _decisionEngine` (readonly) +- `CacheState _state` (readonly reference to shared state) +- ✏️ `PendingRebalance? _pendingRebalance` - **Mutable**, tracks current pending rebalance (accessed via Volatile.Read/Write) **Key Methods**: -**`PublishIntent(Range requestedRange)`**: +**`PublishIntent(Intent intent)`**: ```csharp -public void PublishIntent(Range requestedRange) +public void PublishIntent(Intent intent) { - // 1. Invalidate previous intent - _currentIntentCts?.Cancel(); - _currentIntentCts?.Dispose(); + // 1. Evaluate necessity via DecisionEngine (THE authority) + var pendingSnapshot = Volatile.Read(ref _pendingRebalance); + var decision = _decisionEngine.Evaluate(intent.RequestedRange, _state, pendingSnapshot); - // 2. Create new intent identity - _currentIntentCts = new CancellationTokenSource(); - var intentToken = _currentIntentCts.Token; + // 2. If validation rejects, skip entirely (work avoidance) + if (!decision.ShouldSchedule) return; - // 3. Delegate to scheduler - _scheduler.ScheduleRebalance(requestedRange, intentToken); + // 3. Cancel pending via domain object (validation-driven cancellation) + var oldPending = Volatile.Read(ref _pendingRebalance); + oldPending?.Cancel(); + + // 4. Delegate to scheduler, capture returned PendingRebalance + var newPending = _scheduler.ScheduleRebalance(intent, decision); + + // 5. Update snapshot for next Stage 2 validation + Volatile.Write(ref _pendingRebalance, newPending); } ``` @@ -713,37 +734,48 @@ public void PublishIntent(Range requestedRange) ```csharp public void CancelPendingRebalance() { - if (_currentIntentCts != null) - { - _currentIntentCts.Cancel(); - _currentIntentCts.Dispose(); - _currentIntentCts = null; - } + var pending = Volatile.Read(ref _pendingRebalance); + if (pending == null) return; + + // DDD-style cancellation through domain object + pending.Cancel(); + Volatile.Write(ref _pendingRebalance, null); } ``` **`WaitForIdleAsync(TimeSpan? timeout = null)`** (Infrastructure/Testing): ```csharp -public Task WaitForIdleAsync(TimeSpan? timeout = null) +public async Task WaitForIdleAsync(TimeSpan? timeout = null) { - // Delegate to RebalanceScheduler's Task tracking mechanism - return _scheduler.WaitForIdleAsync(timeout); + // Observe-and-stabilize pattern using PendingRebalance.ExecutionTask + while (stopwatch.Elapsed < maxWait) + { + var observedPending = Volatile.Read(ref _pendingRebalance); + if (observedPending?.ExecutionTask == null) return; + + await observedPending.ExecutionTask; + + var currentPending = Volatile.Read(ref _pendingRebalance); + if (ReferenceEquals(observedPending, currentPending)) return; + } } ``` **Characteristics**: -- ✅ Owns intent identity (CancellationTokenSource lifecycle) -- ✅ Single-flight enforcement (only one active intent) -- ✅ Exposes cancellation to User Path -- ✅ **Lock-free implementation** using `Interlocked.Exchange` for atomic operations +- ✅ Owns pending rebalance snapshot (`_pendingRebalance` field) +- ✅ Single-flight enforcement (only one active intent via cancellation) +- ✅ Exposes cancellation to User Path via `CancelPendingRebalance()` +- ✅ **Lock-free implementation** using `Volatile.Read/Write` for safe memory visibility +- ✅ **DDD-style cancellation** - PendingRebalance domain object encapsulates CancellationTokenSource - ✅ **Thread-safe without locks** - no race conditions, tested under concurrent load - ⚠️ **Intent does not guarantee execution** - execution is opportunistic -- ❌ **Does NOT**: Timing, scheduling, execution logic +- ❌ **Does NOT**: Timing, scheduling, execution logic, CTS lifecycle management **Concurrency Model**: -- Uses lightweight synchronization primitives (`Interlocked.Exchange`) +- Uses `Volatile.Read/Write` for safe memory visibility across threads - No locks, no `lock` statements, no mutexes -- Atomic field replacement ensures thread-safety +- Memory barriers via `Volatile` operations ensure correct ordering +- PendingRebalance domain object owns CancellationTokenSource lifecycle - Validated by `ConcurrencyStabilityTests` under concurrent load **Ownership**: @@ -751,12 +783,12 @@ public Task WaitForIdleAsync(TimeSpan? timeout = null) - Composes with RebalanceScheduler **Execution Context**: -- Synchronous methods (called from User Thread) -- Scheduled work executes in Background +- **PublishIntent() executes synchronously in User Thread** (includes decision evaluation) +- **Only scheduled work (Task.Run lambda) executes in Background ThreadPool** **State**: -- `_currentIntentCts` (mutable, nullable) -- Represents identity of latest intent +- `_pendingRebalance` (mutable, nullable, accessed via Volatile.Read/Write) +- Represents snapshot of current pending rebalance for Stage 2 validation **Responsibilities**: - Intent lifecycle management @@ -1527,21 +1559,24 @@ public Task WaitForIdleAsync(TimeSpan? timeout = null) #### Writers -**UserRequestHandler**: -- ✏️ Writes `LastRequested` property -- ✏️ Writes `Cache` (via `Rematerialize()`) - - **Purpose**: Expand cache to cover requested range - - **When**: User request needs data not in cache - - **Scope**: Expands only (never trims) - -**RebalanceExecutor**: +**RebalanceExecutor** (SOLE WRITER - single-writer architecture): - ✏️ Writes `Cache` (via `Rematerialize()`) - - **Purpose**: Normalize cache to DesiredCacheRange - - **When**: Rebalance execution completes - - **Scope**: Expands AND trims + - **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 + - **When**: After successful rebalance execution - ✏️ Writes `NoRebalanceRange` property - **Purpose**: Update threshold zone after normalization - - **When**: After successful rebalance + - **When**: After successful rebalance execution + +**UserRequestHandler** (READ-ONLY): +- ❌ Does NOT write to CacheState +- ❌ Does NOT call `Cache.Rematerialize()` +- ❌ Does NOT write to `LastRequested` or `NoRebalanceRange` +- ✅ Only reads from cache and IDataSource +- ✅ Publishes intent with delivered data for Rebalance Execution to process #### Readers @@ -1560,10 +1595,16 @@ public Task WaitForIdleAsync(TimeSpan? timeout = null) #### Coordination **No locks** (by design): -- Single consumer model (one logical user per cache) -- Coordination via **CancellationToken** -- User Path **always cancels** rebalance before mutations -- Rebalance **always checks** cancellation before mutations +- **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) @@ -1629,50 +1670,76 @@ The Sliding Window Cache follows a **single consumer model** as documented in `d - ✅ Only `CancellationToken` for coordination 3. **Coordination Mechanism** - - User Path cancels rebalance **before** any cache mutation - - Rebalance checks cancellation **before and during** execution - - Atomic array/list replacement in `Rematerialize()` + - **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 + - **No locks needed** - Single-writer eliminates write-write races, reference reads are atomic ### Thread Contexts -| Component | Thread Context | Notes | -|-----------------------------------|-------------------|----------------------------------------| -| **WindowCache** | Neutral | Just delegates | -| **UserRequestHandler** | ⚡ **User Thread** | Synchronous, fast path | -| **RebalanceIntentManager** | User Thread | Synchronous methods (called from user) | -| **RebalanceScheduler** | 🔄 **Background** | ThreadPool, async | -| **RebalanceDecisionEngine** | 🔄 **Background** | ThreadPool, pure logic | -| **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 | +| Component | Thread Context | Notes | +|-----------------------------------|-------------------|-----------------------------------------------------------| +| **WindowCache** | Neutral | Just delegates | +| **UserRequestHandler** | ⚡ **User Thread** | Synchronous, fast path | +| **IntentController** | ⚡ **User Thread** | Synchronous methods (PublishIntent, decision evaluation) | +| **RebalanceDecisionEngine** | ⚡ **User Thread** | Invoked synchronously by IntentController, CPU-only logic | +| **RebalanceScheduler (scheduling)**| ⚡ **User Thread** | ScheduleRebalance() is synchronous (creates Task) | +| **RebalanceScheduler (execution)**| 🔄 **Background** | Inside Task.Run - debounce + executor invocation | +| **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:** Decision logic and scheduling are **synchronous operations in user thread** (CPU-only, lightweight). Only the actual rebalance execution (I/O) happens in background ThreadPool. ### Concurrency Invariants (from `docs/invariants.md`) **A.1 Concurrency & Priority**: -- **-1**: User Path **MUST NOT execute concurrently** with Rebalance Execution -- **0**: User Path **always has higher priority** than Rebalance Execution -- **0a**: Every User Request **MUST cancel** any ongoing/pending Rebalance before mutations +- **-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 are **obsolete** after new 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 -#### User Request Flow (User Thread) +#### User Request Flow (User Thread - ALL SYNCHRONOUS until Task.Run) ``` 1. UserRequestHandler.HandleRequestAsync() called -2. FIRST STEP: _intentManager.CancelPendingRebalance() - └─> Cancels CancellationTokenSource - └─> Background rebalance receives cancellation signal -3. Check cache, extend if needed -4. Mutate cache (Rematerialize) - safe, rebalance is cancelled -5. Publish new intent -6. Return data +2. Read from cache or fetch missing data from IDataSource +3. Assemble data to return to user (NO cache mutation) +4. Return data to user immediately +5. Publish intent with delivered data (SYNCHRONOUS in user thread): + └─> IntentController.PublishIntent(intent) ⚡ USER THREAD + ├─> DecisionEngine.Evaluate() ⚡ USER THREAD + │ └─> Multi-stage validation (CPU-only, side-effect free) + │ - Stage 1: NoRebalanceRange check + │ - Stage 2: Pending coverage check + │ - Stage 3: Desired==Current check + ├─> If validation rejects: return immediately (work avoidance) + ├─> If validation confirms: oldPending?.Cancel() ⚡ USER THREAD + └─> Scheduler.ScheduleRebalance() ⚡ USER THREAD + ├─> Create PendingRebalance (synchronous) + └─> Task.Run(() => ...) ← HERE background starts 🔄 + └─> Debounce delay 🔄 BACKGROUND + └─> RebalanceExecutor.ExecuteAsync() 🔄 BACKGROUND + └─> I/O operations, cache mutations ``` +**Key:** Everything up to `Task.Run` happens **synchronously in user thread**. +Only debounce + actual execution happen in background. + +**Why This Matters:** +- User request burst → immediate validation in user thread → work avoidance +- No background queue buildup with pending decisions +- Intent thrashing prevented by synchronous validation +- Lightweight CPU-only operations don't block user thread (microseconds) + #### Rebalance Flow (Background Thread) ``` 1. RebalanceScheduler.ScheduleRebalance() in Task.Run() @@ -1730,7 +1797,7 @@ var sharedCache = new WindowCache(...); | 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 (_currentIntentCts) | No | WindowCache owns | 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 | diff --git a/docs/concurrency-model.md b/docs/concurrency-model.md index 24e510a..2b46069 100644 --- a/docs/concurrency-model.md +++ b/docs/concurrency-model.md @@ -30,48 +30,72 @@ The cache implements a **single-writer** concurrency model: ### Write Ownership -Only `RebalanceExecutor` may write to: -- Cache data and range (via `Cache.Rematerialize()`) -- `LastRequested` field -- `NoRebalanceRange` field +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) +- `NoRebalanceRange` property (via `internal set` - restricted to rebalance execution) -All other components have read-only access to cache state. +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 cache (read-only guarantee) -- Rebalance Execution performs atomic updates via `Rematerialize()` -- Cancellation ensures Rebalance Execution yields when new validated rebalance is scheduled -- Single-writer eliminates race conditions +- **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. ### Rebalance Validation vs Cancellation **Key Distinction:** -- **Rebalance Validation** = Decision mechanism (analytical, CPU-only, determines necessity) -- **Cancellation** = Coordination mechanism (mechanical, prevents concurrent executions) +- **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:** -**User Path Priority Model:** -1. User Path publishes intent with delivered data -2. Rebalance Decision Engine validates necessity via multi-stage pipeline -3. If validation confirms necessity, pending rebalance is cancelled and new execution scheduled -4. If validation rejects (NoRebalanceRange containment, Desired==Current), no cancellation occurs +Cancellation does NOT drive decisions; **validated rebalance necessity drives cancellation**. -**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). -### Eventual Consistency Model +### Smart Eventual Consistency Model -Cache state converges to optimal configuration asynchronously: +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 -3. **Rebalance Decision Engine** validates rebalance necessity (multi-stage pipeline) -4. **Cache state** updates occur in background via Rebalance Execution (only if validated) -5. **Debounce delay** controls convergence timing -6. **User correctness** never depends on cache state being up-to-date +2. **User Path** publishes intent with delivered data (**synchronously in user thread**) +3. **Rebalance Decision Engine** validates rebalance necessity through multi-stage analytical pipeline (**synchronously in user thread - CPU-only, side-effect free, lightweight**) +4. **Scheduling** creates PendingRebalance and schedules background Task (**synchronously in user thread**) +5. **Work avoidance**: Rebalance skipped if validation determines it's unnecessary (NoRebalanceRange containment, Desired==Current, pending rebalance coverage) - **all happens synchronously before Task.Run** +6. **Background execution** (only part that runs in ThreadPool): debounce delay + actual rebalance I/O operations +7. **Debounce delay** controls convergence timing and prevents thrashing (background) +8. **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 Synchronous:** + +The decision logic (multi-stage validation) and scheduling are **NOT background operations**. They execute **synchronously in the user thread** before returning control to the user. Only the actual rebalance execution (I/O operations) happens in background via `Task.Run`. + +This design is intentional and critical for handling user request bursts: +- ✅ **CPU-only validation** in user thread (math, conditions, no I/O) +- ✅ **Side-effect free** - just calculations +- ✅ **Lightweight** - completes in microseconds +- ✅ **Prevents intent thrashing** - validates necessity immediately, skips if not needed +- ✅ **No background queue buildup** - decisions made synchronously +- ⚠️ Only actual **I/O operations** (data fetching, cache mutation) happen in background + --- ## Single Cache Instance = Single Consumer @@ -209,24 +233,34 @@ Method exists only to expose idle synchronization through public API for testing **IntentController** uses lock-free synchronization: - **No locks, no `lock` statements, no mutexes** -- Uses `Interlocked.Exchange` for atomic field replacement -- `_currentIntentCts` field atomically swapped during intent operations +- Uses `Volatile.Read` and `Volatile.Write` for safe field access across threads +- `_pendingRebalance` field accessed with memory barriers via `Volatile` operations +- Encapsulates `CancellationTokenSource` within `PendingRebalance` domain object (DDD-style) - Thread-safe without blocking - guaranteed progress - Zero contention overhead -**Race Condition Prevention:** +**Safe Visibility Pattern:** ```csharp -// Atomic replacement ensures no race conditions -var oldCts = Interlocked.Exchange(ref _currentIntentCts, newCts); +// Read with memory barrier for safe observation +var pending = Volatile.Read(ref _pendingRebalance); + +// Write with memory barrier for safe publication +Volatile.Write(ref _pendingRebalance, newPending); ``` +**Domain-Driven Cancellation:** +- `PendingRebalance` domain object owns `CancellationTokenSource` lifecycle +- Cancellation invoked through domain object's `Cancel()` method +- Eliminates direct CTS management in IntentController (better encapsulation) + **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 primitive ensures thread-safety without the overhead -and complexity of traditional locking mechanisms. +This lightweight synchronization approach using `Volatile` operations ensures thread-safety +without the overhead and complexity of traditional locking mechanisms, while the DDD-style +domain object pattern provides clean encapsulation of cancellation infrastructure. ### Relation to Concurrency Model diff --git a/docs/scenario-model.md b/docs/scenario-model.md index c48cfd1..6e67a81 100644 --- a/docs/scenario-model.md +++ b/docs/scenario-model.md @@ -188,20 +188,28 @@ and violate the Cache Contiguity Rule (Invariant 9a). The cache MUST remain cont # II. REBALANCE DECISION PATH — Decision Scenarios -**Important**: Intent does not guarantee execution. Execution is opportunistic. +**Core Principle**: Rebalance necessity is determined by multi-stage analytical validation, not by intent existence. -Publishing a rebalance intent does NOT mean rebalance will execute. The decision path -may determine that execution is not needed (NoRebalanceRange containment, or -DesiredRange == CurrentRange), in which case execution is skipped. Additionally, -intents may be superseded or cancelled before execution begins. +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**: 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 -- may result in a no-op -- determines whether execution is required +- 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. +This path is always triggered by the User Path, but validation determines execution. --- @@ -388,11 +396,13 @@ The Sliding Window Cache follows these rules: ## 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. **U₂ cancels any pending rebalance work before performing its own cache mutations** 2. User Path for U₂ executes normally and immediately 3. A new rebalance trigger R₂ is issued @@ -407,11 +417,13 @@ No rebalance work is executed based on outdated user intent. User Path always ha ## 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. **U₂ cancels ongoing rebalance execution R₁ before performing its own cache mutations** 2. User Path for U₂ executes normally and immediately 3. R₂ becomes the latest rebalance intent @@ -422,18 +434,21 @@ No rebalance work is executed based on outdated user intent. User Path always ha 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. +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 From 2719a1968e32e9f95114c9c583f2a59c6aa0e92f Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 03:58:10 +0100 Subject: [PATCH 09/23] refactor: remove unused 'Decision' namespace references from RebalanceDecisionEngine, IntentController, and RebalanceScheduler files --- .../Core/Rebalance/Decision/RebalanceDecisionEngine.cs | 1 - src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs | 1 - .../Core/Rebalance/Intent/RebalanceScheduler.cs | 1 - 3 files changed, 3 deletions(-) diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs index 0b7564d..4c1e1f2 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs @@ -1,7 +1,6 @@ using Intervals.NET; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Planning; -using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.State; namespace SlidingWindowCache.Core.Rebalance.Decision; diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index f38531c..0fc364a 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -1,7 +1,6 @@ using Intervals.NET; using Intervals.NET.Data; using Intervals.NET.Domain.Abstractions; -using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.Rebalance.Execution; using SlidingWindowCache.Core.State; using SlidingWindowCache.Infrastructure.Instrumentation; diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs index 8fcafa4..d0f16bb 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs @@ -1,5 +1,4 @@ using Intervals.NET.Domain.Abstractions; -using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.Rebalance.Execution; using SlidingWindowCache.Infrastructure.Instrumentation; From 01ef33b1afca34db8a8b6fa2f3d3fe4bb7d40b3a Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 04:14:35 +0100 Subject: [PATCH 10/23] Update tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../RebalanceExceptionHandlingTests.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs index c19c1ed..688ef04 100644 --- a/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs +++ b/tests/SlidingWindowCache.Integration.Tests/RebalanceExceptionHandlingTests.cs @@ -308,10 +308,6 @@ public void RebalanceExecutionCancelled() { } - public void RebalanceSkippedNoRebalanceRange() - { - } - public void RebalanceSkippedCurrentNoRebalanceRange() { } From 864ac3bf8754137e3994ac1aab87d0f6a81272e1 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 20:40:04 +0100 Subject: [PATCH 11/23] docs: update execution context descriptions to clarify synchronous behavior and intent publication handling --- docs/actors-and-responsibilities.md | 5 +++- docs/actors-to-components-mapping.md | 10 +++++--- docs/component-map.md | 11 +++++---- .../Decision/RebalanceDecisionEngine.cs | 24 +++++++++++++------ 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/docs/actors-and-responsibilities.md b/docs/actors-and-responsibilities.md index 08e5397..14ebe5a 100644 --- a/docs/actors-and-responsibilities.md +++ b/docs/actors-and-responsibilities.md @@ -117,7 +117,10 @@ This logical actor is internally decomposed into two components for separation o - **ProportionalRangePlanner** - Computes DesiredCacheRange, plans cache geometry **Execution Context:** -**Lives in: Background / ThreadPool** (invoked by RebalanceDecisionEngine) +**Lives in: User Thread** (invoked synchronously by RebalanceDecisionEngine, which itself runs in user thread) + +**Characteristics:** +Pure functions, lightweight structs (value types), CPU-only, side-effect free **Responsible for invariants:** - 29. DesiredCacheRange computed from RequestedRange + config [ProportionalRangePlanner] diff --git a/docs/actors-to-components-mapping.md b/docs/actors-to-components-mapping.md index 39cb0f7..b8a0b25 100644 --- a/docs/actors-to-components-mapping.md +++ b/docs/actors-to-components-mapping.md @@ -308,9 +308,13 @@ shape to target). ### Execution Context -**Mixed:** -- **User Thread**: PublishIntent(), DecisionEngine.Evaluate(), ScheduleRebalance() (all synchronous) -- **Background / ThreadPool**: Only the Task.Run lambda in Scheduler (debounce + execution) (invoked by RebalanceDecisionEngine) +**User Thread** (invoked synchronously by RebalanceDecisionEngine during intent publication) + +**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 diff --git a/docs/component-map.md b/docs/component-map.md index fc9ea38..10f00b4 100644 --- a/docs/component-map.md +++ b/docs/component-map.md @@ -1032,7 +1032,7 @@ public Range? GetNoRebalanceRange(Range cacheRange) **Ownership**: Value type, copied into RebalanceDecisionEngine and RebalanceExecutor -**Execution Context**: Background / ThreadPool +**Execution Context**: User Thread (invoked by RebalanceDecisionEngine which runs synchronously in user thread) **Responsibilities**: - Compute NoRebalanceRange (shrinks cache by threshold ratios) @@ -1086,7 +1086,7 @@ public Range Plan(Range requested) **Ownership**: Value type, copied into RebalanceDecisionEngine -**Execution Context**: Background / ThreadPool +**Execution Context**: User Thread (invoked by RebalanceDecisionEngine which runs synchronously in user thread) **Responsibilities**: - Compute DesiredCacheRange (expands requested by left/right coefficients) @@ -1832,10 +1832,13 @@ var sharedCache = new WindowCache(...); **User Thread (Synchronous, Fast)**: - WindowCache - Facade, delegates - UserRequestHandler - Serve requests, trigger intents +- IntentController - Intent lifecycle, decision orchestration (synchronous methods) +- RebalanceDecisionEngine - Pure decision logic (CPU-only, synchronous) +- ThresholdRebalancePolicy - Threshold validation (value type, inline) +- ProportionalRangePlanner - Cache geometry planning (value type, inline) **Background / ThreadPool (Asynchronous, Heavy)**: -- RebalanceScheduler - Timing, debounce, orchestration -- RebalanceDecisionEngine - Pure decision logic +- RebalanceScheduler - Timing, debounce, orchestration (execution only, scheduling is sync) - RebalanceExecutor - Cache normalization, I/O **Both Contexts**: diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs index 4c1e1f2..0d48f7b 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs @@ -7,23 +7,33 @@ namespace SlidingWindowCache.Core.Rebalance.Decision; /// /// Evaluates whether rebalance execution is required based on cache geometry policy. -/// This component lives strictly in the background execution context and is never -/// invoked directly by the User Path. +/// This is the SOLE AUTHORITY for rebalance necessity determination. /// /// The type representing the range boundaries. /// The type representing the domain of the ranges. /// -/// Execution Context: Background / ThreadPool -/// Visibility: Not visible to User Path, invoked only by IntentController -/// Characteristics: Pure, deterministic, side-effect free +/// Execution Context: User Thread (Synchronous) +/// +/// This component executes SYNCHRONOUSLY in the user thread during intent publication. +/// This is intentional and critical for handling request bursts and preventing intent thrashing. +/// Decision logic is CPU-only, side-effect free, and lightweight (completes in microseconds). +/// +/// Visibility: Not visible to external users, owned and invoked by IntentController +/// Invocation: Called synchronously by IntentController.PublishIntent() before any background scheduling (before Task.Run) +/// Characteristics: Pure, deterministic, side-effect free, CPU-only (no I/O) /// Decision Pipeline (5 Stages): /// -/// Stage 1: Current Cache NoRebalanceRange stability check (fast path) +/// Stage 1: Current Cache NoRebalanceRange stability check (fast path work avoidance) /// Stage 2: Pending Rebalance NoRebalanceRange stability check (anti-thrashing) /// Stage 3: Compute DesiredCacheRange and DesiredNoRebalanceRange -/// Stage 4: Equality short-circuit (DesiredRange == CurrentRange) +/// Stage 4: Equality short-circuit (DesiredRange == CurrentRange - no-op prevention) /// Stage 5: Rebalance required - return full decision /// +/// Smart Eventual Consistency: +/// +/// Enables work avoidance through multi-stage validation. Prevents thrashing, reduces redundant I/O, +/// and maintains stability under rapidly changing access patterns while ensuring eventual convergence. +/// /// internal sealed class RebalanceDecisionEngine where TRange : IComparable From b80afbc424634a102a993dfde83b90d9316fd635 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 20:42:39 +0100 Subject: [PATCH 12/23] Update docs/component-map.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/component-map.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/component-map.md b/docs/component-map.md index 10f00b4..8b5b964 100644 --- a/docs/component-map.md +++ b/docs/component-map.md @@ -140,10 +140,10 @@ The system uses a **multi-stage rebalance decision pipeline**, not a cancellatio - Note: May be implemented via cancellation timing optimization 3. **Stage 3: DesiredCacheRange vs CurrentCacheRange Equality** - - Component: `RebalanceExecutor.ExecuteAsync()` (early exit optimization) + - Component: `RebalanceDecisionEngine.Evaluate` (pre-scheduling analytical check) - Check: Does computed DesiredCacheRange == CurrentCacheRange? - Purpose: Avoid no-op mutations - - Result: Skip if cache already in optimal configuration + - Result: Skip scheduling if cache already in optimal configuration **Execution Rule**: Rebalance executes ONLY if ALL stages confirm necessity. From 170ffac61b1c7e36564379be986432229c6dacf7 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 20:53:54 +0100 Subject: [PATCH 13/23] refactor: refactor rebalance namespace structure to improve organization and clarity, moving decision-related classes to the Intent namespace. --- .../Core/Rebalance/Decision/RebalanceDecisionEngine.cs | 1 + .../Core/Rebalance/Execution/RebalanceExecutor.cs | 1 + .../Core/Rebalance/Intent/IntentController.cs | 3 ++- .../Core/Rebalance/Intent/PendingRebalance.cs | 2 +- .../Core/Rebalance/Intent/RebalanceScheduler.cs | 3 ++- src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs | 1 + src/SlidingWindowCache/Public/WindowCache.cs | 1 + 7 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs index 0d48f7b..125cb17 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Decision/RebalanceDecisionEngine.cs @@ -1,6 +1,7 @@ using Intervals.NET; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Planning; +using SlidingWindowCache.Core.Rebalance.Intent; 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 5caf633..ae42ec4 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs @@ -2,6 +2,7 @@ using Intervals.NET.Data; using Intervals.NET.Domain.Abstractions; using SlidingWindowCache.Core.Rebalance.Decision; +using SlidingWindowCache.Core.Rebalance.Intent; using SlidingWindowCache.Core.State; using SlidingWindowCache.Infrastructure.Instrumentation; diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index 0fc364a..2191a5c 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -1,11 +1,12 @@ using Intervals.NET; using Intervals.NET.Data; using Intervals.NET.Domain.Abstractions; +using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.Rebalance.Execution; using SlidingWindowCache.Core.State; using SlidingWindowCache.Infrastructure.Instrumentation; -namespace SlidingWindowCache.Core.Rebalance.Decision; +namespace SlidingWindowCache.Core.Rebalance.Intent; /// /// Represents the intent to rebalance the cache based on a requested range and the currently available range data. diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs index 22f9da9..2d15bdf 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs @@ -1,6 +1,6 @@ using Intervals.NET; -namespace SlidingWindowCache.Core.Rebalance.Decision; +namespace SlidingWindowCache.Core.Rebalance.Intent; /// /// Represents an immutable snapshot of a pending rebalance operation's target state. diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs index d0f16bb..8f33669 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs @@ -1,8 +1,9 @@ using Intervals.NET.Domain.Abstractions; +using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.Rebalance.Execution; using SlidingWindowCache.Infrastructure.Instrumentation; -namespace SlidingWindowCache.Core.Rebalance.Decision; +namespace SlidingWindowCache.Core.Rebalance.Intent; /// /// Responsible for scheduling and executing rebalance operations in the background. diff --git a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs index a49a6f4..8eafd11 100644 --- a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs +++ b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs @@ -5,6 +5,7 @@ using Intervals.NET.Extensions; using SlidingWindowCache.Core.Rebalance.Execution; using SlidingWindowCache.Core.Rebalance.Decision; +using SlidingWindowCache.Core.Rebalance.Intent; using SlidingWindowCache.Core.State; using SlidingWindowCache.Infrastructure.Instrumentation; using SlidingWindowCache.Public; diff --git a/src/SlidingWindowCache/Public/WindowCache.cs b/src/SlidingWindowCache/Public/WindowCache.cs index 7172ce5..b9c00d9 100644 --- a/src/SlidingWindowCache/Public/WindowCache.cs +++ b/src/SlidingWindowCache/Public/WindowCache.cs @@ -3,6 +3,7 @@ using SlidingWindowCache.Core.Planning; using SlidingWindowCache.Core.Rebalance.Decision; using SlidingWindowCache.Core.Rebalance.Execution; +using SlidingWindowCache.Core.Rebalance.Intent; using SlidingWindowCache.Core.State; using SlidingWindowCache.Core.UserPath; using SlidingWindowCache.Infrastructure.Instrumentation; From d4da7a0f6d62e4b52a7ff29ba115b81c85c3287f Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 21:12:28 +0100 Subject: [PATCH 14/23] refactor: refactor background task scheduling in RebalanceScheduler for improved performance and clarity, replacing Task.Run with Task.Delay and ContinueWith pattern. --- docs/actors-to-components-mapping.md | 20 +-- docs/component-map.md | 60 ++++---- docs/concurrency-model.md | 4 +- docs/invariants.md | 2 +- .../Rebalance/Intent/RebalanceScheduler.cs | 143 +++++++++++------- 5 files changed, 131 insertions(+), 98 deletions(-) diff --git a/docs/actors-to-components-mapping.md b/docs/actors-to-components-mapping.md index b8a0b25..8802760 100644 --- a/docs/actors-to-components-mapping.md +++ b/docs/actors-to-components-mapping.md @@ -72,15 +72,15 @@ User Thread (Synchronous) │ ▼ ┌───────────────────────────┐ -│ ScheduleRebalance() │ ← Creates Task.Run (returns synchronously) +│ ScheduleRebalance() │ ← Creates background task (returns synchronously) └───────────┬───────────────┘ │ - │ Task.Run() - HERE background starts ⚡→🔄 + │ Background scheduling - HERE background starts ⚡→🔄 │ ▼ ═══════════════════════════════════════════════════════════ -Background / ThreadPool (After Task.Run) +Background / ThreadPool (After background scheduling) ═══════════════════════════════════════════════════════════ ▼ @@ -101,7 +101,7 @@ Background / ThreadPool (After Task.Run) └───────────────────────────┘ ``` -**Critical:** Everything up to Task.Run happens **synchronously in user thread**. Only debounce + actual execution happen in background. +**Critical:** Everything up to background scheduling happens **synchronously in user thread**. Only debounce + actual execution happen in background. --- @@ -225,7 +225,7 @@ return _userRequestHandler.HandleRequestAsync(requestedRange, cancellationToken) - **Not visible to external users** - **Owned by IntentController** (composed in constructor) - Invoked synchronously by IntentController.PublishIntent() -- Executes inline with user request (before Task.Run) +- Executes inline with user request (before background scheduling) - May execute many times, work avoidance allows skipping scheduling entirely ### Ownership @@ -386,7 +386,7 @@ but externally appears as a unified policy concept. - File: `src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs` - **Owned by IntentController** (created in constructor) - Handles debounce timing and background execution - - ScheduleRebalance() is synchronous (creates Task.Run, returns PendingRebalance) + - ScheduleRebalance() is synchronous (schedules background task, returns PendingRebalance) - Ensures single-flight execution via Task lifecycle - **Intentionally stateless** - does not own intent identity - **Task tracking** - provides ExecutionTask on PendingRebalance for deterministic synchronization (infrastructure/testing) @@ -406,15 +406,15 @@ The Rebalance Intent Manager actor is responsible for: - **Intent lifecycle management** via PendingRebalance snapshot [IntentController - Volatile.Read/Write] - **Cancellation coordination** based on validation results from owned DecisionEngine [IntentController - User Thread] - **Immediate work avoidance** through synchronous decision evaluation [IntentController - User Thread] -- **Debouncing** [Execution Scheduler - Background, after Task.Run] +- **Debouncing** [Execution Scheduler - Background, after background scheduling] - **Single-flight execution** enforcement [Both components via cancellation + Task lifecycle] -- **Starting background tasks** [Execution Scheduler - ScheduleRebalance creates Task.Run] +- **Starting background tasks** [Execution Scheduler - ScheduleRebalance creates background task] - **Orchestrating the validation-driven decision pipeline**: [IntentController - User Thread, SYNCHRONOUS] 1. **IntentController.PublishIntent()** invokes owned DecisionEngine synchronously (User Thread, CPU-only) 2. **DecisionEngine.Evaluate()** performs multi-stage validation (User Thread, CPU-only) - 3. If validation rejects → return immediately (work avoidance, no Task.Run scheduled) + 3. If validation rejects → return immediately (work avoidance, no background task scheduled) 4. If validation confirms → cancel old pending, call Scheduler.ScheduleRebalance() - 5. **Scheduler.ScheduleRebalance()** creates PendingRebalance, schedules Task.Run (returns synchronously to user thread) + 5. **Scheduler.ScheduleRebalance()** creates PendingRebalance, schedules background task (returns synchronously to user thread) 6. **Background Task** (only part that's async) performs debounce delay + ExecuteAsync **Key Principle:** IntentController is the owner/orchestrator. It **owns DecisionEngine** and invokes it **synchronously in user thread** during PublishIntent(), enabling immediate work avoidance and preventing intent thrashing. The **DecisionEngine (owned by IntentController) is THE sole authority** for necessity determination. This separation enables **smart eventual consistency** through work avoidance: the system converges to optimal configuration while avoiding unnecessary operations. diff --git a/docs/component-map.md b/docs/component-map.md index 8b5b964..1428efe 100644 --- a/docs/component-map.md +++ b/docs/component-map.md @@ -784,7 +784,7 @@ public async Task WaitForIdleAsync(TimeSpan? timeout = null) **Execution Context**: - **PublishIntent() executes synchronously in User Thread** (includes decision evaluation) -- **Only scheduled work (Task.Run lambda) executes in Background ThreadPool** +- **Only scheduled work (background task) executes in Background ThreadPool** **State**: - `_pendingRebalance` (mutable, nullable, accessed via Volatile.Read/Write) @@ -827,26 +827,26 @@ internal sealed class RebalanceScheduler ```csharp public void ScheduleRebalance(Range requestedRange, CancellationToken intentToken) { - // Fire-and-forget: schedule execution in background thread pool - Task.Run(async () => - { - try - { - // Debounce delay - await Task.Delay(_debounceDelay, intentToken); - - // Intent validity check - if (intentToken.IsCancellationRequested) - return; - - // Execute pipeline - await ExecutePipelineAsync(requestedRange, intentToken); - } - catch (OperationCanceledException) + // Fire-and-forget: optimized background execution on thread pool + // Using Task.Delay().ContinueWith() pattern for performance + Task.Delay(_debounceDelay, intentToken) + .ContinueWith(async delayTask => { - // Expected when intent is cancelled - } - }, intentToken); + try + { + // Intent validity check + if (delayTask.IsCanceled || intentToken.IsCancellationRequested) + return; + + // Execute pipeline + await ExecutePipelineAsync(requestedRange, intentToken); + } + catch (OperationCanceledException) + { + // Expected when intent is cancelled + } + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default) + .Unwrap(); } ``` @@ -1441,11 +1441,11 @@ public Task WaitForIdleAsync(TimeSpan? timeout = null) │ 🟦 CLASS (sealed) │ │ │ │ │ │ ScheduleRebalance(range, intentToken): │ │ -│ Task.Run(async () => { │ │ -│ await Task.Delay(_debounceDelay, intentToken); │ │ -│ if (!intentToken.IsCancellationRequested) │ │ -│ await ExecutePipelineAsync(range, intentToken); ───────────┼───┤ -│ }); │ │ +│ Task.Delay(_debounceDelay, intentToken) │ │ +│ .ContinueWith(async () => { │ │ +│ if (!intentToken.IsCancellationRequested) │ │ +│ await ExecutePipelineAsync(range, intentToken); ─────────┼───┤ +│ }).Unwrap(); │ │ │ │ │ │ ExecutePipelineAsync(range, ct): │ │ │ 1. Check cancellation │ │ @@ -1683,8 +1683,8 @@ The Sliding Window Cache follows a **single consumer model** as documented in `d | **UserRequestHandler** | ⚡ **User Thread** | Synchronous, fast path | | **IntentController** | ⚡ **User Thread** | Synchronous methods (PublishIntent, decision evaluation) | | **RebalanceDecisionEngine** | ⚡ **User Thread** | Invoked synchronously by IntentController, CPU-only logic | -| **RebalanceScheduler (scheduling)**| ⚡ **User Thread** | ScheduleRebalance() is synchronous (creates Task) | -| **RebalanceScheduler (execution)**| 🔄 **Background** | Inside Task.Run - debounce + executor invocation | +| **RebalanceScheduler (scheduling)**| ⚡ **User Thread** | ScheduleRebalance() is synchronous (creates background task) | +| **RebalanceScheduler (execution)**| 🔄 **Background** | Background task execution - debounce + executor invocation | | **RebalanceExecutor** | 🔄 **Background** | ThreadPool, async, I/O | | **CacheDataExtensionService** | Both ⚡🔄 | User Thread OR Background | | **CacheState** | Both ⚡🔄 | Shared mutable (no locks!) | @@ -1708,7 +1708,7 @@ The Sliding Window Cache follows a **single consumer model** as documented in `d ### How It Works -#### User Request Flow (User Thread - ALL SYNCHRONOUS until Task.Run) +#### User Request Flow (User Thread - ALL SYNCHRONOUS until background scheduling) ``` 1. UserRequestHandler.HandleRequestAsync() called 2. Read from cache or fetch missing data from IDataSource @@ -1725,13 +1725,13 @@ The Sliding Window Cache follows a **single consumer model** as documented in `d ├─> If validation confirms: oldPending?.Cancel() ⚡ USER THREAD └─> Scheduler.ScheduleRebalance() ⚡ USER THREAD ├─> Create PendingRebalance (synchronous) - └─> Task.Run(() => ...) ← HERE background starts 🔄 + └─> Schedule background task ← HERE background starts 🔄 └─> Debounce delay 🔄 BACKGROUND └─> RebalanceExecutor.ExecuteAsync() 🔄 BACKGROUND └─> I/O operations, cache mutations ``` -**Key:** Everything up to `Task.Run` happens **synchronously in user thread**. +**Key:** Everything up to background task scheduling happens **synchronously in user thread**. Only debounce + actual execution happen in background. **Why This Matters:** diff --git a/docs/concurrency-model.md b/docs/concurrency-model.md index 2b46069..0edd8f7 100644 --- a/docs/concurrency-model.md +++ b/docs/concurrency-model.md @@ -75,7 +75,7 @@ Cache state converges to optimal configuration asynchronously through **decision 2. **User Path** publishes intent with delivered data (**synchronously in user thread**) 3. **Rebalance Decision Engine** validates rebalance necessity through multi-stage analytical pipeline (**synchronously in user thread - CPU-only, side-effect free, lightweight**) 4. **Scheduling** creates PendingRebalance and schedules background Task (**synchronously in user thread**) -5. **Work avoidance**: Rebalance skipped if validation determines it's unnecessary (NoRebalanceRange containment, Desired==Current, pending rebalance coverage) - **all happens synchronously before Task.Run** +5. **Work avoidance**: Rebalance skipped if validation determines it's unnecessary (NoRebalanceRange containment, Desired==Current, pending rebalance coverage) - **all happens synchronously before background scheduling** 6. **Background execution** (only part that runs in ThreadPool): debounce delay + actual rebalance I/O operations 7. **Debounce delay** controls convergence timing and prevents thrashing (background) 8. **User correctness** never depends on cache state being up-to-date @@ -86,7 +86,7 @@ Cache state converges to optimal configuration asynchronously through **decision **Critical Architectural Detail - Intent Processing is Synchronous:** -The decision logic (multi-stage validation) and scheduling are **NOT background operations**. They execute **synchronously in the user thread** before returning control to the user. Only the actual rebalance execution (I/O operations) happens in background via `Task.Run`. +The decision logic (multi-stage validation) and scheduling are **NOT background operations**. They execute **synchronously in the user thread** before returning control to the user. Only the actual rebalance execution (I/O operations) happens in background via background task scheduling. This design is intentional and critical for handling user request bursts: - ✅ **CPU-only validation** in user thread (math, conditions, no I/O) diff --git a/docs/invariants.md b/docs/invariants.md index 57be0aa..37c7b31 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -147,7 +147,7 @@ deterministic, race-free synchronization without polling or timing dependencies. - *Architecture*: Encapsulation prevents other components from publishing intents **A.4** 🔵 **[Architectural]** Rebalance execution is **always performed asynchronously** relative to the User Path. -- *Enforced by*: `Task.Run()` in `RebalanceScheduler`, fire-and-forget pattern +- *Enforced by*: Background task scheduling in `RebalanceScheduler`, fire-and-forget pattern - *Architecture*: User Path returns immediately after publishing intent **A.5** 🔵 **[Architectural]** The User Path performs **only the work necessary to return data to the user**. diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs index 8f33669..08c46ec 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs @@ -100,35 +100,95 @@ public PendingRebalance ScheduleRebalance( pendingCts ); - // Fire-and-forget: schedule execution in background thread pool - var backgroundTask = Task.Run(async () => - { - try - { - await ExecuteAfterAsync( - executePipelineAsync: () => ExecutePipelineAsync(intent, decision, intentToken), - intentToken: intentToken - ); - } - catch (OperationCanceledException) - { - // Expected when intent is cancelled or superseded - // This is normal behavior, not an error - _cacheDiagnostics.RebalanceIntentCancelled(); - } - catch (Exception) + // ═══════════════════════════════════════════════════════════════════════════════════ + // FIRE-AND-FORGET: Optimized background execution scheduling on thread pool + // ═══════════════════════════════════════════════════════════════════════════════════ + // + // IMPLEMENTATION PATTERN: Task.Delay().ContinueWith().Unwrap() + // + // EQUIVALENT TO (original Task.Run approach): + // Task.Run(async () => { + // await Task.Delay(_debounceDelay, intentToken); + // intentToken.ThrowIfCancellationRequested(); + // await ExecutePipelineAsync(...); + // }, CancellationToken.None) + // + // WHY THIS OPTIMIZED PATTERN IS USED (Performance for Hot User Path): + // + // 1. ELIMINATES UNNECESSARY THREAD POOL QUEUEING: + // - Task.Run queues work to thread pool even for async lambda (adds overhead) + // - Task.Delay already returns a hot task that completes on timer thread pool thread + // - ContinueWith executes directly on that timer thread (already a thread pool thread) + // - Result: One less context switch (~0.5-1μs saved in hot user-facing code path) + // + // 2. MAINTAINS IDENTICAL EXECUTION CONTEXT GUARANTEES: + // - Task.Delay completion ALWAYS happens on timer thread pool thread (.NET guarantee) + // - Continuation with ExecuteSynchronously runs on completing thread = timer thread pool thread + // - Fully satisfies Invariant G.44: "Rebalance executes outside user execution context" + // - The architectural requirement is "background thread pool execution" - SATISFIED ✓ + // + // 3. FUNCTIONAL COMPOSITION & CODE CLARITY: + // - Expresses intent as continuation chain: delay → execute → handle exceptions + // - Avoids unnecessary lambda wrapping (Task.Run wrapper is redundant here) + // - More elegant functional style with clear data flow + // + // 4. EXCEPTION HANDLING UNCHANGED: + // - OperationCanceledException → RebalanceIntentCancelled() diagnostic + // - All other exceptions → swallowed (already recorded via RebalanceExecutionFailed) + // - Prevents unhandled task exceptions from crashing application + // + // CRITICAL ARCHITECTURAL NOTE: + // This implementation satisfies Invariant G.44 ("background thread pool execution") + // because .NET Task.Delay timer threads ARE ThreadPool threads. The Task.Delay + // implementation uses System.Threading.TimerQueueTimer which queues callbacks to + // ThreadPool. Therefore continuation executes on ThreadPool = architectural contract met. + // + // TECHNICAL NOTE - Why .Unwrap()? + // The async continuation returns Task, so Unwrap() flattens it to Task for + // clean await semantics in WaitForIdleAsync and direct consumption scenarios. + // + // ═══════════════════════════════════════════════════════════════════════════════════ + + var backgroundTask = Task.Delay(_debounceDelay, intentToken) + .ContinueWith(async delayTask => { - // All other exceptions are already recorded via RebalanceExecutionFailed - // They bubble up from ExecutePipelineAsync and are swallowed here to prevent - // unhandled task exceptions from crashing the application. - // - // ⚠️ CRITICAL: Applications MUST subscribe to RebalanceExecutionFailed events - // and implement appropriate error handling (logging, alerting, monitoring). - // Ignoring this event means silent failures in background operations. - } - }, CancellationToken.None); - // NOTE: Do NOT pass intentToken to Task.Run ^ - it should only be used inside the lambda - // to ensure the try-catch properly handles all OperationCanceledExceptions + try + { + // If delay was cancelled, handle as expected cancellation + if (delayTask.IsCanceled) + { + _cacheDiagnostics.RebalanceIntentCancelled(); + return; + } + + // Intent validity check: discard if cancelled during debounce + // This implements Invariant C.20: "If intent becomes obsolete before execution begins, execution must not start" + intentToken.ThrowIfCancellationRequested(); + + // Execute the rebalance pipeline + await ExecutePipelineAsync(intent, decision, intentToken); + } + catch (OperationCanceledException) + { + // Expected when intent is cancelled or superseded + // This is normal behavior, not an error + _cacheDiagnostics.RebalanceIntentCancelled(); + } + catch (Exception) + { + // All other exceptions are already recorded via RebalanceExecutionFailed + // They bubble up from ExecutePipelineAsync and are swallowed here to prevent + // unhandled task exceptions from crashing the application. + // + // ⚠️ CRITICAL: Applications MUST subscribe to RebalanceExecutionFailed events + // and implement appropriate error handling (logging, alerting, monitoring). + // Ignoring this event means silent failures in background operations. + } + }, + CancellationToken.None, // Do NOT pass intentToken - only used inside continuation body + TaskContinuationOptions.ExecuteSynchronously, // Run on timer thread (already ThreadPool) + TaskScheduler.Default) + .Unwrap(); // Unwrap Task to Task (continuation is async) // Set execution task on PendingRebalance for direct await scenarios pendingRebalance.ExecutionTask = backgroundTask; @@ -136,33 +196,6 @@ await ExecuteAfterAsync( return pendingRebalance; } - /// - /// Executes the provided function after a debounce delay, checking intent validity before execution. - /// - /// - /// The asynchronous function to execute after the debounce delay. This typically encapsulates the entire - /// decision and execution pipeline for rebalance. It receives the delivered data and intent token as context. - /// The function should respect the intentToken for cancellation to ensure timely yielding to new intents. - /// - /// - /// The cancellation token associated with the current intent. This token is used to implement single-flight execution and intent invalidation. - /// If this token is cancelled during the debounce delay, the execution will be aborted and the pipeline will not start. If the token is cancelled during execution, the pipeline should respond to cancellation as soon as possible to yield to new intents. - /// This token is owned and managed by the Intent Manager, which creates a new token for each intent and cancels the previous one when a new intent is published. - /// - private async Task ExecuteAfterAsync(Func executePipelineAsync, CancellationToken intentToken) - { - // Debounce delay: wait before executing - // This can be cancelled if a new intent arrives during the delay - await Task.Delay(_debounceDelay, intentToken); - - // Intent validity check: discard if cancelled during debounce - // This implements Invariant C.20: "If intent becomes obsolete before execution begins, execution must not start" - intentToken.ThrowIfCancellationRequested(); - - // Execute the provided function - await executePipelineAsync(); - } - /// /// Executes the mechanical rebalance pipeline in the background. /// From 825fdb8bdcde27b4bb47c07eff425080c4570200 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 21:14:06 +0100 Subject: [PATCH 15/23] refactor: refactor execution task assignment in RebalanceScheduler to streamline handling of direct await scenarios --- .../Core/Rebalance/Intent/RebalanceScheduler.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs index 08c46ec..a176d85 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs @@ -149,7 +149,8 @@ public PendingRebalance ScheduleRebalance( // // ═══════════════════════════════════════════════════════════════════════════════════ - var backgroundTask = Task.Delay(_debounceDelay, intentToken) + // Set execution task on PendingRebalance for direct await scenarios + pendingRebalance.ExecutionTask = Task.Delay(_debounceDelay, intentToken) .ContinueWith(async delayTask => { try @@ -190,9 +191,6 @@ public PendingRebalance ScheduleRebalance( TaskScheduler.Default) .Unwrap(); // Unwrap Task to Task (continuation is async) - // Set execution task on PendingRebalance for direct await scenarios - pendingRebalance.ExecutionTask = backgroundTask; - return pendingRebalance; } From 04e40ae55cdfae2ab833adf5d36e2c88eecaf0a6 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 21:25:05 +0100 Subject: [PATCH 16/23] refactor: refactor ScheduleRebalance method to use local async function with ConfigureAwait(false) for improved performance and clarity in background execution --- docs/component-map.md | 50 +++---- .../Rebalance/Intent/RebalanceScheduler.cs | 126 +++++++++--------- 2 files changed, 90 insertions(+), 86 deletions(-) diff --git a/docs/component-map.md b/docs/component-map.md index 1428efe..ce0b285 100644 --- a/docs/component-map.md +++ b/docs/component-map.md @@ -827,26 +827,29 @@ internal sealed class RebalanceScheduler ```csharp public void ScheduleRebalance(Range requestedRange, CancellationToken intentToken) { - // Fire-and-forget: optimized background execution on thread pool - // Using Task.Delay().ContinueWith() pattern for performance - Task.Delay(_debounceDelay, intentToken) - .ContinueWith(async delayTask => + // Fire-and-forget: background execution with ConfigureAwait(false) + pendingRebalance.ExecutionTask = RunAsync(); + + async Task RunAsync() + { + try { - try - { - // Intent validity check - if (delayTask.IsCanceled || intentToken.IsCancellationRequested) - return; - - // Execute pipeline - await ExecutePipelineAsync(requestedRange, intentToken); - } - catch (OperationCanceledException) - { - // Expected when intent is cancelled - } - }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default) - .Unwrap(); + await Task.Delay(_debounceDelay, intentToken) + .ConfigureAwait(false); + + // Intent validity check + if (intentToken.IsCancellationRequested) + return; + + // Execute pipeline + await ExecutePipelineAsync(requestedRange, intentToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected when intent is cancelled + } + } } ``` @@ -1441,11 +1444,12 @@ public Task WaitForIdleAsync(TimeSpan? timeout = null) │ 🟦 CLASS (sealed) │ │ │ │ │ │ ScheduleRebalance(range, intentToken): │ │ -│ Task.Delay(_debounceDelay, intentToken) │ │ -│ .ContinueWith(async () => { │ │ +│ ExecutionTask = RunAsync(); │ │ +│ async Task RunAsync() { │ │ +│ await Task.Delay(...).ConfigureAwait(false); │ │ │ if (!intentToken.IsCancellationRequested) │ │ -│ await ExecutePipelineAsync(range, intentToken); ─────────┼───┤ -│ }).Unwrap(); │ │ +│ await ExecutePipelineAsync(...).ConfigureAwait(false); ──┼───┤ +│ } │ │ │ │ │ │ ExecutePipelineAsync(range, ct): │ │ │ 1. Check cancellation │ │ diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs index a176d85..d7efd4f 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs @@ -101,10 +101,10 @@ public PendingRebalance ScheduleRebalance( ); // ═══════════════════════════════════════════════════════════════════════════════════ - // FIRE-AND-FORGET: Optimized background execution scheduling on thread pool + // FIRE-AND-FORGET: Optimized background execution on thread pool // ═══════════════════════════════════════════════════════════════════════════════════ // - // IMPLEMENTATION PATTERN: Task.Delay().ContinueWith().Unwrap() + // IMPLEMENTATION PATTERN: Local async function with ConfigureAwait(false) // // EQUIVALENT TO (original Task.Run approach): // Task.Run(async () => { @@ -113,24 +113,26 @@ public PendingRebalance ScheduleRebalance( // await ExecutePipelineAsync(...); // }, CancellationToken.None) // - // WHY THIS OPTIMIZED PATTERN IS USED (Performance for Hot User Path): + // WHY THIS PATTERN IS OPTIMAL (Correctness + Performance + Clarity for Hot User Path): // - // 1. ELIMINATES UNNECESSARY THREAD POOL QUEUEING: - // - Task.Run queues work to thread pool even for async lambda (adds overhead) - // - Task.Delay already returns a hot task that completes on timer thread pool thread - // - ContinueWith executes directly on that timer thread (already a thread pool thread) - // - Result: One less context switch (~0.5-1μs saved in hot user-facing code path) + // 1. ELIMINATES UNNECESSARY TASK.RUN OVERHEAD: + // - Task.Run queues work to thread pool (unnecessary for already-async operations) + // - Local async function starts immediately without queueing overhead + // - First await (Task.Delay) yields naturally to thread pool timer thread + // - Result: ~0.5-1μs saved per rebalance scheduling in hot user-facing code path // - // 2. MAINTAINS IDENTICAL EXECUTION CONTEXT GUARANTEES: - // - Task.Delay completion ALWAYS happens on timer thread pool thread (.NET guarantee) - // - Continuation with ExecuteSynchronously runs on completing thread = timer thread pool thread - // - Fully satisfies Invariant G.44: "Rebalance executes outside user execution context" - // - The architectural requirement is "background thread pool execution" - SATISFIED ✓ + // 2. CONFIGUREAWAIT(FALSE) - EXPLICIT BACKGROUND EXECUTION GUARANTEE: + // - ConfigureAwait(false) explicitly opts out of capturing SynchronizationContext + // - Ensures continuations run on thread pool threads (not user's context) + // - More architecturally sound than relying on Task.Delay implementation details + // - Works correctly in ALL .NET environments (ASP.NET, WPF, WinForms, console, etc.) + // - Fully satisfies Invariant G.44: "Rebalance executes outside user execution context" ✓ // - // 3. FUNCTIONAL COMPOSITION & CODE CLARITY: - // - Expresses intent as continuation chain: delay → execute → handle exceptions - // - Avoids unnecessary lambda wrapping (Task.Run wrapper is redundant here) - // - More elegant functional style with clear data flow + // 3. SIMPLER & MORE MAINTAINABLE THAN ALTERNATIVES: + // - Standard async/await syntax (vs complex ContinueWith chains or Task.Run wrappers) + // - No Task unwrapping needed (vs ContinueWith approach) + // - No closure allocation overhead (vs Task.Run lambda) + // - Cleaner exception handling flow // // 4. EXCEPTION HANDLING UNCHANGED: // - OperationCanceledException → RebalanceIntentCancelled() diagnostic @@ -138,60 +140,58 @@ public PendingRebalance ScheduleRebalance( // - Prevents unhandled task exceptions from crashing application // // CRITICAL ARCHITECTURAL NOTE: - // This implementation satisfies Invariant G.44 ("background thread pool execution") - // because .NET Task.Delay timer threads ARE ThreadPool threads. The Task.Delay - // implementation uses System.Threading.TimerQueueTimer which queues callbacks to - // ThreadPool. Therefore continuation executes on ThreadPool = architectural contract met. + // ConfigureAwait(false) is the KEY to satisfying Invariant G.44. It ensures that + // after the first await (Task.Delay), ALL subsequent code runs on thread pool threads + // without capturing the user's synchronization context. This is MORE RELIABLE than + // depending on Task.Delay completion context or Task.Run wrappers, as it works + // correctly regardless of the calling context or custom task schedulers. // - // TECHNICAL NOTE - Why .Unwrap()? - // The async continuation returns Task, so Unwrap() flattens it to Task for - // clean await semantics in WaitForIdleAsync and direct consumption scenarios. + // PERFORMANCE NOTE: + // ConfigureAwait(false) has essentially zero overhead. The compiler generates the same + // state machine structure, just with a different awaiter that doesn't capture context. + // The performance win comes from avoiding Task.Run's thread pool queue operation. // // ═══════════════════════════════════════════════════════════════════════════════════ // Set execution task on PendingRebalance for direct await scenarios - pendingRebalance.ExecutionTask = Task.Delay(_debounceDelay, intentToken) - .ContinueWith(async delayTask => - { - try - { - // If delay was cancelled, handle as expected cancellation - if (delayTask.IsCanceled) - { - _cacheDiagnostics.RebalanceIntentCancelled(); - return; - } + pendingRebalance.ExecutionTask = RunAsync(); - // Intent validity check: discard if cancelled during debounce - // This implements Invariant C.20: "If intent becomes obsolete before execution begins, execution must not start" - intentToken.ThrowIfCancellationRequested(); + return pendingRebalance; - // Execute the rebalance pipeline - await ExecutePipelineAsync(intent, decision, intentToken); - } - catch (OperationCanceledException) - { - // Expected when intent is cancelled or superseded - // This is normal behavior, not an error - _cacheDiagnostics.RebalanceIntentCancelled(); - } - catch (Exception) - { - // All other exceptions are already recorded via RebalanceExecutionFailed - // They bubble up from ExecutePipelineAsync and are swallowed here to prevent - // unhandled task exceptions from crashing the application. - // - // ⚠️ CRITICAL: Applications MUST subscribe to RebalanceExecutionFailed events - // and implement appropriate error handling (logging, alerting, monitoring). - // Ignoring this event means silent failures in background operations. - } - }, - CancellationToken.None, // Do NOT pass intentToken - only used inside continuation body - TaskContinuationOptions.ExecuteSynchronously, // Run on timer thread (already ThreadPool) - TaskScheduler.Default) - .Unwrap(); // Unwrap Task to Task (continuation is async) + // Local async function - executes in background with ConfigureAwait(false) + async Task RunAsync() + { + try + { + // Debounce delay - ConfigureAwait(false) ensures continuation on thread pool + await Task.Delay(_debounceDelay, intentToken) + .ConfigureAwait(false); - return pendingRebalance; + // Intent validity check: discard if cancelled during debounce + // This implements Invariant C.20: "If intent becomes obsolete before execution begins, execution must not start" + intentToken.ThrowIfCancellationRequested(); + + // Execute the rebalance pipeline - ConfigureAwait(false) maintains thread pool execution + await ExecutePipelineAsync(intent, decision, intentToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected when intent is cancelled or superseded + // This is normal behavior, not an error + _cacheDiagnostics.RebalanceIntentCancelled(); + } + catch (Exception) + { + // All other exceptions are already recorded via RebalanceExecutionFailed + // They bubble up from ExecutePipelineAsync and are swallowed here to prevent + // unhandled task exceptions from crashing the application. + // + // ⚠️ CRITICAL: Applications MUST subscribe to RebalanceExecutionFailed events + // and implement appropriate error handling (logging, alerting, monitoring). + // Ignoring this event means silent failures in background operations. + } + } } /// From 650a775fb17442ed0fac4c0f31c4bb2ab7383326 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 21:27:46 +0100 Subject: [PATCH 17/23] refactor: remove CancelPendingRebalance method and its documentation to streamline IntentController functionality --- .../Core/Rebalance/Intent/IntentController.cs | 37 ------------------- 1 file changed, 37 deletions(-) diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index 2191a5c..61d4a0f 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -120,43 +120,6 @@ ICacheDiagnostics cacheDiagnostics ); } - /// - /// Cancels any pending or ongoing rebalance execution. - /// This method is called by the User Path to ensure exclusive cache access - /// before performing cache mutations (satisfies Invariant A.1-0a). - /// - /// - /// - /// This method is synchronous and returns immediately after signaling cancellation. - /// The background rebalance task will handle the cancellation asynchronously. - /// - /// - /// User Path never waits for rebalance to fully complete - it just ensures - /// the cancellation signal is sent before proceeding with its own mutations. - /// - /// DDD-Style Cancellation: - /// - /// Uses the PendingRebalance domain object's Cancel() method rather than directly - /// managing CancellationTokenSource. This provides better encapsulation and aligns - /// with domain-driven design principles. - /// - /// - public void CancelPendingRebalance() - { - var pending = Volatile.Read(ref _pendingRebalance); - - if (pending == null) - { - return; - } - - // DDD-style cancellation through domain object - pending.Cancel(); - - // Clear pending rebalance snapshot since no rebalance is scheduled - Volatile.Write(ref _pendingRebalance, null); - } - /// /// Publishes a rebalance intent triggered by a user request. /// This method is fire-and-forget and returns immediately. From 177a3fef9df5159b38ee92d6101ff498cf11622e Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 21:44:28 +0100 Subject: [PATCH 18/23] refactor: fix intent cancellation handling in rebalance process and dispose of cancellation token source on cancel --- .../Core/Rebalance/Intent/PendingRebalance.cs | 1 + .../Core/Rebalance/Intent/RebalanceScheduler.cs | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs index 2d15bdf..9f279d1 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs @@ -83,5 +83,6 @@ public PendingRebalance( public void Cancel() { _cts?.Cancel(); + _cts?.Dispose(); } } \ No newline at end of file diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs index d7efd4f..c054e1a 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs @@ -169,7 +169,11 @@ await Task.Delay(_debounceDelay, intentToken) // Intent validity check: discard if cancelled during debounce // This implements Invariant C.20: "If intent becomes obsolete before execution begins, execution must not start" - intentToken.ThrowIfCancellationRequested(); + if (intentToken.IsCancellationRequested) + { + _cacheDiagnostics.RebalanceIntentCancelled(); + return; + } // Execute the rebalance pipeline - ConfigureAwait(false) maintains thread pool execution await ExecutePipelineAsync(intent, decision, intentToken) From f513b28858a39cceef58653c3adbc8d8a695adc5 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 21:55:30 +0100 Subject: [PATCH 19/23] fix: enhance asynchronous operations by adding ConfigureAwait(false) to improve performance and avoid deadlocks --- .../Core/Rebalance/Execution/CacheDataExtensionService.cs | 3 ++- .../Core/Rebalance/Execution/RebalanceExecutor.cs | 3 ++- .../Core/Rebalance/Intent/IntentController.cs | 2 +- .../Core/Rebalance/Intent/RebalanceScheduler.cs | 3 ++- src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs index 241ab47..83e0dca 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/CacheDataExtensionService.cs @@ -91,7 +91,8 @@ CancellationToken ct var missingRanges = CalculateMissingRanges(currentCache.Range, requested); // Step 2: Fetch the missing data from data source - var fetchedResults = await _dataSource.FetchAsync(missingRanges, ct); + var fetchedResults = await _dataSource.FetchAsync(missingRanges, ct) + .ConfigureAwait(false); // Step 3: Union fetched data with current cache return UnionAll(currentCache, fetchedResults, _domain); diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs index ae42ec4..0cd0466 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs @@ -81,7 +81,8 @@ public async Task ExecuteAsync( // Phase 1: Extend delivered data to cover desired range (fetch only truly missing data) // Use delivered data as base instead of current cache to ensure consistency - var extended = await _cacheExtensionService.ExtendCacheAsync(baseRangeData, desiredRange, cancellationToken); + var extended = await _cacheExtensionService.ExtendCacheAsync(baseRangeData, desiredRange, cancellationToken) + .ConfigureAwait(false); // Cancellation check after I/O but before mutation // If User Path cancelled us, don't apply the rebalance result diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index 61d4a0f..0f871e9 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -266,7 +266,7 @@ public async Task WaitForIdleAsync(TimeSpan? timeout = null) } // Await the observed task - await observedPending.ExecutionTask; + await observedPending.ExecutionTask.ConfigureAwait(false); // Check if _pendingRebalance changed while we were waiting var currentPending = Volatile.Read(ref _pendingRebalance); diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs index c054e1a..68bf4fa 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/RebalanceScheduler.cs @@ -237,7 +237,8 @@ await _executor.ExecuteAsync( intent, decision.DesiredRange!.Value, decision.DesiredNoRebalanceRange, - cancellationToken); + cancellationToken) + .ConfigureAwait(false); } catch (OperationCanceledException) { diff --git a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs index 8eafd11..e5e1b20 100644 --- a/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs +++ b/src/SlidingWindowCache/Core/UserPath/UserRequestHandler.cs @@ -170,7 +170,7 @@ public async ValueTask> HandleRequestAsync( // Fetch ONLY the requested range from IDataSource // NOTE: The logic is similar to cold start _cacheDiagnostics.DataSourceFetchSingleRange(); - assembledData = (await _dataSource.FetchAsync(requestedRange, cancellationToken)) + assembledData = (await _dataSource.FetchAsync(requestedRange, cancellationToken).ConfigureAwait(false)) .ToRangeData(requestedRange, _state.Domain); _cacheDiagnostics.UserRequestFullCacheMiss(); From a95fb1dd4f7d5f325fd163090383c901e17e5172 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 23:10:37 +0100 Subject: [PATCH 20/23] fix: enhance RebalanceExecutor with execution serialization using SemaphoreSlim to prevent concurrent cache writes, ensuring thread safety and improved cancellation handling. --- README.md | 11 +-- docs/component-map.md | 90 +++++++++++-------- docs/concurrency-model.md | 29 ++++++ docs/invariants.md | 1 + .../Rebalance/Execution/RebalanceExecutor.cs | 57 ++++++++---- .../Core/Rebalance/Intent/IntentController.cs | 8 +- .../Core/Rebalance/Intent/PendingRebalance.cs | 24 ++++- 7 files changed, 157 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 3462aa8..d6700c4 100644 --- a/README.md +++ b/README.md @@ -549,8 +549,9 @@ For detailed architectural documentation, see: ### Key Architectural Principles -1. **Single-Writer Architecture**: Only Rebalance Execution writes to cache state; User Path is read-only. This - eliminates race conditions through architectural constraints rather than locks. +1. **Single-Writer Architecture**: Only Rebalance Execution writes to cache state; User Path is read-only. Multiple + rebalance executions are serialized via `SemaphoreSlim` to guarantee only one execution writes to cache at a time. + This eliminates race conditions and data corruption through architectural constraints and execution serialization. See [Concurrency Model](docs/concurrency-model.md). 2. **Decision-Driven Execution**: Rebalance necessity determined by synchronous CPU-only analytical validation in user @@ -581,9 +582,9 @@ For detailed architectural documentation, see: pending rebalance is cancelled and rescheduled. Cancellation is mechanical coordination (prevents concurrent executions), not a decision mechanism. See [Cache State Machine](docs/cache-state-machine.md). -8. **Lock-Free Concurrency**: Intent management uses `Volatile.Read/Write` for safe memory visibility - no locks, no - race conditions, guaranteed progress. Thread-safety achieved through architectural constraints (single-writer) and - atomic reference operations. +8. **Lock-Free Concurrency**: Intent management uses `Volatile.Read/Write` and `Interlocked.Exchange` for atomic + operations - no locks, no race conditions, guaranteed progress. Execution serialization via `SemaphoreSlim` ensures + single-writer semantics. Thread-safety achieved through architectural constraints and atomic operations. See [Concurrency Model - Lock-Free Implementation](docs/concurrency-model.md#lock-free-implementation). --- diff --git a/docs/component-map.md b/docs/component-map.md index ce0b285..f499d28 100644 --- a/docs/component-map.md +++ b/docs/component-map.md @@ -1144,7 +1144,7 @@ internal readonly struct RebalanceDecision internal sealed class RebalanceExecutor ``` -**File**: `src/SlidingWindowCache/CacheRebalance/Executor/RebalanceExecutor.cs` +**File**: `src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs` **Type**: Class (sealed) @@ -1153,51 +1153,69 @@ internal sealed class RebalanceExecutor **Fields** (all readonly): - `CacheState _state` - `CacheDataExtensionService _cacheExtensionService` -- `ThresholdRebalancePolicy _rebalancePolicy` +- `ICacheDiagnostics _cacheDiagnostics` +- `SemaphoreSlim _executionSemaphore` (initialized to `new SemaphoreSlim(1, 1)`) + +**Concurrency Model**: +- Uses `SemaphoreSlim(1, 1)` to serialize execution - ensures only one rebalance can write to cache state at a time +- Semaphore acquired at start of `ExecuteAsync()`, before any I/O operations +- Released in `finally` block to guarantee release even on cancellation or exception +- Works with `CancellationToken` - operations can be cancelled while waiting for semaphore +- WebAssembly-compatible, async, zero User Path blocking **Key Method**: ```csharp -public async Task ExecuteAsync(Range desiredRange, CancellationToken cancellationToken) +public async Task ExecuteAsync( + Intent intent, + Range desiredRange, + Range? desiredNoRebalanceRange, + CancellationToken cancellationToken) { - // Get current cache snapshot - var rangeData = _state.Cache.ToRangeData(); - - // Check if already at desired state (Decision Path D2) - if (rangeData.Range == desiredRange) - return; - - // Cancellation check before I/O - cancellationToken.ThrowIfCancellationRequested(); - - // Phase 1: Extend cache to cover desired range - var extended = await _cacheFetcher.ExtendCacheAsync(rangeData, desiredRange, cancellationToken); - - // Cancellation check after I/O - cancellationToken.ThrowIfCancellationRequested(); - - // Phase 2: Trim to desired range - var rebalanced = extended[desiredRange]; - - // Cancellation check before mutation - cancellationToken.ThrowIfCancellationRequested(); - - // Phase 3: Update cache (atomic mutation) - _state.Cache.Rematerialize(rebalanced); + // Acquire semaphore to serialize execution + await _executionSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - // Phase 4: Update no-rebalance range - _state.NoRebalanceRange = _rebalancePolicy.GetNoRebalanceRange(_state.Cache.Range); + try + { + // Get delivered data from intent + var baseRangeData = intent.AvailableRangeData; + + // Cancellation check after acquiring semaphore + cancellationToken.ThrowIfCancellationRequested(); + + // Phase 1: Extend to cover desired range + var extended = await _cacheExtensionService.ExtendCacheAsync( + baseRangeData, desiredRange, cancellationToken).ConfigureAwait(false); + + // Cancellation check after I/O + cancellationToken.ThrowIfCancellationRequested(); + + // Phase 2: Trim to desired range + baseRangeData = extended[desiredRange]; + + // Cancellation check before mutation + cancellationToken.ThrowIfCancellationRequested(); + + // Phase 3: Update cache state atomically + UpdateCacheState(baseRangeData, intent.RequestedRange, desiredNoRebalanceRange); + } + finally + { + // Always release semaphore + _executionSemaphore.Release(); + } } ``` **Reads from**: -- ⊳ `_state.Cache` (ToRangeData, Range) +- ⊳ `intent.AvailableRangeData` (delivered data from User Path) **Writes to**: - ⊲ `_state.Cache` (via Rematerialize - normalizes to DesiredCacheRange) +- ⊲ `_state.LastRequested` - ⊲ `_state.NoRebalanceRange` **Uses**: -- ◇ `_cacheFetcher.ExtendCacheAsync()` (fetch missing data) +- ◇ `_cacheExtensionService.ExtendCacheAsync()` (fetch missing data) - ◇ `_rebalancePolicy.GetNoRebalanceRange()` (compute new threshold zone) **Characteristics**: @@ -1667,17 +1685,19 @@ The Sliding Window Cache follows a **single consumer model** as documented in `d - One access trajectory - One temporal sequence of requests -2. **No Synchronization Primitives** +2. **Execution Serialization** + - ✅ Uses `SemaphoreSlim(1, 1)` in `RebalanceExecutor` for execution serialization - ❌ No locks (`lock`, `Monitor`) - - ❌ No semaphores (`SemaphoreSlim`) - ❌ No concurrent collections - - ✅ Only `CancellationToken` for coordination + - ✅ `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 - - **No locks needed** - Single-writer eliminates write-write races, reference reads are atomic + - **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 diff --git a/docs/concurrency-model.md b/docs/concurrency-model.md index 0edd8f7..a9a6739 100644 --- a/docs/concurrency-model.md +++ b/docs/concurrency-model.md @@ -49,6 +49,35 @@ User Path safely reads cache state without locks because: **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, `RebalanceExecutor` uses a `SemaphoreSlim(1, 1)` for mutual exclusion. + +**Serialization Mechanism:** + +- **`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:** + +- ✅ **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 + +**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. + ### Rebalance Validation vs Cancellation **Key Distinction:** diff --git a/docs/invariants.md b/docs/invariants.md index 37c7b31..742996c 100644 --- a/docs/invariants.md +++ b/docs/invariants.md @@ -131,6 +131,7 @@ deterministic, race-free synchronization without polling or timing dependencies. - *Test verifies*: Cancellation counter increments when new request arrives and rebalance validation requires rescheduling - *Clarification*: Cancellation is a mechanical coordination tool (single-writer architecture), not a decision mechanism. Rebalance necessity is determined by the Rebalance Decision Engine through analytical validation (NoRebalanceRange containment, DesiredRange vs CurrentRange comparison). User requests do NOT automatically trigger cancellation; validated rebalance necessity triggers cancellation + rescheduling. - *Note*: Cancellation prevents concurrent rebalance executions, not duplicate decision-making +- *Implementation*: Uses `Interlocked.Exchange` for atomic read-and-clear of pending rebalance, preventing race where multiple threads could call `Cancel()` on same `PendingRebalance` ### A.2 User-Facing Guarantees diff --git a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs index 0cd0466..c98b267 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Execution/RebalanceExecutor.cs @@ -19,6 +19,10 @@ namespace SlidingWindowCache.Core.Rebalance.Execution; /// Execution Context: Background / ThreadPool /// Characteristics: Asynchronous, cancellable, heavyweight /// Responsibility: Cache normalization (expand, trim, recompute NoRebalanceRange) +/// Execution Serialization: Uses to ensure only one rebalance +/// execution can write to cache state at a time. This guarantees single-writer semantics even when multiple +/// rebalance operations are scheduled concurrently. CancellationToken provides early exit signaling, while the +/// semaphore provides mutual exclusion for cache mutations. WebAssembly-compatible, async, and lightweight. /// internal sealed class RebalanceExecutor where TRange : IComparable @@ -27,6 +31,7 @@ internal sealed class RebalanceExecutor private readonly CacheState _state; private readonly CacheDataExtensionService _cacheExtensionService; private readonly ICacheDiagnostics _cacheDiagnostics; + private readonly SemaphoreSlim _executionSemaphore = new SemaphoreSlim(1, 1); public RebalanceExecutor( CacheState state, @@ -65,6 +70,9 @@ ICacheDiagnostics cacheDiagnostics /// This executor is intentionally simple - no analytical decisions, no necessity checks. /// Decision logic has been validated by DecisionEngine before invocation. /// + /// Serialization: Uses semaphore to ensure only one execution can write to cache at a time. + /// Semaphore is acquired before I/O operations to prevent queue buildup while allowing cancellation to propagate. + /// If cancelled during wait, the operation exits cleanly without acquiring the semaphore. /// public async Task ExecuteAsync( Intent intent, @@ -75,30 +83,43 @@ public async Task ExecuteAsync( // Use delivered data as the base - this is what the user received var baseRangeData = intent.AvailableRangeData; - // Cancellation check before expensive I/O - // Satisfies Invariant 34a: "Rebalance Execution MUST yield to User Path requests immediately" - cancellationToken.ThrowIfCancellationRequested(); + // Acquire semaphore to serialize execution - ensures only one rebalance writes to cache at a time + // This prevents race conditions even when multiple rebalance operations are scheduled concurrently + await _executionSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - // Phase 1: Extend delivered data to cover desired range (fetch only truly missing data) - // Use delivered data as base instead of current cache to ensure consistency - var extended = await _cacheExtensionService.ExtendCacheAsync(baseRangeData, desiredRange, cancellationToken) - .ConfigureAwait(false); + try + { + // Cancellation check after acquiring semaphore but before expensive I/O + // Satisfies Invariant 34a: "Rebalance Execution MUST yield to User Path requests immediately" + cancellationToken.ThrowIfCancellationRequested(); - // Cancellation check after I/O but before mutation - // If User Path cancelled us, don't apply the rebalance result - cancellationToken.ThrowIfCancellationRequested(); + // Phase 1: Extend delivered data to cover desired range (fetch only truly missing data) + // Use delivered data as base instead of current cache to ensure consistency + var extended = await _cacheExtensionService.ExtendCacheAsync(baseRangeData, desiredRange, cancellationToken) + .ConfigureAwait(false); - // Phase 2: Trim to desired range (rebalancing-specific: discard data outside desired range) - baseRangeData = extended[desiredRange]; + // Cancellation check after I/O but before mutation + // If User Path cancelled us, don't apply the rebalance result + cancellationToken.ThrowIfCancellationRequested(); - // Final cancellation check before applying mutation - // Ensures we don't apply obsolete rebalance results - cancellationToken.ThrowIfCancellationRequested(); + // Phase 2: Trim to desired range (rebalancing-specific: discard data outside desired range) + baseRangeData = extended[desiredRange]; - // Phase 3: Apply cache state mutations - UpdateCacheState(baseRangeData, intent.RequestedRange, desiredNoRebalanceRange); + // Final cancellation check before applying mutation + // Ensures we don't apply obsolete rebalance results + cancellationToken.ThrowIfCancellationRequested(); - _cacheDiagnostics.RebalanceExecutionCompleted(); + // Phase 3: Apply cache state mutations + UpdateCacheState(baseRangeData, intent.RequestedRange, desiredNoRebalanceRange); + + _cacheDiagnostics.RebalanceExecutionCompleted(); + } + finally + { + // Always release semaphore, even if cancelled or exception occurred + // This ensures subsequent rebalance operations can proceed + _executionSemaphore.Release(); + } } /// diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index 0f871e9..d65bb74 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -173,14 +173,16 @@ public void PublishIntent(Intent intent) return; } - // Step 3: Cancel pending rebalance (mechanical safeguard for state transition) - // Use DDD-style cancellation through PendingRebalance domain object + // Step 3: Atomically cancel pending rebalance (race-free coordination) + // Use Interlocked.Exchange to atomically read and clear _pendingRebalance in single operation + // This prevents race where two threads could both call Cancel() on same PendingRebalance // This is NOT a blind cancellation - it only happens when DecisionEngine validated necessity - var oldPending = Volatile.Read(ref _pendingRebalance); + var oldPending = Interlocked.Exchange(ref _pendingRebalance, null); oldPending?.Cancel(); // Step 4: Delegate to scheduler and capture returned PendingRebalance // Scheduler fully owns execution infrastructure (CTS, Task, debounce, exceptions) + // New rebalance scheduled AFTER old one is cancelled to ensure proper semaphore acquisition ordering var newPending = _scheduler.ScheduleRebalance(intent, decision); // Step 5: Update pending rebalance snapshot for next Stage 2 validation diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs index 9f279d1..c97b159 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/PendingRebalance.cs @@ -79,10 +79,30 @@ public PendingRebalance( /// This method provides a more DDD-aligned approach where the domain object /// encapsulates its own behavior (cancellation) rather than requiring external /// management through the IntentController. + /// Safe to call multiple times - subsequent calls are no-ops. /// public void Cancel() { - _cts?.Cancel(); - _cts?.Dispose(); + if (_cts == null) return; + + try + { + _cts.Cancel(); + } + catch (ObjectDisposedException) + { + // Already disposed - safe to ignore + } + finally + { + try + { + _cts.Dispose(); + } + catch (ObjectDisposedException) + { + // Already disposed - safe to ignore + } + } } } \ No newline at end of file From 5c3d60d1a6a46d35822239e6b616166488ae896e Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 23:11:55 +0100 Subject: [PATCH 21/23] Update tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../WindowCacheInvariantTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs index f835bc0..c32af74 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs @@ -528,7 +528,7 @@ public async Task Invariant_D29_Stage2_SkipsWhenWithinPendingNoRebalanceRange() leftRatio: -(options.LeftThreshold ?? 0), rightRatio: -(options.RightThreshold ?? 0) ); - var nextRequestRange = TestHelpers.CreateRange(320, 420); // Span 11, within pending NoRebalanceRange but outside current NoRebalanceRange + var nextRequestRange = TestHelpers.CreateRange(320, 420); // Span 101, within pending NoRebalanceRange but outside current NoRebalanceRange // ACT: Establish initial cache await TestHelpers.ExecuteRequestAndWaitForRebalance(cache, initialRange); From 5b2665da459d2c88b6f6110673c8c9344ef03ad7 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 23:13:17 +0100 Subject: [PATCH 22/23] Update src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Core/Rebalance/Intent/IntentController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs index d65bb74..e89fe78 100644 --- a/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs +++ b/src/SlidingWindowCache/Core/Rebalance/Intent/IntentController.cs @@ -210,7 +210,7 @@ private void RecordReason(RebalanceReason reason) _cacheDiagnostics.RebalanceScheduled(); break; default: - throw new ArgumentOutOfRangeException(nameof(reason), reason, null); + throw new ArgumentOutOfRangeException(nameof(reason), reason, "Unhandled rebalance reason"); } } From 15563714c771627de11a175fb63edbf5e7532e46 Mon Sep 17 00:00:00 2001 From: Mykyta Zotov Date: Tue, 17 Feb 2026 23:13:57 +0100 Subject: [PATCH 23/23] Update tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../WindowCacheInvariantTests.cs | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs index c32af74..a5d417f 100644 --- a/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs +++ b/tests/SlidingWindowCache.Invariants.Tests/WindowCacheInvariantTests.cs @@ -513,23 +513,17 @@ public async Task Invariant_D29_Stage2_SkipsWhenWithinPendingNoRebalanceRange() debounceDelay: TimeSpan.FromMilliseconds(2000)); // Long debounce to create pending state var (cache, _) = TrackCache(TestHelpers.CreateCacheWithDefaults(_domain, _cacheDiagnostics, options)); var initialRange = TestHelpers.CreateRange(200, 300); // Span 101 - // Initial cache range [98, 400] size 303, NoRebalanceRange 20% from 301 = 60 on left side, so [159, 340] - var initialCacheRange = initialRange.ExpandByRatio(_domain, options.LeftCacheSize, options.RightCacheSize); - var initialNoRebalanceRange = initialCacheRange.ExpandByRatio( - domain: _domain, - leftRatio: -(options.LeftThreshold ?? 0), // Negate to shrink - rightRatio: -(options.RightThreshold ?? 0) // Negate to shrink - ); + // Initial cache range is expected to be [98, 400] (size 303). + // The NoRebalanceRange is 20% shrunk from each side of that cache range, + // which gives [159, 340] (301 inner span; 20% of 301 is ~60 on each side). + var requestRange = TestHelpers.CreateRange(300, 400); // Span 101 - // Desired cache range for request would be [198, 500], NoRebalanceRange would be [258, 440] - var desiredCacheRange = requestRange.ExpandByRatio(_domain, options.LeftCacheSize, options.RightCacheSize); - var desiredNoRebalanceRange = desiredCacheRange.ExpandByRatio( - domain: _domain, - leftRatio: -(options.LeftThreshold ?? 0), - rightRatio: -(options.RightThreshold ?? 0) - ); - var nextRequestRange = TestHelpers.CreateRange(320, 420); // Span 101, within pending NoRebalanceRange but outside current NoRebalanceRange + // Desired cache range for this request would be [198, 500], + // and its NoRebalanceRange would be [258, 440] using the same 20% shrink. + // Next request is chosen to be within the pending rebalance's NoRebalanceRange + // but outside the current NoRebalanceRange, to trigger a Stage 2 skip. + var nextRequestRange = TestHelpers.CreateRange(320, 420); // Span 101 // ACT: Establish initial cache await TestHelpers.ExecuteRequestAndWaitForRebalance(cache, initialRange); _cacheDiagnostics.Reset();