1919
2020import static org .junit .Assert .*;
2121
22+ /**
23+ * Educational tests for Multi-Paxos demonstrating distributed log consensus:
24+ * 1. Leader election and single value consensus
25+ * 2. Multiple values in replicated log
26+ * 3. Leader failure and recovery scenarios
27+ * 4. Log completion after partial failures
28+ */
2229public class MultiPaxosTest extends ClusterTest <MultiPaxos > {
30+
31+ // Educational naming: Greek city-states for MultiPaxos cluster
32+ private MultiPaxos athens ;
33+ private MultiPaxos byzantium ;
34+ private MultiPaxos cyrene ;
35+
2336 @ Before
24- public void setUp () throws IOException {
25- super .nodes = TestUtils .startCluster (nodeNames ("athens" , "byzantium" , "cyrene" ),
26- (name , config , clock , clientConnectionAddress , peerConnectionAddress , peers ) -> new MultiPaxos (name , clock , config , clientConnectionAddress , peerConnectionAddress , peers ));
27-
28- }
29-
30- private static List <String > nodeNames (String ... names ) {
31- return Arrays .asList (names );
37+ public void setupMultiPaxosCluster () throws IOException {
38+ super .nodes = TestUtils .startCluster (
39+ cityStateNames ("athens" , "byzantium" , "cyrene" ),
40+ (name , config , clock , clientConnectionAddress , peerConnectionAddress , peers ) ->
41+ new MultiPaxos (name , clock , config , clientConnectionAddress , peerConnectionAddress , peers )
42+ );
43+
44+ // Cache references for educational clarity
45+ athens = nodes .get ("athens" );
46+ byzantium = nodes .get ("byzantium" );
47+ cyrene = nodes .get ("cyrene" );
3248 }
3349
50+ // ============================================================================
51+ // SCENARIO 1: Basic Leader Election and Single Value Consensus
52+ // ============================================================================
53+
3454 @ Test
35- public void setsSingleValue () throws Exception {
36- var athens = nodes .get ("athens" );
37- athens .leaderElection ();
38- TestUtils .waitUntilTrue (() -> {
39- return athens .isLeader ();
40- }, "Waiting for leader election" , Duration .ofSeconds (2 ));
41-
42- var networkClient = new NetworkClient ();
43- byte [] command = new SetValueCommand ("title" , "Microservices" ).serialize ();
44- var setValueResponse = networkClient .sendAndReceive (new ExecuteCommandRequest (command ), nodes .get ("athens" ).getClientConnectionAddress (), ExecuteCommandResponse .class ).getResult ();
45- assertEquals (Optional .of ("Microservices" ), setValueResponse .getResponse ());
55+ public void demonstratesLeaderElectionAndBasicConsensus () throws Exception {
56+ // GIVEN: A MultiPaxos cluster with no leader
57+ // WHEN: Athens runs leader election
58+ electLeader (athens , "Athens should become leader" );
59+
60+ // THEN: Athens can accept and replicate a single value
61+ String result = executeCommand ("title" , "Microservices" , athens );
62+ assertEquals ("Basic consensus through elected leader should work" ,
63+ "Microservices" , result );
4664 }
4765
4866 @ Test
49- public void singleValueNullPaxosGetTest () throws Exception {
50- var athens = nodes .get ("athens" );
51-
52- athens .leaderElection ();
53- TestUtils .waitUntilTrue (() -> {
54- return athens .isLeader ();
55- }, "Waiting for leader election" , Duration .ofSeconds (2 ));
56-
57- var networkClient = new NetworkClient ();
58- var getValueResponse = networkClient .sendAndReceive (new GetValueRequest ("title" ), nodes .get ("athens" ).getClientConnectionAddress (), GetValueResponse .class ).getResult ();
59- assertEquals (Optional .empty (), getValueResponse .value );
67+ public void handlesEmptyLogQueries () throws Exception {
68+ // GIVEN: An elected leader with empty log
69+ electLeader (athens , "Need leader for queries" );
70+
71+ // WHEN: Client queries non-existent key
72+ // THEN: Should return empty, not error
73+ String result = queryValue ("title" , athens );
74+ assertNull ("Empty log should return null for non-existent keys" , result );
6075 }
6176
77+ // ============================================================================
78+ // SCENARIO 2: Multiple Commands in Replicated Log
79+ // ============================================================================
80+
6281 @ Test
63- public void singleValuePaxosGetTest () throws Exception {
64- var athens = nodes .get ("athens" );
65-
66- athens .leaderElection ();
67- TestUtils .waitUntilTrue (() -> {
68- return athens .isLeader ();
69- }, "Waiting for leader election" , Duration .ofSeconds (2 ));
70-
71- var networkClient = new NetworkClient ();
72- byte [] command = new SetValueCommand ("title" , "Microservices" ).serialize ();
73- var setValueResponse = networkClient .sendAndReceive (new ExecuteCommandRequest (command ), nodes .get ("athens" ).getClientConnectionAddress (), ExecuteCommandResponse .class );
74-
75- var getValueResponse = networkClient .sendAndReceive (new GetValueRequest ("title" ), nodes .get ("athens" ).getClientConnectionAddress (), GetValueResponse .class ).getResult ();
76- assertEquals (Optional .of ("Microservices" ), getValueResponse .value );
82+ public void demonstratesSequentialCommandReplication () throws Exception {
83+ // GIVEN: Athens is the elected leader
84+ electLeader (athens , "Athens leads the cluster" );
85+
86+ // WHEN: Multiple commands are executed in sequence
87+ executeCommand ("title" , "Microservices" , athens );
88+ String result = queryValue ("title" , athens );
89+ assertEquals ("First command should be replicated" , "Microservices" , result );
90+
91+ // THEN: Later queries should return the stored value
92+ String laterResult = queryValue ("title" , athens );
93+ assertEquals ("Value should persist in replicated log" , "Microservices" , laterResult );
7794 }
7895
79-
80- @ Test
81- public void leaderElectionCompletesIncompletePaxosRuns () throws Exception {
82- MultiPaxos athens = nodes .get ("athens" );
83- MultiPaxos byzantium = nodes .get ("byzantium" );
84- MultiPaxos cyrene = nodes .get ("cyrene" );
85-
86- athens .leaderElection ();
87-
88- TestUtils .waitUntilTrue (() -> {
89- return athens .isLeader ();
90- }, "Waiting for leader election" , Duration .ofSeconds (2 ));
91-
96+ // ============================================================================
97+ // SCENARIO 3: Leader Failure and Log Recovery (The Key Educational Scenario)
98+ // This demonstrates the critical Multi-Paxos recovery mechanism
99+ // ============================================================================
100+
101+ @ Test
102+ public void demonstratesLeaderFailureRecoveryAndLogCompletion () throws Exception {
103+ // PHASE 1: Establish Athens as leader and execute first command successfully
104+ LeadershipPhase phase1 = establishInitialLeadership ();
105+
106+ // PHASE 2: Simulate network partition during second command
107+ PartialFailureScenario phase2 = simulatePartialCommandFailure ();
108+
109+ // PHASE 3: New leader election and automatic log completion
110+ LogRecoveryResult phase3 = demonstrateAutomaticLogRecovery ();
111+
112+ // FINAL VERIFICATION: All nodes have consistent, complete log
113+ verifyLogConsistencyAcrossCluster ();
114+ }
115+
116+ // ============================================================================
117+ // Educational Helper Methods - Breaking down complex Multi-Paxos scenarios
118+ // ============================================================================
119+
120+ private LeadershipPhase establishInitialLeadership () throws Exception {
121+ // EDUCATIONAL STEP 1: Athens becomes leader
122+ electLeader (athens , "Athens establishes initial leadership" );
123+
124+ // EDUCATIONAL STEP 2: Successfully replicate first command
125+ String firstResult = executeCommand ("title" , "Microservices" , athens );
126+ assertEquals ("First command should succeed under stable leadership" ,
127+ "Microservices" , firstResult );
128+
129+ // EDUCATIONAL VERIFICATION: All nodes have the first entry
130+ verifyLogSizeAcrossCluster (1 , "After first successful command" );
131+ verifyAllNodesHaveCommittedEntry (0 , "First entry should be committed everywhere" );
132+
133+ return new LeadershipPhase ("Athens" , "Microservices" , true );
134+ }
135+
136+ private PartialFailureScenario simulatePartialCommandFailure () throws Exception {
137+ // EDUCATIONAL SETUP: Create network partition that prevents full replication
138+ athens .dropMessagesTo (byzantium ); // Athens can't reach Byzantium
139+ athens .dropMessagesTo (cyrene ); // Athens can't reach Cyrene
140+
141+ // EDUCATIONAL ATTEMPT: Try to execute second command (should fail)
92142 var networkClient = new NetworkClient ();
93- byte [] command = new SetValueCommand ("title" , "Microservices" ).serialize ();
94- var setValueResponse = networkClient .sendAndReceive (new ExecuteCommandRequest (command ), athens .getClientConnectionAddress (), ExecuteCommandResponse .class );
95-
96- athens .dropMessagesTo (byzantium ); //propose messages fail
97- athens .dropMessagesTo (cyrene ); //propose messages fail
98-
99-
100- command = new SetValueCommand ("author" , "Martin" ).serialize ();
101- var response1 = networkClient .sendAndReceive (new ExecuteCommandRequest (command ), athens .getClientConnectionAddress (), ExecuteCommandResponse .class );
102- assertTrue ("Expected to fail because athens will be unable to reach quorum" , response1 .isError ());
103-
104- assertEquals (2 , athens .paxosLog .size ()); //uncommitted second entry
105- assertEquals (1 , byzantium .paxosLog .size ()); //only first entry.
106- assertEquals (1 , cyrene .paxosLog .size ()); //only first entry.
107-
108- assertTrue (athens .paxosLog .get (0 ).committedValue ().isPresent ());
109- assertTrue (byzantium .paxosLog .get (0 ).committedValue ().isPresent ());
110- assertTrue (cyrene .paxosLog .get (0 ).committedValue ().isPresent ());
111-
112- assertFalse (athens .paxosLog .get (1 ).committedValue ().isPresent ());
113-
143+ byte [] command = new SetValueCommand ("author" , "Martin" ).serialize ();
144+ var response = networkClient .sendAndReceive (
145+ new ExecuteCommandRequest (command ),
146+ athens .getClientConnectionAddress (),
147+ ExecuteCommandResponse .class
148+ );
149+
150+ // EDUCATIONAL ASSERTION: Command should fail due to inability to reach quorum
151+ assertTrue ("Command should fail - Athens cannot reach quorum due to network partition" ,
152+ response .isError ());
153+
154+ // EDUCATIONAL STATE VERIFICATION: Check log states after partial failure
155+ assertEquals ("Athens should have 2 entries (1 committed, 1 pending)" ,
156+ 2 , athens .paxosLog .size ());
157+ assertEquals ("Byzantium should only have 1 entry (the committed one)" ,
158+ 1 , byzantium .paxosLog .size ());
159+ assertEquals ("Cyrene should only have 1 entry (the committed one)" ,
160+ 1 , cyrene .paxosLog .size ());
161+
162+ // EDUCATIONAL KEY INSIGHT: First entry is committed everywhere, second is only on Athens
163+ verifyAllNodesHaveCommittedEntry (0 , "First entry remains committed despite network issues" );
164+ assertFalse ("Athens' second entry should be uncommitted due to network failure" ,
165+ athens .paxosLog .get (1 ).committedValue ().isPresent ());
166+
167+ // EDUCATIONAL VERIFICATION: The failed command value is not accessible
168+ assertNull ("Failed command should not be queryable" , athens .getValue ("author" ));
169+
170+ return new PartialFailureScenario ("author" , "Martin" , false );
171+ }
172+
173+ private LogRecoveryResult demonstrateAutomaticLogRecovery () throws Exception {
174+ // EDUCATIONAL SETUP: Network heals, Byzantium becomes new leader
114175 athens .reconnectTo (byzantium );
115176 athens .reconnectTo (cyrene );
177+
178+ // EDUCATIONAL KEY POINT: New leader election triggers log completion
179+ electLeader (byzantium , "Byzantium becomes new leader and will complete incomplete log entries" );
180+
181+ // EDUCATIONAL VERIFICATION: Log completion should happen automatically
182+ verifyLogSizeAcrossCluster (2 , "New leader should complete all pending log entries" );
183+
184+ // EDUCATIONAL KEY INSIGHT: The previously failed command is now successful
185+ assertEquals ("Previously failed command should now be accessible after log completion" ,
186+ "Martin" , athens .getValue ("author" ));
187+ assertEquals ("All nodes should have the recovered value" ,
188+ "Martin" , byzantium .getValue ("author" ));
189+ assertEquals ("Cyrene should also have the recovered value" ,
190+ "Martin" , cyrene .getValue ("author" ));
191+
192+ return new LogRecoveryResult ("Martin" , 2 , true );
193+ }
194+
195+ private void verifyLogConsistencyAcrossCluster () {
196+ // EDUCATIONAL FINAL CHECK: All nodes should have identical logs
197+ assertEquals ("All nodes should have same log size after recovery" ,
198+ athens .paxosLog .size (), byzantium .paxosLog .size ());
199+ assertEquals ("Athens and Cyrene should have same log size" ,
200+ athens .paxosLog .size (), cyrene .paxosLog .size ());
201+
202+ // EDUCATIONAL VERIFICATION: All values should be accessible from any node
203+ assertEquals ("Title should be accessible from any node" ,
204+ "Microservices" , athens .getValue ("title" ));
205+ assertEquals ("Author should be accessible from any node" ,
206+ "Martin" , byzantium .getValue ("author" ));
207+ }
116208
117- assertNull (athens .getValue ("author" ));
118-
119- //election which is equivalent to prepare phase of basic paxos, checks
120- //and completes pending log entries from majority quorum of the servers.
121- byzantium .leaderElection ();
122- TestUtils .waitUntilTrue (() -> {
123- return byzantium .isLeader ();
124- }, "Waiting for leader election" , Duration .ofSeconds (2 ));
125-
126- assertEquals (2 , athens .paxosLog .size ());
127- assertEquals (2 , byzantium .paxosLog .size ());
128- assertEquals (2 , cyrene .paxosLog .size ());
209+ // ============================================================================
210+ // Educational Utility Methods - Making tests more readable and teachable
211+ // ============================================================================
212+
213+ private void electLeader (MultiPaxos node , String educationalContext ) throws Exception {
214+ node .leaderElection ();
215+ TestUtils .waitUntilTrue (() -> node .isLeader (),
216+ "Waiting for leader election: " + educationalContext ,
217+ Duration .ofSeconds (2 ));
218+ assertTrue (educationalContext + " - Node should be leader after election" ,
219+ node .isLeader ());
220+ }
221+
222+ private String executeCommand (String key , String value , MultiPaxos leader ) throws Exception {
223+ var networkClient = new NetworkClient ();
224+ byte [] command = new SetValueCommand (key , value ).serialize ();
225+ var response = networkClient .sendAndReceive (
226+ new ExecuteCommandRequest (command ),
227+ leader .getClientConnectionAddress (),
228+ ExecuteCommandResponse .class
229+ ).getResult ();
230+
231+ return response .getResponse ().orElse (null );
232+ }
233+
234+ private String queryValue (String key , MultiPaxos node ) throws Exception {
235+ var networkClient = new NetworkClient ();
236+ var response = networkClient .sendAndReceive (
237+ new GetValueRequest (key ),
238+ node .getClientConnectionAddress (),
239+ GetValueResponse .class
240+ ).getResult ();
241+
242+ return response .value .orElse (null );
243+ }
244+
245+ private void verifyLogSizeAcrossCluster (int expectedSize , String context ) {
246+ assertEquals (context + " - Athens log size" , expectedSize , athens .paxosLog .size ());
247+ assertEquals (context + " - Byzantium log size" , expectedSize , byzantium .paxosLog .size ());
248+ assertEquals (context + " - Cyrene log size" , expectedSize , cyrene .paxosLog .size ());
249+ }
250+
251+ private void verifyAllNodesHaveCommittedEntry (int index , String context ) {
252+ assertTrue (context + " - Athens entry " + index + " should be committed" ,
253+ athens .paxosLog .get (index ).committedValue ().isPresent ());
254+ assertTrue (context + " - Byzantium entry " + index + " should be committed" ,
255+ byzantium .paxosLog .get (index ).committedValue ().isPresent ());
256+ assertTrue (context + " - Cyrene entry " + index + " should be committed" ,
257+ cyrene .paxosLog .get (index ).committedValue ().isPresent ());
258+ }
129259
130- assertEquals ("Martin" , athens .getValue ("author" ));
131- assertEquals ("Martin" , byzantium .getValue ("author" ));
132- assertEquals ("Martin" , cyrene .getValue ("author" ));
260+ // ============================================================================
261+ // Educational Data Classes - Making test results more structured and readable
262+ // ============================================================================
263+
264+ private static class LeadershipPhase {
265+ final String leader ;
266+ final String firstValue ;
267+ final boolean successful ;
268+
269+ LeadershipPhase (String leader , String firstValue , boolean successful ) {
270+ this .leader = leader ;
271+ this .firstValue = firstValue ;
272+ this .successful = successful ;
273+ }
274+ }
275+
276+ private static class PartialFailureScenario {
277+ final String attemptedKey ;
278+ final String attemptedValue ;
279+ final boolean succeeded ;
280+
281+ PartialFailureScenario (String attemptedKey , String attemptedValue , boolean succeeded ) {
282+ this .attemptedKey = attemptedKey ;
283+ this .attemptedValue = attemptedValue ;
284+ this .succeeded = succeeded ;
285+ }
286+ }
287+
288+ private static class LogRecoveryResult {
289+ final String recoveredValue ;
290+ final int finalLogSize ;
291+ final boolean recoverySuccessful ;
292+
293+ LogRecoveryResult (String recoveredValue , int finalLogSize , boolean recoverySuccessful ) {
294+ this .recoveredValue = recoveredValue ;
295+ this .finalLogSize = finalLogSize ;
296+ this .recoverySuccessful = recoverySuccessful ;
297+ }
298+ }
299+
300+ private static List <String > cityStateNames (String ... names ) {
301+ return Arrays .asList (names );
133302 }
134303}
0 commit comments