Skip to content

fix #228: Critical: Inconsistent reads in single-node mode #229

Merged
JoshuaChi merged 6 commits intodevelopfrom
hotfix/228_linear_read
Jan 1, 2026
Merged

fix #228: Critical: Inconsistent reads in single-node mode #229
JoshuaChi merged 6 commits intodevelopfrom
hotfix/228_linear_read

Conversation

@JoshuaChi
Copy link
Copy Markdown
Contributor

@JoshuaChi JoshuaChi commented Jan 1, 2026

Type

  • New feature
  • Bug Fix

Related Issues

Checklist

  • The code has been tested locally (unit test or integration test)
  • Squash down commits to one or two logical commits which clearly describe the work you've done.

Summary by CodeRabbit

  • New Features

    • Linearizable reads now synchronize state machine before returning data, ensuring improved consistency guarantees.
  • Configuration

    • Added state_machine_sync_timeout_ms parameter (default 10ms) for read consistency tuning.
  • Documentation

    • Restructured README with unified Integration Modes framework and linearizable read examples.
  • Tests

    • Added multi-node linearizable read consistency validation and post-write visibility tests.

✏️ Tip: You can customize this high-level summary in your review settings.

Problem:
- put() → get_linearizable() immediately after returned None
- Root cause: leader commits log but doesn't wait for state machine apply
- Only reproducible on first startup (no existing data)

Solution (following OpenRaft best practices):
- Add tokio::sync::watch channel to notify when last_applied advances
- ensure_state_machine_upto_commit_index() now waits for apply completion
- Configurable timeout: state_machine_sync_timeout_ms (default: 10ms)

Performance impact:
- Linearizable Read: +0.8% (no regression)
- LeaseRead: +9.5% (unexpected improvement)
- Eventual Read: -6.1% (will optimize in v0.3.0)
- Write: -3.0% (acceptable for correctness)

Trade-off: -3~6% performance for critical data consistency fix.
- Add mock expectations for wait_applied() and read_from_state_machine() in leader_state_test
- Fix quick-start-embedded example to use get_linearizable instead of get_eventual
- Add GET verification to service-discovery-embedded after PUT operations
- Add comprehensive integration tests for linearizable read after write (Test 1 & Test 5b)
Copilot AI review requested due to automatic review settings January 1, 2026 16:02
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Jan 1, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

The pull request adds linearizable read support to a Raft-based key-value system by introducing state machine synchronization. A new configuration parameter specifies the state machine sync timeout, a wait_applied method is added to the StateMachineHandler trait, and the linearizable read path is updated to wait for the state machine to apply committed entries before returning results.

Changes

Cohort / File(s) Summary
Configuration & Raft Settings
README.md, config/base/raft.toml, d-engine-core/src/config/raft.rs
Added state_machine_sync_timeout_ms configuration field to control timeout for state machine catch-up in linearizable reads; updated documentation structure and examples to reflect new initialization patterns.
State Machine Handler Interface & Implementation
d-engine-core/src/state_machine_handler/mod.rs, d-engine-core/src/state_machine_handler/default_state_machine_handler.rs
Introduced async wait_applied(target_index, timeout) method to trait; implemented via watch channel (applied_notify_tx/rx) to notify advancement of last_applied index after applying chunks.
Linearizable Read Path
d-engine-core/src/raft_role/leader_state.rs
Made ensure_state_machine_upto_commit_index async and updated ClientReadRequest flow to await completion with configurable timeout, ensuring state machine is synchronized before returning linearizable reads.
Cluster & Component Tests
d-engine-server/tests/cluster_start_stop/cluster_integration_test.rs, d-engine-server/tests/components/raft_role/leader_state_test.rs, d-engine-server/tests/embedded/local_kv_client_integration_test.rs
Added multi-node linearizable read consistency tests; updated leader_state tests to mock and verify wait_applied invocations.
Example Applications
examples/quick-start-embedded/src/main.rs, examples/service-discovery-embedded/server.rs
Switched read operations from eventual to linearizable consistency; added assertions verifying written values are visible immediately after writes.

