@@ -748,6 +748,68 @@ void inFlightCommandOnStopIsRecordedAndNotReplayed() throws Exception {
748748 }
749749 }
750750
751+ /**
752+ * A replay still in flight when the executor stops must finish and record its progress durably,
753+ * because closing the log watcher cancels its tasks with an interrupt. Otherwise the command is
754+ * applied to the local data while last_revision is not advanced, and the next start-up applies it
755+ * a second time.
756+ */
757+ @ Test
758+ @ Timeout (120 )
759+ void inFlightReplayOnStopIsRecordedAndNotReplayed () throws Exception {
760+ final CountDownLatch replayEntered = new CountDownLatch (1 );
761+ final CountDownLatch proceed = new CountDownLatch (1 );
762+ final AtomicInteger replayCount = new AtomicInteger ();
763+ final AtomicInteger replicaIndex = new AtomicInteger ();
764+ final Supplier <Function <Command <?>, CompletableFuture <?>>> delegateSupplier = () -> {
765+ final boolean replaying = replicaIndex .getAndIncrement () == 1 ;
766+ final Function <Command <?>, CompletableFuture <?>> base = newMockDelegate ();
767+ return command -> {
768+ if (replaying && command != null && command .type () == CommandType .CREATE_REPOSITORY ) {
769+ // Park inside replayLogs() so we can stop while the replay is in flight.
770+ replayCount .incrementAndGet ();
771+ replayEntered .countDown ();
772+ return CompletableFuture .supplyAsync (() -> {
773+ try {
774+ proceed .await ();
775+ } catch (InterruptedException e ) {
776+ throw new RuntimeException (e );
777+ }
778+ return null ;
779+ }, CommonPools .blockingTaskExecutor ());
780+ }
781+ return base .apply (command );
782+ };
783+ };
784+
785+ try (Cluster cluster = Cluster .builder ().numReplicas (3 ).build (delegateSupplier )) {
786+ final Replica origin = cluster .get (0 );
787+ final Replica replaying = cluster .get (1 );
788+ origin .commandExecutor ().execute (Command .createProject (Author .SYSTEM , "p" )).join ();
789+ await ().untilAsserted (() -> assertThat (replaying .localRevision ()).isEqualTo (0L ));
790+
791+ // Park the replay of revision 1, then stop while it is in flight.
792+ origin .commandExecutor ().execute (Command .createRepository (Author .SYSTEM , "p" , "r" )).join ();
793+ assertThat (replayEntered .await (10 , TimeUnit .SECONDS )).isTrue ();
794+ final CompletableFuture <Void > stopFuture = replaying .commandExecutor ().stop ();
795+ // The shutdown must not finish while the replay is parked; that is the barrier doing its job.
796+ assertThatThrownBy (() -> stopFuture .get (3 , TimeUnit .SECONDS ))
797+ .isInstanceOf (TimeoutException .class );
798+ proceed .countDown ();
799+ stopFuture .join ();
800+
801+ // The replay finished before the log watcher was closed, so its progress is durable.
802+ assertThat (replaying .localRevision ()).isEqualTo (1L );
803+
804+ // Catch up on a later revision to prove revision 1 was not applied a second time.
805+ replaying .commandExecutor ().start ().join ();
806+ origin .commandExecutor ().execute (Command .createProject (Author .SYSTEM , "p2" )).join ();
807+ await ().untilAsserted (() -> assertThat (replaying .localRevision ()).isEqualTo (2L ));
808+ assertThat (replayCount ).hasValue (1 );
809+ assertThat (replaying .commandExecutor ().isWritable ()).isTrue ();
810+ }
811+ }
812+
751813 private static <T > void awaitUntilReplicated (Cluster cluster , Command <T > command ) {
752814 for (int i = 0 ; i < cluster .size (); i ++) {
753815 final Replica replica = cluster .get (i );
0 commit comments