Skip to content

Commit 9e7b7f6

Browse files
authored
Persist local_last_revision in storeLog even while shutting down (#1313)
Motivation: - After a cluster reboot, a replica entered read-only mode while replaying the latest ZooKeeper log: the mirror PushAsIsCommand replayed at a revision whose effect was already in the local data, so it failed with a RedundantChangeException ("changes did not change anything ..."). - Root cause: ZooKeeperCommandExecutor.doStop() sets listenerInfo to null at the very beginning, before draining the in-flight blockingExecute via shutdown(executor). storeLog() persisted local_last_revision only when listenerInfo != null. When a command was being originated at shutdown time, its change had already been applied to local data and the ZooKeeper log node was still created, but the local_last_revision update was silently skipped. This left the local data ahead of both progress files (git HEAD at the new revision while last_revision and local_last_revision lagged by one). - On the next start-up, replay re-executed that already-applied revision. Because the mirror command is an unnormalized PushAsIsCommand whose base revision is Revision.HEAD, re-applying it against the already-advanced head produced an empty diff and threw RedundantChangeException, tripping read-only mode. This was the gap introduced together with local_last_revision in #1303. Modifications: - Decouple the local_last_revision disk write from the listenerInfo guard in storeLog(): always call updateLocalLastAppliedRevision(revision), and gate only the in-memory ListenerInfo.localLastAppliedRevision update on listenerInfo. Result: - A command being originated while the replica is shutting down now durably records its progress in local_last_revision, so the data and the progress files stay consistent and the replica no longer re-applies an already-applied revision (and no longer enters read-only mode) on the next start-up.
1 parent d4c2120 commit 9e7b7f6

2 files changed

Lines changed: 81 additions & 2 deletions

File tree

server/src/main/java/com/linecorp/centraldogma/server/internal/replication/ZooKeeperCommandExecutor.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ private static final class ListenerInfo {
376376
}
377377
}
378378

379+
@Nullable
379380
private volatile ListenerInfo listenerInfo;
380381

