Summary
A follower that catches up through a snapshot install advances its applied index without waking the threads blocked in RaftHAServer.waitForAppliedIndex(). They keep sleeping until quorumTimeout even though the index they are waiting for has already been reached.
The visible effects:
LINEARIZABLE reads fail with ReplicationException -> HTTP 503, although the local state machine was in fact up to date.
READ_YOUR_WRITES reads pay the full quorumTimeout and then log consistency degraded to EVENTUAL, a warning that misreports what happened.
Version: 26.8.1.
Detail
The wait is a plain wait/notify on applyNotifier:
// ha-raft/src/main/java/com/arcadedb/server/ha/raft/RaftHAServer.java:2121
public void waitForAppliedIndex(final long targetIndex, final boolean throwOnTimeout) {
if (targetIndex <= 0)
return;
try {
final long deadline = System.currentTimeMillis() + quorumTimeout;
synchronized (applyNotifier) {
while (getLastAppliedIndex() < targetIndex) {
final long remaining = deadline - System.currentTimeMillis();
if (remaining <= 0) {
...
}
applyNotifier.wait(remaining);
}
}
notifyApplied() has exactly one caller in the codebase, at the end of ArcadeStateMachine.applyTransaction():
// ha-raft/src/main/java/com/arcadedb/server/ha/raft/ArcadeStateMachine.java:636
// Wake up any threads waiting for this index (READ_YOUR_WRITES, waitForLocalApply)
final RaftHAServer raftHA = this.raftHAServer;
if (raftHA != null) {
raftHA.notifyApplied();
But applyTransaction() is not the only path that advances the applied index. reinitialize() — called by Ratis's StateMachineUpdater.reload() after a snapshot install, as the javadoc in that same file describes — seeds it directly and does not notify:
// ha-raft/src/main/java/com/arcadedb/server/ha/raft/ArcadeStateMachine.java:438
lastAppliedIndex.set(snapshotIndex);
...
updateLastAppliedTermIndex(snapshotInfo.getTerm(), snapshotIndex);
There is no notifyApplied() anywhere in reinitialize().
The waiter's loop condition reads RaftHAServer.getLastAppliedIndex(), which comes from Ratis itself:
// RaftHAServer.java:2191
return raftServer.getDivision(raftGroup.getGroupId()).getInfo().getLastAppliedIndex();
so the condition does become true after the snapshot is installed. The waiter simply is never told, and wait(remaining) only returns at the deadline.
Failure scenario
- A follower falls far enough behind that the leader ships a snapshot instead of log entries.
- A client read arrives at that follower with
X-ArcadeDB-Read-After: N (READ_YOUR_WRITES) or with LINEARIZABLE.
- The handler calls
waitForAppliedIndex(N); the local applied index is still below N, so the thread waits.
- The snapshot install completes and
reinitialize() sets the applied index past N. No notification is sent.
- The waiter sleeps for the remainder of
quorumTimeout.
LINEARIZABLE then throws ReplicationException and the read fails with 503; READ_YOUR_WRITES logs a degradation warning and serves the read.
The window closes as soon as any subsequent entry is applied, since that calls notifyApplied() and the loop re-checks. So the bug bites hardest on a cluster that is quiet right after a catch-up — which is exactly when a snapshot install happens and when these waits are most likely to be pending.
Suggested fix
Notify at the end of reinitialize() whenever the snapshot seeded a new index:
lastAppliedIndex.set(snapshotIndex);
updateLastAppliedTermIndex(snapshotInfo.getTerm(), snapshotIndex);
final RaftHAServer raftHA = this.raftHAServer;
if (raftHA != null)
raftHA.notifyApplied();
More robustly, since the waiter's condition is sourced from Ratis rather than from the state machine's own field, the two can drift apart for any Ratis-internal apply (configuration entries, leader no-op entries) that never reaches applyTransaction. A bounded re-check — applyNotifier.wait(Math.min(remaining, someSmallInterval)) — would make the wait robust against every missed-notification path rather than just this one, at the cost of a periodic wake-up.
Summary
A follower that catches up through a snapshot install advances its applied index without waking the threads blocked in
RaftHAServer.waitForAppliedIndex(). They keep sleeping untilquorumTimeouteven though the index they are waiting for has already been reached.The visible effects:
LINEARIZABLEreads fail withReplicationException-> HTTP 503, although the local state machine was in fact up to date.READ_YOUR_WRITESreads pay the fullquorumTimeoutand then logconsistency degraded to EVENTUAL, a warning that misreports what happened.Version: 26.8.1.
Detail
The wait is a plain
wait/notifyonapplyNotifier:notifyApplied()has exactly one caller in the codebase, at the end ofArcadeStateMachine.applyTransaction():But
applyTransaction()is not the only path that advances the applied index.reinitialize()— called by Ratis'sStateMachineUpdater.reload()after a snapshot install, as the javadoc in that same file describes — seeds it directly and does not notify:There is no
notifyApplied()anywhere inreinitialize().The waiter's loop condition reads
RaftHAServer.getLastAppliedIndex(), which comes from Ratis itself:so the condition does become true after the snapshot is installed. The waiter simply is never told, and
wait(remaining)only returns at the deadline.Failure scenario
X-ArcadeDB-Read-After: N(READ_YOUR_WRITES) or withLINEARIZABLE.waitForAppliedIndex(N); the local applied index is still belowN, so the thread waits.reinitialize()sets the applied index pastN. No notification is sent.quorumTimeout.LINEARIZABLEthen throwsReplicationExceptionand the read fails with 503;READ_YOUR_WRITESlogs a degradation warning and serves the read.The window closes as soon as any subsequent entry is applied, since that calls
notifyApplied()and the loop re-checks. So the bug bites hardest on a cluster that is quiet right after a catch-up — which is exactly when a snapshot install happens and when these waits are most likely to be pending.Suggested fix
Notify at the end of
reinitialize()whenever the snapshot seeded a new index:More robustly, since the waiter's condition is sourced from Ratis rather than from the state machine's own field, the two can drift apart for any Ratis-internal apply (configuration entries, leader no-op entries) that never reaches
applyTransaction. A bounded re-check —applyNotifier.wait(Math.min(remaining, someSmallInterval))— would make the wait robust against every missed-notification path rather than just this one, at the cost of a periodic wake-up.