Sequence Diagram

sequenceDiagram
    participant Client
    participant LocalKvClient
    participant LeaderState
    participant StateMachineHandler
    participant RaftApply
    
    Client->>LocalKvClient: get_linearizable(key)
    LocalKvClient->>LeaderState: handle_client_read_request()
    
    rect rgb(220, 240, 250)
        Note over LeaderState,StateMachineHandler: State Machine Synchronization
        LeaderState->>LeaderState: ensure_state_machine_upto_commit_index()
        LeaderState->>StateMachineHandler: wait_applied(target_index, timeout)
        activate StateMachineHandler
        
        par Async Wait
            StateMachineHandler->>StateMachineHandler: watch for last_applied advancement
            Note over RaftApply: Concurrent apply loop<br/>processes committed entries
            RaftApply->>StateMachineHandler: notify applied_notify_tx
            StateMachineHandler->>StateMachineHandler: target_index reached
        end
        
        StateMachineHandler-->>LeaderState: Ok(())
        deactivate StateMachineHandler
    end
    
    rect rgb(240, 250, 220)
        Note over LeaderState,Client: Safe Read Phase
        LeaderState->>LeaderState: read from state machine
        LeaderState-->>LocalKvClient: value
        LocalKvClient-->>Client: value
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • V0.1.4 release #149: Addresses linearizable-read and state-machine synchronization code paths using the same trait method extensions (wait_applied) and async/await patterns introduced in this PR.
  • feat #167: workspace structure #175: Modifies Raft linearizable-read implementations and state machine sync APIs (leader_state.rs, state_machine_handler trait), directly related to the state-machine wait mechanisms added here.

Poem