381382
public ZooKeeperCommandExecutor(ZooKeeperReplicationConfig cfg,
@@ -1096,9 +1097,13 @@ private long storeLog(ReplicationLog<?> log) {
10961097
revision = revisionFromPath(logPath);
10971098

10981099
final ListenerInfo info = listenerInfo;
1099-
if (info != null && revision > info.localLastAppliedRevision) {
1100+
if (info != null) {
1101+
if (revision > info.localLastAppliedRevision) {
1102+
updateLocalLastAppliedRevision(revision);
1103+
info.localLastAppliedRevision = revision;
1104+
}
1105+
} else if (revision > getLocalLastAppliedRevision()) {
11001106
updateLocalLastAppliedRevision(revision);
1101-
info.localLastAppliedRevision = revision;
11021107
}
11031108
}
11041109

server/src/test/java/com/linecorp/centraldogma/server/internal/replication/ZooKeeperCommandExecutorTest.java

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import java.util.Map;
3737
import java.util.concurrent.CompletableFuture;
3838
import java.util.concurrent.CompletionException;
39+
import java.util.concurrent.CountDownLatch;
3940
import java.util.concurrent.TimeUnit;
4041
import java.util.concurrent.TimeoutException;
4142
import java.util.concurrent.atomic.AtomicBoolean;
@@ -704,6 +705,79 @@ void testLocalLastRevisionParseFailureFailsStartup() throws Exception {
704705
}
705706
}
706707

708+
/**
709+
* Regression test for the shutdown race that left {@code local_last_revision} behind the local data
710+
* (the bug introduced by #1303 and observed as a {@code RedundantChangeException} replay failure).
711+
*
712+
* <p>{@code doStop()} sets {@code listenerInfo} to {@code null} before draining the in-flight
713+
* {@code blockingExecute}. If a command is being originated at that moment, its storage side effect has
714+
* already been applied and {@code storeLog} still creates the ZooKeeper log node — but the
715+
* {@code local_last_revision} update used to be gated on {@code listenerInfo != null} and was therefore
716+
* silently skipped. On the next start-up the replica re-applied that already-applied revision and, for a
717+
* mirror {@link PushAsIsCommand} (whose base revision is {@code HEAD}), failed with a
718+
* {@code RedundantChangeException} and entered read-only mode.
719+
*
720+
* <p>This test parks an originating command inside the delegate, stops the executor so that
721+
* {@code listenerInfo} becomes {@code null}, then lets the command reach {@code storeLog} and asserts
722+
* that {@code local_last_revision} is still persisted.
723+
*/
724+
@Test
725+
void localLastRevisionPersistedWhenStoreLogRacesShutdown() throws Exception {
726+
final CountDownLatch executeEntered = new CountDownLatch(1);
727+
final CountDownLatch proceed = new CountDownLatch(1);
728+
729+
final Supplier<Function<Command<?>, CompletableFuture<?>>> delegateSupplier = () -> {
730+
final Function<Command<?>, CompletableFuture<?>> base = newMockDelegate();
731+
return command -> {
732+
if (command != null && command.type() == CommandType.CREATE_REPOSITORY) {
733+
// Park the origination inside the delegate so that we can stop the executor while this
734+
// command is in flight (after delegate.execute() but before storeLog()).
735+
executeEntered.countDown();
736+
return CompletableFuture.supplyAsync(() -> {
737+
try {
738+
proceed.await();
739+
} catch (InterruptedException e) {
740+
throw new RuntimeException(e);
741+
}
742+
return null;
743+
}, CommonPools.blockingTaskExecutor());
744+
}
745+
return base.apply(command);
746+
};
747+
};
748+
749+
try (Cluster cluster = Cluster.of(delegateSupplier)) {
750+
final Replica self = cluster.get(0); // replicaId=1, the originator we will stop mid-store.
751+
752+
// Warm up with two self-originated commands so local_last_revision advances to 1.
753+
self.commandExecutor().execute(Command.createProject(Author.SYSTEM, "p1")).join();
754+
self.commandExecutor().execute(Command.createProject(Author.SYSTEM, "p2")).join();
755+
await().untilAsserted(() -> assertThat(self.localLastAppliedRevision()).isEqualTo(1L));
756+
757+
// Originate a third command (ZK revision 2). The delegate parks it before storeLog runs.
758+
final CompletableFuture<Void> blocked =
759+
self.commandExecutor().execute(Command.createRepository(Author.SYSTEM, "p1", "r-block"));
760+
assertThat(executeEntered.await(10, TimeUnit.SECONDS)).isTrue();
761+
762+
// Stop the executor. doStop() nulls listenerInfo first, then blocks in shutdown(executor)
763+
// waiting for the parked command. The gauge reads 0 once listenerInfo is null, which is our
764+
// sync point for "the store is now racing a shutdown".
765+
final CompletableFuture<Void> stopFuture = self.commandExecutor().stop();
766+
await().untilAsserted(() -> assertThat(MoreMeters.measureAll(self.meterRegistry()))
767+
.containsEntry("replica.last.local.applied.revision#value", 0.0));
768+
769+
// Let the parked command reach storeLog while listenerInfo is null.
770+
proceed.countDown();
771+
blocked.join();
772+
stopFuture.join();
773+
774+
// storeLog must have persisted local_last_revision to the new revision (2) even though
775+
// listenerInfo was null during the store. Before the fix it stayed at 1, so on the next
776+
// start-up the replica would re-apply revision 2 and enter read-only mode.
777+
assertThat(self.localLastAppliedRevision()).isEqualTo(2L);
778+
}
779+
}
780+
707781
@Test
708782
void testLogMetaSerde() throws JsonProcessingException {
709783
final LogMeta logMeta = new LogMeta(1, 1L, 10, false, false);

0 commit comments

Comments
 (0)