fix: catch uncaught exception in onApply to prevent apply pipeline stall#1246
Conversation
📝 WalkthroughWalkthroughThe changes add error handling for uncaught exceptions in the state machine's Changes
Sequence DiagramsequenceDiagram
participant FSM as FSMCaller
participant Task as doApplyTasks
participant Handler as User StateMachine
participant Iter as Iterator
rect rgba(255, 0, 0, 0.5)
Note over FSM,Iter: Before: Exception Causes Infinite Stall
FSM->>Task: execute(iterImpl)
Task->>Handler: onApply(entry)
Handler--XTask: throws Throwable
Note over Task: Exception not caught<br/>lastAppliedIndex not advanced
Task-->>FSM: exception bubbles up
Note over FSM: Next doCommitted() retries<br/>same entry → infinite loop
end
rect rgba(0, 255, 0, 0.5)
Note over FSM,Iter: After: Exception Handled Gracefully
FSM->>Task: try { execute(iterImpl) }
Task->>Handler: onApply(entry)
Handler--XTask: throws Throwable
Task-->>FSM: catch(Throwable)
FSM->>FSM: log error
FSM->>Iter: setErrorAndRollback(1, ESTATEMACHINE)
FSM->>FSM: break (stop processing)
Note over FSM: Node transitions to ERROR state<br/>No infinite retry, callbacks notified
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
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. Comment Tip Migrating from UI to YAML configuration.Use the |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
jraft-core/src/test/java/com/alipay/sofa/jraft/core/OnApplyExceptionTest.java (1)
150-151: Replace fixed sleep with bounded condition wait to reduce test flakiness.
Thread.sleep(3000)can be timing-sensitive and slow CI; prefer polling with timeout until the expected condition is met.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@jraft-core/src/test/java/com/alipay/sofa/jraft/core/OnApplyExceptionTest.java` around lines 150 - 151, In OnApplyExceptionTest replace the fixed Thread.sleep(3000) with a bounded condition wait: poll the expected condition (that the exception fired and the error state has been set) in a short-interval loop or use a synchronization primitive (e.g., CountDownLatch.await with timeout or Awaitility) until it becomes true or a reasonable timeout elapses; update the check around the Thread.sleep location in OnApplyExceptionTest to break out early when the condition is met and fail the test if the timeout is reached to avoid flakiness and long CI waits.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@jraft-core/src/test/java/com/alipay/sofa/jraft/core/OnApplyExceptionTest.java`:
- Around line 143-164: Add assertions that verify the poison task's closure was
executed and that it received an error status: change the Task callback passed
to node.apply to record the callback firing and capture the provided status (use
the existing poisonCallbackFired and poisonLatch and add an AtomicReference for
the status, e.g., poisonStatusRef), then wait on poisonLatch (with a timeout)
and assert poisonCallbackFired is true and poisonStatusRef.get() is non-null and
indicates an error state; place these checks after the Thread.sleep() /
exception count assertions to validate remaining closures are notified with an
error.
---
Nitpick comments:
In
`@jraft-core/src/test/java/com/alipay/sofa/jraft/core/OnApplyExceptionTest.java`:
- Around line 150-151: In OnApplyExceptionTest replace the fixed
Thread.sleep(3000) with a bounded condition wait: poll the expected condition
(that the exception fired and the error state has been set) in a short-interval
loop or use a synchronization primitive (e.g., CountDownLatch.await with timeout
or Awaitility) until it becomes true or a reasonable timeout elapses; update the
check around the Thread.sleep location in OnApplyExceptionTest to break out
early when the condition is met and fail the test if the timeout is reached to
avoid flakiness and long CI waits.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 86437b88-1364-4362-9147-2bf12409511a
📒 Files selected for processing (2)
jraft-core/src/main/java/com/alipay/sofa/jraft/core/FSMCallerImpl.javajraft-core/src/test/java/com/alipay/sofa/jraft/core/OnApplyExceptionTest.java
|
This is by design. If For example, if task A fails to deserialize, the state machine should not proceed to apply task B unless explicitly confirmed, since task B may depend on the state resulting from task A. |
|
@killme2008 Thanks for the reply! I completely agree with the design intent — if onApply() throws, the state machine should stop, not silently continue. However, I think the current code doesn't actually achieve that. Here's what happens:
The state machine isn't "stopped" — it's stuck in an infinite retry loop on the same failing entry, and setErrorAndRollback() is never reached. Our fix does exactly what you described as the intended design: catch the exception, call setErrorAndRollback(ESTATEMACHINE), and transition the node to ERROR state — so it actually stops instead of looping forever. Happy to discuss further if I'm misreading the code path! |
|
public void onApply(...) {
...
try {
while (iter.hasNext()) {
Task t = iter.next();
...
}
} catch (Throwable t) {
iter.setErrorAndRollback(entriesWantedToRollback, reason);
}
}While I understand the use case, I prefer not to trigger automatic rollbacks. Rollback should be managed by the user. As a library, we cannot safely assume a rollback is appropriate for every caught exception unless we introduce a checked exception like |
|
Thanks for the explanation! I agree that rollback should be managed by the user, not triggered automatically by the library. But I'm curious about your thoughts on the livelock scenario itself -- when onApply() throws and the user didn't write a try-catch, lastAppliedIndex never advances, and doCommitted() keeps retrying the same entry forever. The node looks alive but the FSM is silently stuck. Do you think this is acceptable behavior, or would it make sense to at least catch the exception and call setError(ESTATEMACHINE) to stop the node cleanly (without any rollback)? That way the error handling policy stays with the user, but we avoid the silent infinite loop. |
Agreed. Catch unexpected exceptions and call |
|
Thanks! Updated the PR to use setError instead of setErrorAndRollback -- no rollback, just halt the state machine on uncaught exceptions. |
|
Thanks for addressing the stall pipeline issue in CI is currently reporting a failure in the |
|
The failing test is |
As I said in #1245 (review) We should find a better way to test than using sleep; it’s fragile. |
9fbfcd9 to
e67c1c5
Compare
7070bab to
6a043dd
Compare
|
Thanks for the feedback! Replaced the sleeps with polling, and for the flaky |
Motivation
If
StateMachine.onApply()throws (NPE, deserialization error, etc.), the exception propagates out ofdoApplyTasks()(line 562), skippingsetLastApplied()(line 572).lastAppliedIndexis never advanced. NextdoCommitted()retries the same entries, hits the same exception, loops forever. The node is alive but the FSM is permanently stuck.Modification
Catch
ThrowablearounddoApplyTasks(), callsetErrorAndRollback()to enter the existing error handling path. After the catch,setError()+runTheRestClosureWithError()run normally, andsetLastApplied()advances the index.Test:
OnApplyExceptionTest#testOnApplyExceptionSetsErrorInsteadOfStalling— single-node cluster with aStateMachinethat throws on a specific entry. Exception fires exactly once (before the fix it grows unbounded).Result
Fixes #1244 (item 3).
Summary by CodeRabbit