🐰 With patience and watchful ears,
The state machine keeps time clear—
No stale reads shall peek through,
For the rabbit ensures you
Get the latest view, fresh and true! 📚✨

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the issue being fixed (#228) and accurately describes the main change: ensuring linearizable reads in single-node mode by making them wait for state machine synchronization.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@JoshuaChi
Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Jan 1, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a critical bug (#228) where linearizable reads in single-node mode could return stale or missing data immediately after writes. The fix implements a proper wait mechanism to ensure the state machine has applied all committed entries before serving linearizable reads, maintaining Raft's linearizability guarantee across both single-node and multi-node deployments.

Key Changes

  • Introduces wait_applied() method in StateMachineHandler trait to wait for state machine to catch up with commit index
  • Adds watch channel notification mechanism in DefaultStateMachineHandler to signal when entries are applied
  • Updates ensure_state_machine_upto_commit_index() in LeaderState to asynchronously wait for state machine synchronization
  • Adds comprehensive integration tests validating linearizable read guarantees with no artificial delays

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
d-engine-core/src/state_machine_handler/mod.rs Adds wait_applied() trait method for linearizable read support
d-engine-core/src/state_machine_handler/default_state_machine_handler.rs Implements watch-based notification mechanism and wait logic for state machine synchronization
d-engine-core/src/raft_role/leader_state.rs Updates linearizable read path to asynchronously wait for state machine before serving reads
d-engine-core/src/config/raft.rs Adds configurable timeout for state machine synchronization (default 10ms)
config/base/raft.toml Documents new state_machine_sync_timeout_ms configuration parameter
d-engine-server/tests/embedded/local_kv_client_integration_test.rs Adds focused tests for linearizable read-after-write and sequential write scenarios
d-engine-server/tests/cluster_start_stop/cluster_integration_test.rs Adds multi-node test to verify consistent behavior across cluster sizes
d-engine-server/tests/components/raft_role/leader_state_test.rs Updates mock tests to handle new async behavior and wait_applied expectations
examples/service-discovery-embedded/server.rs Adds verification of linearizable reads after writes with assertions
examples/quick-start-embedded/src/main.rs Replaces eventual consistency reads with linearizable reads and adds error handling
README.md Simplifies quick start example to use linearizable reads by default
Comments suppressed due to low confidence (1)

d-engine-core/src/config/raft.rs:996

  • The validate method does not check if state_machine_sync_timeout_ms is greater than 0. A timeout value of 0 would cause tokio::time::timeout to return immediately with an error, which could lead to unexpected behavior. Consider adding validation to ensure this value is greater than 0, similar to how lease_duration_ms is validated.
impl ReadConsistencyConfig {
    fn validate(&self) -> Result<()> {
        // Validate read consistency configuration
        if self.lease_duration_ms == 0 {
            return Err(Error::Config(ConfigError::Message(
                "read_consistency.lease_duration_ms must be greater than 0".into(),
            )));
        }
        Ok(())
    }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
d-engine-core/src/config/raft.rs (1)

987-996: Consider adding validation for state_machine_sync_timeout_ms.

The validate() method checks lease_duration_ms > 0 but doesn't validate the new state_machine_sync_timeout_ms field. For consistency and to prevent misconfigurations, consider adding a similar check.

🔎 Suggested validation addition
 impl ReadConsistencyConfig {
     fn validate(&self) -> Result<()> {
         // Validate read consistency configuration
         if self.lease_duration_ms == 0 {
             return Err(Error::Config(ConfigError::Message(
                 "read_consistency.lease_duration_ms must be greater than 0".into(),
             )));
         }
+        if self.state_machine_sync_timeout_ms == 0 {
+            return Err(Error::Config(ConfigError::Message(
+                "read_consistency.state_machine_sync_timeout_ms must be greater than 0".into(),
+            )));
+        }
         Ok(())
     }
 }
d-engine-server/tests/components/raft_role/leader_state_test.rs (1)

857-947: Linearizable read test now correctly exercises state machine handler

Wiring MockStateMachineHandler into the MockBuilder and asserting update_pending + wait_applied + read_from_state_machine in test_handle_raft_event_case6_2 gives you an end‑to‑end check of the linearizable read path. If you want this even tighter, you could add .times(1) on read_from_state_machine as well, but that’s optional.

d-engine-core/src/state_machine_handler/default_state_machine_handler.rs (1)

105-109: wait_applied + watch-channel wiring for linearizable reads looks correct; consider a couple of refinements

  • Using a watch channel keyed by last_applied and cloning the receiver in wait_applied is a good fit: waiters see the current value immediately and then block on changed() only when necessary.
  • Updating last_applied with Ordering::Release in apply_chunk and sending on applied_notify_tx ensures wait_applied observes applied entries in order, and the >= check is safe under the Raft “apply in log order” assumption.
  • Seeding the channel with last_applied_index in new means fresh waiters won’t unnecessarily block when state is already caught up.

Two small follow-ups you might consider:

  • For timeouts and channel-closure, you currently return Error::Fatal. If a slow/blocked state machine should not be treated as process‑fatal, you may want a more specific non‑fatal error for read paths so the node can remain healthy while signaling a failed linearizable read.
  • If there are code paths (e.g., snapshot application) that can advance the state machine’s applied index without going through apply_chunk, it would be safer to also update last_applied and send on applied_notify_tx there to keep wait_applied in sync.

Also applies to: 163-187, 238-246, 789-823

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1d42cb4 and 17a612e.

📒 Files selected for processing (11)
  • README.md
  • config/base/raft.toml
  • d-engine-core/src/config/raft.rs
  • d-engine-core/src/raft_role/leader_state.rs
  • d-engine-core/src/state_machine_handler/default_state_machine_handler.rs
  • d-engine-core/src/state_machine_handler/mod.rs
  • d-engine-server/tests/cluster_start_stop/cluster_integration_test.rs
  • d-engine-server/tests/components/raft_role/leader_state_test.rs
  • d-engine-server/tests/embedded/local_kv_client_integration_test.rs
  • examples/quick-start-embedded/src/main.rs
  • examples/service-discovery-embedded/server.rs
🧰 Additional context used
🧬 Code graph analysis (4)
d-engine-core/src/state_machine_handler/mod.rs (1)
d-engine-core/src/state_machine_handler/default_state_machine_handler.rs (1)
  • wait_applied (163-187)
d-engine-server/tests/embedded/local_kv_client_integration_test.rs (1)
d-engine-core/src/watch/manager.rs (1)
  • key (58-60)
d-engine-server/tests/components/raft_role/leader_state_test.rs (1)
d-engine-server/src/test_utils/mock/mock_node_builder.rs (1)
  • new (139-160)
d-engine-core/src/state_machine_handler/default_state_machine_handler.rs (1)
d-engine-core/src/state_machine_handler/mod.rs (1)
  • wait_applied (87-91)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Agent
  • GitHub Check: Lint and Format Check
  • GitHub Check: Lint and Format Check
🔇 Additional comments (15)
examples/quick-start-embedded/src/main.rs (2)

63-68: LGTM! Linearizable read correctly replaces eventual consistency.

The change from get_eventual to get_linearizable ensures the read reflects the immediately preceding write, which is the correct consistency level for this demo workflow.


81-94: Good addition of error handling for read verification.

The error message correctly identifies that a None result after a successful PUT indicates a bug when using linearizable reads. The fallback error path improves the robustness of the example.

examples/service-discovery-embedded/server.rs (2)

88-98: Excellent verification pattern for linearizable reads.

The immediate read-after-write verification effectively demonstrates the linearizable consistency guarantee. The panic message clearly identifies this as a bug scenario, which is appropriate for example code.


110-120: LGTM! Consistent verification after UPDATE.

The verification pattern after UPDATE matches the PUT verification, maintaining consistency in the example and reinforcing the linearizable read guarantee.

d-engine-core/src/config/raft.rs (1)

953-959: Well-documented configuration field.

The documentation clearly explains the purpose, typical latency expectations, and rationale for the default value. The 10ms default provides a reasonable safety margin over the typical <1ms apply latency.

README.md (2)

45-70: LGTM! Quick start example properly demonstrates linearizable reads.

The updated example correctly uses get_linearizable to demonstrate strong consistency guarantees. The simplified error handling with unwrap() is appropriate for a quick-start example, keeping the focus on the core API usage.


74-99: Clear distinction between integration modes.

The restructured documentation effectively communicates the embedded vs standalone trade-offs, with clear "Use when" and "Why" guidance for each mode. The performance note provides valuable context for users making deployment decisions.

config/base/raft.toml (1)

86-89: Well-documented configuration parameter.

The inline documentation clearly explains the purpose of the timeout, provides context about typical apply latency, and justifies the default value. The 10ms default provides an appropriate safety buffer for linearizable reads.

d-engine-server/tests/components/raft_role/leader_state_test.rs (2)

389-447: Good coverage of ensure_state_machine_upto_commit_index happy/no-op paths

The two tests correctly assert that update_pending/wait_applied are invoked only when last_applied < commit_index, and skipped when last_applied >= commit_index, and they’ve been updated to await the now-async method. This is a solid regression check for the new behavior.


4926-4940: Unspecified policy test now validates linearizable default path

The handle_client_read_request::test_handle_client_read_unspecified_policy_leader update to mock wait_applied/read_from_state_machine and inject the handler via with_state_machine_handler matches the new linearizable default behavior and should catch regressions in the read path.

d-engine-server/tests/cluster_start_stop/cluster_integration_test.rs (1)

260-340: Multi-node linearizable read test is well-structured and reuses helpers

The new test_multi_node_linearizable_read_consistency cleanly reuses the existing cluster setup and test_put_get helper to assert PUT→linearizable GET without sleep and sequential overwrite behavior in a 3‑node cluster. This directly codifies the regression scenario and looks correct.

d-engine-core/src/state_machine_handler/mod.rs (1)

81-92: Trait-level wait_applied API is clear and appropriately scoped

Adding wait_applied(target_index, timeout) to StateMachineHandler with explicit linearizability docs cleanly captures the new responsibility and matches the implementation in the default handler. Signature (u64 index + Duration timeout, returning Result<()>) is flexible enough for other implementations.

d-engine-server/tests/embedded/local_kv_client_integration_test.rs (1)

377-481: New LocalKvClient linearizable-read tests accurately encode the bug scenario

Both tests precisely capture the intended guarantees:

  • test_linearizable_read_after_write_no_sleep validates read‑after‑write with no delay on a single node.
  • test_linearizable_read_sees_latest_value checks sequential overwrites always surface the latest value via get_linearizable.

They’re minimal, deterministic, and directly guard against regressions in the new wait_applied path.

d-engine-core/src/raft_role/leader_state.rs (2)

914-920: Linearizable read fix correctly awaits state machine synchronization.

The change properly ensures that linearizable reads wait for the state machine to apply all committed entries before serving the request. The error handling is appropriate, failing the read request with a descriptive message if the state machine fails to catch up within the configured timeout.


1848-1872: State machine synchronization implementation is correct.

The async implementation properly waits for the state machine to catch up to the commit index before allowing linearizable reads to proceed. Configuration verification confirms that state_machine_sync_timeout_ms is properly defined (default: 10ms) in d-engine-core/src/config/raft.rs, and the wait_applied method in DefaultStateMachineHandler correctly handles timeouts and returns immediately when the state machine has already reached the target index.

The last_applied parameter comparison could theoretically be stale, but the wait_applied implementation gracefully handles this scenario by checking if the current applied index is already at or beyond the target before blocking, avoiding any correctness issues.

@codecov
Copy link
Copy Markdown

codecov bot commented Jan 1, 2026

Codecov Report

❌ Patch coverage is 82.75862% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...e_machine_handler/default_state_machine_handler.rs 75.00% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

- Add test_put_get_no_sleep() for true no-sleep linearizable read tests
- Fix test_remove_node connection conflict with independent mock addresses
- Increase state_machine_sync_timeout to 100ms in tests for CI stability
- Replace test_put_get with test_put_get_no_sleep in multi-node linearizable test
…gnostics

Include current state machine index in timeout error to help diagnose:
- State machine stuck at specific index
- Apply progress too slow
- No progress at all
- Reduce example code from 30 to 13 lines (keep essentials)
- Add links to examples/quick-start-embedded
- Add links to examples/service-discovery-embedded
- Add link to examples/quick-start-standalone
@JoshuaChi JoshuaChi merged commit dcae76c into develop Jan 1, 2026
4 checks passed
JoshuaChi added a commit that referenced this pull request Jan 2, 2026
# fix #228: Critical: Inconsistent reads in single-node mode  (#229)

## Problem

**Critical bug**: `put()` → `get_linearizable()` returned `None` immediately after write

- **Root cause**: Leader commits log but doesn't wait for state machine to apply
- **Impact**: Violates linearizability guarantee - reads don't reflect committed writes
- **Reproducibility**: Only on first startup (no existing data) due to timing race

## Solution

Implemented **wait-for-apply mechanism**:

1. **Watch channel for apply notifications**
   - `tokio::sync::watch` notifies when `last_applied` advances
   - `ensure_state_machine_upto_commit_index()` waits until target index applied

2. **Configurable timeout**
   - `state_machine_sync_timeout_ms` (default: 10ms for local SSD)
   - Tests use 100ms for CI environment reliability

3. **Enhanced error diagnostics**
   - Timeout errors now include `current_applied` index
   - Helps diagnose: stuck state machine, slow apply, or no progress
@JoshuaChi JoshuaChi deleted the hotfix/228_linear_read branch January 2, 2026 10:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants