@@ -1040,3 +1040,87 @@ func TestE2E_Reconfig_RemoveNode_Contention_QuorumsSwitchAfterApply(t *testing.T
10401040 t .Fatalf ("data did not commit with 2/3 after remove(4) applied" )
10411041 }
10421042}
1043+
1044+ func TestLeaderFallbackRunsWhenKHasUncommittedLeaderNoop (t * testing.T ) {
1045+ // Build a single-node leader with fast path on.
1046+ cfg := baseConfigFast (1 ) // or baseConfigFast if you have it
1047+ cfg .EnableFastPath = true
1048+ rl := newRaft (cfg )
1049+ primeSingleVoter (rl , rl .id )
1050+
1051+ rl .becomeCandidate ()
1052+ rl .becomeLeader ()
1053+
1054+ // Build a committed prefix: committed = 5
1055+ mustAppendCommitted (rl , 5 )
1056+
1057+ // Now append a leader-approved noop at k = committed+1 (index 6).
1058+ // This simulates the upstream leader's noop at term start.
1059+ if ! rl .appendEntry (pb.Entry {Data : nil }) {
1060+ t .Fatalf ("append noop failed" )
1061+ }
1062+ noopIdx := rl .raftLog .lastIndex () // should be 6
1063+ if noopIdx != rl .raftLog .committed + 1 {
1064+ t .Fatalf ("noop not at k; noopIdx=%d committed=%d" , noopIdx , rl .raftLog .committed )
1065+ }
1066+ // Sanity: hasLeaderApprovedAt(k) should be true for the noop.
1067+ if ! rl .hasLeaderApprovedAt (noopIdx ) {
1068+ t .Fatalf ("expected leader-approved noop at k=%d" , noopIdx )
1069+ }
1070+
1071+ // Pre-cache the leader payload at leader's current k so the decision
1072+ // will use *our* bytes (important for etcd waiter).
1073+ rl .CacheLeaderFastPayload ([]byte ("leader-payload" ), []byte ("cid-x" ))
1074+
1075+ // Record state before attempting fallback.
1076+ lastIdxBefore := rl .raftLog .lastIndex ()
1077+
1078+ // Send the leader's own fast-prop (From=None → normalized to leader).
1079+ msg := pb.Message {
1080+ Type : pb .MsgFastProp ,
1081+ From : None , // local
1082+ Index : 0 , // ignored by leader
1083+ Entries : []pb.Entry {{
1084+ Type : pb .EntryNormal ,
1085+ Data : []byte ("leader-payload" ),
1086+ ContentId : []byte ("cid-x" ),
1087+ }},
1088+ }
1089+ if err := rl .Step (msg ); err != nil {
1090+ t .Fatalf ("leader step: %v" , err )
1091+ }
1092+
1093+ // After the fix:
1094+ // - fallback should *run*, i.e. append the decision (either at k, replacing noop,
1095+ // or at k+1 if your fallback still uses appendEntry), so lastIndex must increase by 1.
1096+ // - proposalCache[k] should be cleared.
1097+ // - fastMatchIndex[leader] should count k.
1098+ lastIdxAfter := rl .raftLog .lastIndex ()
1099+ if lastIdxAfter != lastIdxBefore + 1 {
1100+ t .Fatalf ("fallback did not append decision; lastIdx before=%d after=%d" , lastIdxBefore , lastIdxAfter )
1101+ }
1102+
1103+ // proposalCache for k should be cleared after install.
1104+ if rl .proposalCache [noopIdx ] != nil {
1105+ t .Fatalf ("proposalCache[%d] not cleared" , noopIdx )
1106+ }
1107+
1108+ // Leader should count itself at k toward fast quorum.
1109+ if rl .fastMatchIndex [rl .id ] < noopIdx {
1110+ t .Fatalf ("fastMatchIndex[self]=%d want >= %d" , rl .fastMatchIndex [rl .id ], noopIdx )
1111+ }
1112+
1113+ // Optional: if your fallback installs *exactly* at k (ideal), validate it replaced the noop.
1114+ ents , _ := rl .raftLog .slice (noopIdx , noopIdx + 1 , noLimit )
1115+ if len (ents ) == 1 && len (ents [0 ].Data ) != 0 {
1116+ if string (ents [0 ].Data ) != "leader-payload" || getOrigin (& ents [0 ]) != pb .EntryOriginLeader {
1117+ t .Fatalf ("entry at k not leader-approved payload: %+v" , ents [0 ])
1118+ }
1119+ } else {
1120+ // If you still append at k+1, ensure the leader payload is at lastIdxAfter.
1121+ ents2 , _ := rl .raftLog .slice (lastIdxAfter , lastIdxAfter + 1 , noLimit )
1122+ if len (ents2 ) != 1 || string (ents2 [0 ].Data ) != "leader-payload" || getOrigin (& ents2 [0 ]) != pb .EntryOriginLeader {
1123+ t .Fatalf ("leader decision not appended properly: %+v" , ents2 )
1124+ }
1125+ }
1126+ }
0 commit comments