fix: [#4741] follower-side recovery for stuck Raft-log divergence (IN… - #4744
Conversation
…CONSISTENCY loop)
On an idle cluster, a tiny (1-2 entry) Raft-log divergence at a low index makes the
leader's Ratis GrpcLogAppender spin forever on `received INCONSISTENCY reply with
nextIndex {4,5}, errorCount=...`: the follower replies snapshotIndex+1 for one index
then fails the previous-entry term check for the next, oscillating. The leader's log
is never compacted, so `shouldNotifyToInstallSnapshot` stays null and the proper
reconcile path never fires; the divergence is too small for the lag-based recoveries
(HA_STALE_FOLLOWER_LAG_THRESHOLD / HA_STALLED_REPLICA_RESYNC_DURATION_MS), which also
only re-acquire the database, not the Raft log. Only a node restart cleared it.
Add a follower-side detector: when a running follower recognizes a leader at a newer
term but cannot apply the leader's current-term entries (currentTerm > lastAppliedTerm
and commit == applied), HealthMonitor waits HA_STALE_FOLLOWER_RECOVERY_DURATION_MS for
the condition to persist, then reformats the follower's Raft storage and rejoins the
group (RaftHAServer.recoverFromDivergence -> restartRatis(format=true)). The peer
rejoins as a fresh member so the leader reconciles it via the snapshot-install path -
the automated, scoped equivalent of the manual node restart. Gated by the new
HA_DIVERGED_FOLLOWER_RECOVERY config (default true).
Tests: HealthMonitorTest streak unit tests (deterministic clock); RaftDivergedFollowerRecoveryIT
(@slow) verifies reformat+rejoin with no data loss and no false positives on a healthy node.
|
Tick the box to add this pull request to the merge queue (same as
|
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| Documentation | 1 minor |
| CodeStyle | 7 minor |
🟢 Metrics 44 complexity
Metric Results Complexity 44
🟢 Coverage 70.45% diff coverage · -7.37% coverage variation
Metric Results Coverage variation ✅ -7.37% coverage variation Diff coverage ✅ 70.45% diff coverage Coverage variation details
Coverable lines Covered lines Coverage Common ancestor commit (4da1bb6) 131929 98114 74.37% Head commit (1a62cba) 163848 (+31919) 109783 (+11669) 67.00% (-7.37%) Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch:
<coverage of head commit> - <coverage of common ancestor commit>Diff coverage details
Coverable lines Covered lines Diff coverage Pull request (#4744) 132 93 70.45% Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified:
<covered lines added or modified>/<coverable lines added or modified> * 100%
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Code Review
This pull request introduces a self-healing mechanism to resolve issues where a Raft follower becomes stuck due to log divergence (issue #4741). It adds a new configuration option HA_DIVERGED_FOLLOWER_RECOVERY, implements stuck-divergence detection and recovery in HealthMonitor and RaftHAServer (which reformats the local Raft storage and rejoins the group), and includes comprehensive unit and integration tests. The feedback suggests adding a check and warning log in RaftHAServer if deleteRecursive fails to completely delete the Raft storage directory before formatting, which would improve diagnostics and serviceability.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if (formatStorage) { | ||
| if (storageDir.exists()) | ||
| deleteRecursive(storageDir); | ||
| startupOption = RaftStorage.StartupOption.FORMAT; | ||
| } else |
There was a problem hiding this comment.
If deleteRecursive(storageDir) fails to completely delete the Raft storage directory (e.g., due to locked files or permission issues on Windows), the directory will still exist and may contain partial files. Since deleteRecursive silently ignores deletion failures, Ratis will subsequently attempt to start with RaftStorage.StartupOption.FORMAT on a non-empty directory, which can fail or cause unexpected behavior. Adding a check and a warning log if the directory still exists after deletion will greatly improve serviceability and diagnostics.
if (formatStorage) {
if (storageDir.exists()) {
deleteRecursive(storageDir);
if (storageDir.exists()) {
LogManager.instance().log(this, Level.WARNING,
"Failed to completely delete Raft storage directory: %s. Ratis startup may fail.", storageDir.getAbsolutePath());
}
}
startupOption = RaftStorage.StartupOption.FORMAT;
} else
Code Review - PR #4744: follower-side recovery for stuck Raft-log divergence (#4741)Reviewed the full diff against the HA/Raft code and the existing stale-follower recovery it mirrors. Overall this is a well-scoped, well-documented fix that fits the existing self-healing architecture cleanly. Strengths
Concerns / questions
Style / conventions
VerdictSolid, low-risk fix with good documentation and a sensible default-on gate. The main gap is test coverage of the positive detection predicate, given the destructive nature of the recovery action. Recommend adding a unit-level test for |
…icate; warn on failed reformat Code review follow-ups (gemini-code-assist + claude): - Extract isFollowerStuckDiverged() decision logic into a pure, package-private isStuckDivergedState(...) and add RaftHAServerStuckDivergenceTest covering the POSITIVE detection case (the destructive recovery makes the predicate the highest-risk piece, and the IT cannot reproduce a real divergence deterministically), plus healthy/lagging/leaderless/ catching-up/snapshot-pending/unreadable-state cases. - Guard a negative appliedTerm for symmetry with the other term/index range checks. - Warn (instead of failing opaquely) if deleteRecursive leaves the Raft storage directory behind before a FORMAT restart (locked file / Windows permission), per Gemini. - Document the deliberately non-atomic state reads (tolerated by the persistence window) and the IT's healthy-follower scope (diverged on-disk DB correction relies on the existing SnapshotInstaller path).
|
Review: follower-side recovery for stuck Raft-log divergence (issue #4741) Solid, well-scoped fix. The design (follower self-detects, waits for persistence, reformats + rejoins so the leader reconciles via snapshot-install) is the right automated analogue of the manual node restart, and the diff is clean and well-documented. Extracting the pure isStuckDivergedState(...) predicate so it can be unit-tested in isolation is exactly the right call given the recovery is destructive. Observations, ordered by significance: CORRECTNESS / POTENTIAL ISSUES
TESTS
STYLE / MINOR
Overall this is a careful, defensively-coded fix with strong unit coverage. My only substantive ask is bounding the reformat loop (point 1); points 2-3 are confirmations/comments. |
…covery mutual-exclusivity Address human PR review (#4744): - Repeated-reformat circuit breaker: a clean reformat resets the shared Ratis restart-retry counter, so a node whose divergence keeps reproducing would reformat + full-snapshot-install every persistence window forever without ever hitting the max-retries breaker. Add a separate bounded budget HA_DIVERGED_FOLLOWER_MAX_REFORMATS (default 5, 0=unbounded): cap reformats per divergence episode; once exhausted, log SEVERE once and stop instead of looping silently. The budget re-arms after the follower looks healthy for 5x HA_STALE_FOLLOWER_RECOVERY_DURATION_MS (episode considered resolved). HealthMonitor tracks the count/healthy-streak/exhausted flag and resets them on a CLOSED/EXCEPTION Ratis restart. - Document that checkStaleFollower (commit-applied > threshold) and checkStuckFollower (commit == applied) are mutually exclusive by construction, so at most one arms per tick. Tests: HealthMonitorTest gains budget-cap, unbounded (max=0), and re-arm-after-healthy cases.
The MongoDB wire-protocol plugin rejected any db.auth() attempt with 'Error on executing MongoDB saslStart command' because SASL commands were never handled. Implement the SASL PLAIN mechanism (RFC 4616) in MongoDBBackend, validating credentials against ArcadeDB server security (mirroring the cleartext approach used by the postgresw plugin). SCRAM-SHA-1/256 cannot be supported because ArcadeDB stores passwords as one-way PBKDF2 hashes and those challenge-response mechanisms require the clear-text password (or SCRAM-specific stored keys) server-side. Clients must therefore connect with authMechanism=PLAIN (use TLS to protect the credentials in transit).
Review: follower-side recovery for stuck Raft-log divergence (#4741)Reviewed the full diff plus the surrounding Potential issues1. False positive on a slow/asymmetric link vs. genuine log divergence (main risk). 2. Cost of a spurious reformat scales with DB size. 3. Integration test does not exercise the positive detection path. Minor / style
Things done well
VerdictSolid, well-tested at the unit level, and conservative by construction. The one item I'd want resolved before merge is #1/#3: confidence that the signature is specific to divergence and not to a sustained network outage, ideally backed by (or at least an explicit note about) the partition case. The rest are non-blocking. |
Review: follower-side recovery for stuck Raft-log divergence (#4741)Thanks for this - the change is well-reasoned and the documentation is excellent. The root-cause writeup (idle cluster, tiny divergence, leader's Strengths
Concerns1. Unrelated mongodbw SASL PLAIN change bundled in (#4746). The diff includes 2. Destructive recovery is default-on. 3. No cluster-level coordination across followers. If a systemic condition makes several followers simultaneously satisfy 4. Predicate matches non-divergence "can't receive entries" states. A follower that receives heartbeats (so it updates its term and sees the leader) but cannot receive 5. Partial-delete then proceed-anyway. In Test coverageStrong at the unit level. The one gap: Minor
Overall this is a solid, defensive fix with good tests. The main blocker is splitting out the unrelated mongodbw change (likely just a rebase); items 2-3 are worth an explicit decision before merge given the destructive, default-on nature of the recovery. |
Three data-visibility bugs in the MongoDB wire-protocol plugin: 1. An unfiltered find() returned a single document. The default limit was -1, which handleQuery(QueryParameters) treats as a legacy single-batch limit and negates to 1. Use 0 (no limit) as the default instead. 2. Collections created after the first connection (via Studio, SQL or another client) were invisible, because the collection cache was built once in the constructor. Resolve collections lazily against the live schema. 3. After a client disconnected, subsequent connections returned no data: handleClose() wiped the shared collection cache and every connection's last-result state, even though the database wrapper is cached per database and shared across all clients. Only remove the closing channel's state, mirroring the reference implementation. Adds MongoDBFindTest covering all three regressions.
…t-in, abort on partial wipe, richer log) Address round-3 review (gemini + human): - Default HA_DIVERGED_FOLLOWER_RECOVERY to false (opt-in): the action is destructive (auto-deletes Raft storage), replacing an operator-initiated node restart, so the first release ships opt-in pending a conscious default-on sign-off. - Abort the reformat if the Raft storage directory cannot be fully deleted (locked file / Windows) instead of running Ratis FORMAT on a non-empty directory (undefined behaviour); the throw is caught by the existing handler, increments restartFailureCount, and the reformat budget already counted the attempt - so it escalates through the retry/backoff machinery. - Log the exact currentTerm/appliedTerm/appliedIndex/commitIndex on recovery so an operator can confirm a genuine divergence vs a follower merely stuck at a stale term (e.g. a sustained one-sided outage), and reword to "stuck at a stale term" since the signature matches both. - Document the one-sided-outage caveat and the destructive nature in the config; note the freshly-joined/empty-node case is excluded (null applied TermIndex / catching-up / term==currentTerm). - IT now also pins the real-getter invariant on a healthy follower (appliedTerm==currentTerm, commit==applied) to de-risk the predicate-vs-live-getters wiring. Cosmetic field alignment.
PR Review: #4744Reviewed the full diff. Overall this is high-quality, well-documented work: the Raft divergence detector is split into a pure, unit-testable predicate, the destructive recovery is gated, bounded, and opt-in, and every code path has a test. The MongoDB fixes are small, correct, and each ships a regression test. Points worth discussing, ordered by importance: 1. Scope: this PR bundles four unrelated issuesThe diff mixes the HA/Raft diverged-follower recovery (#4741) with three MongoDB wire-protocol fixes (#4745, #4746 SASL, #4746 find limit). These touch disjoint modules and carry independent risk profiles. Splitting into two PRs would make review, bisection, and revert far cleaner - especially since the Raft change is destructive and the MongoDB changes are user-facing data correctness. Not a blocker, but worth noting. 2. recoverFromDivergence() runs restartRatis() synchronously on the health-monitor thread`checkStuckFollower()` calls `target.recoverFromDivergence()` inline, which calls `restartRatis(true)` under `synchronized (recoveryLock)` - a full Ratis teardown + storage delete + rebuild. This blocks the single health-monitor scheduled thread for the whole reformat, so no other health checks (lag, CLOSED/EXCEPTION recovery, allowlist refresh) run until it returns. This matches the existing `restartRatisIfNeeded()` pattern so it's consistent, but the reformat path is heavier (disk delete + FORMAT + full snapshot install). Worth confirming the tick interval and any executor timeouts tolerate a multi-second block here. 3. MongoDBDatabaseWrapper.collections is now never invalidatedPreviously `handleClose()` cleared the cache; now entries live for the wrapper's lifetime (correct fix for the reconnect bug). But `getCollection()` caches a `MongoDBCollectionWrapper` by name via `computeIfAbsent` and never removes it. If a type is dropped and recreated, the stale wrapper lingers. The `existsType()` guard correctly returns `null` for a dropped type, so behavior is safe, but the map only grows. Likely negligible; flagging in case collection churn is expected - worth a one-line note that drop/recreate relies on `MongoDBCollectionWrapper` re-resolving the type by name per op. 4. HA_DIVERGED_FOLLOWER_MAX_REFORMATS negative value silently means "unbounded"The breaker check is `divergedFollowerMaxReformats > 0`, so `0` (documented as unbounded) and any negative value behave identically. The doc only mentions `0`. Either clamp negatives or note that `<= 0` disables the breaker, so an operator typo like `-1` doesn't silently remove the safety cap. 5. SASL PLAIN: confirm credentials are never loggedThe implementation is sound and TLS guidance is documented. `parts[2]` is the cleartext password, and on the unsupported-command path `MongoDBDatabaseWrapper.handleCommand` logs the full `document` at SEVERE. Since `saslStart`/`saslContinue` are intercepted in `MongoDBBackend.handleCommand` before delegating to the wrapper, the password document never reaches that log line - good. Just worth a deliberate confirmation that no underlying bwaldvogel/Netty debug logging echoes the SASL payload. 6. Minor
Test coverageStrong. `HealthMonitorTest` covers the streak state machine, budget cap, unbounded mode, and re-arm-after-healthy with a deterministic clock; `RaftHAServerStuckDivergenceTest` covers the predicate's positive and all negative branches including unreadable state; the MongoDB regressions each pin a specific reported bug. Nothing here is a hard blocker - items 2-4 are the ones I'd most want addressed (or explicitly acknowledged) before merge. Automated review by Claude. Reviewer guidance from CLAUDE.md applied. |
…-safe The lazily-populated collection cache is never evicted; document that it stays bounded by the number of distinct type names (reused on drop/recreate) and that correctness after a drop/recreate relies on MongoDBCollectionWrapper re-resolving the type by name on each operation.
…erred cross-follower coordination Per maintainer sign-off: HA_DIVERGED_FOLLOWER_RECOVERY stays default true (self-heal #4741 out of the box) with the destructive/one-sided-outage caveats documented. Document that there is no cross-follower coordination - a systemic condition could make several followers reformat in the same window, briefly costing quorum - which is bounded (non-data-losing + HA_DIVERGED_FOLLOWER_MAX_REFORMATS) and a leader-coordinated one-at-a-time variant is deferred to a follow-up.
PR Review: #4744 - follower-side recovery for stuck Raft-log divergenceReviewed the full diff. This is solid, carefully-reasoned work - the self-documenting config descriptions, the split between the pure predicate ( 1. PR scope (process)The PR bundles two unrelated efforts: the HA Raft divergence recovery (#4741) and the MongoDB wire-protocol changes (SASL PLAIN auth #4746, 2. HA Raft - the highest-risk pieceDestructive recovery on a non-divergence signature (the core trade-off).
Operator visibility. The exhaustion path logs Non-atomic state reads.
3. MongoDB wire protocol
SASL PLAIN auth - enforcement question (security).
Lazy 4. Tests
Overall: well-engineered and well-tested. My main asks before merge are (a) confirm the MongoDB SASL auth is actually enforced (not just validated for opt-in clients), and (b) consider strengthening the divergence signal with an "applied index actually stuck" check and/or a metric, given the action is destructive and automatic. |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #4744 +/- ##
============================================
- Coverage 65.32% 65.31% -0.01%
- Complexity 580 594 +14
============================================
Files 1673 1673
Lines 131929 132045 +116
Branches 28244 28267 +23
============================================
+ Hits 86181 86246 +65
- Misses 33851 33892 +41
- Partials 11897 11907 +10 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…CONSISTENCY loop)
On an idle cluster, a tiny (1-2 entry) Raft-log divergence at a low index makes the leader's Ratis GrpcLogAppender spin forever on
received INCONSISTENCY reply with nextIndex {4,5}, errorCount=...: the follower replies snapshotIndex+1 for one index then fails the previous-entry term check for the next, oscillating. The leader's log is never compacted, soshouldNotifyToInstallSnapshotstays null and the proper reconcile path never fires; the divergence is too small for the lag-based recoveries (HA_STALE_FOLLOWER_LAG_THRESHOLD / HA_STALLED_REPLICA_RESYNC_DURATION_MS), which also only re-acquire the database, not the Raft log. Only a node restart cleared it.Add a follower-side detector: when a running follower recognizes a leader at a newer term but cannot apply the leader's current-term entries (currentTerm > lastAppliedTerm and commit == applied), HealthMonitor waits HA_STALE_FOLLOWER_RECOVERY_DURATION_MS for the condition to persist, then reformats the follower's Raft storage and rejoins the group (RaftHAServer.recoverFromDivergence -> restartRatis(format=true)). The peer rejoins as a fresh member so the leader reconciles it via the snapshot-install path - the automated, scoped equivalent of the manual node restart. Gated by the new HA_DIVERGED_FOLLOWER_RECOVERY config (default true).
Tests: HealthMonitorTest streak unit tests (deterministic clock); RaftDivergedFollowerRecoveryIT (@slow) verifies reformat+rejoin with no data loss and no false positives on a healthy node.
What does this PR do?
A brief description of the change being made with this pull request.
Motivation
What inspired you to submit this pull request?
Related issues
A list of issues either fixed, containing architectural discussions, otherwise relevant
for this Pull Request.
Additional Notes
Anything else we should know when reviewing?
Checklist
mvn clean packagecommand