Skip to content

Commit 045403d

Browse files
authored
Scope replication-failure read-only to repo/project, not the whole server (#1305)
Motivation: Central Dogma is a multi-tenant store: a single replica hosts many projects and repositories belonging to unrelated tenants. When a follower fails to replay a single log (state divergence, lock acquisition timeout, or transient I/O error), the existing failure path puts the whole server into read-only mode. A problem in one repository then propagates to every other tenant on that replica: writes stop everywhere until an operator investigates and lifts the server-wide read-only state. The goal of this change is to shrink that blast radius. A replay failure flips only the smallest enclosing scope (repository, project, or server) to read-only, and the executor keeps replicating unrelated logs for everyone else. The read-only state is persisted so it survives restarts — in `dogma/dogma` for repo/project scope, and in a properties file for server scope. The existing administrative API can lift it once the operator has investigated. Modifications: - Add `UpdateProjectStatusCommand` and `UpdateRepositoryStatusCommand`, routed through the same replication pipeline as ordinary commands so scope changes converge across the cluster. Their effect lands in `dogma/dogma/status/{project}/{repo}.json` — the same storage as ordinary commands, intentionally: a replay failure of the status update itself is then treated as a `dogma/dogma` failure and escalates to server scope. - Carry `commandType`, `projectName`, and `repoName` on every `LogMeta`. - Add `ReplicationLogContext` attached to `ReplicationException` so `ZooKeeperCommandExecutor.handleReplicationFailure()` can pick the read-only scope: - null project name (root commands or `dogma/dogma`) → server - non-null project name with null repo (or the dogma metadata repo) → project - otherwise → repository - Add `RepoStatusManager` to manage the read-only status of projects and repositories; remove `MetadataService.updateRepositoryStatus()` and migrate callers. - On a replay error, skip past the failing log (so subsequent unrelated logs keep replaying) and issue the scope-appropriate `Update(Project|Repository)StatusCommand`. Fall back to a server-wide read-only state only if recovery itself fails. - Wire `StandaloneCommandExecutor` to consult `RepoStatusManager` on every push, so a per-repo / per-project read-only state rejects writes with `ReadOnlyException` before reaching the storage layer. - Rename `DefaultCrud*` → `ReplicatingCrud*` and introduce `StandaloneCrud*` to bypass replication when the standalone executor applies state updates directly. - Move `ServerStatusManager` to the internal package. - Add `ReplicationStatus` to describe the status of projects and repositories, and migrate `RepositoryStatus` in `RepositoryDto` to `ReplicationStatus`. Result: - A replication failure no longer puts the whole server into read-only mode; only the affected project or repository becomes read-only.
1 parent b0011fd commit 045403d

52 files changed

Lines changed: 2683 additions & 645 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,5 +141,11 @@ typings/
141141
# macOS folder meta-data
142142
.DS_Store
143143

144+
# Claude
145+
.claude/
146+
144147
# Codex
145148
AGENTS.md
149+
150+
# IntelliJ AI agent
151+
.junie/
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright 2026 LY Corporation
3+
*
4+
* LY Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package com.linecorp.centraldogma.common;
18+
19+
/**
20+
* The replication status of a project or repository. It indicates whether the project or repository is active
21+
* and writable or in read-only mode.
22+
*/
23+
public enum ReplicationStatus {
24+
25+
/**
26+
* The project or repository is active and writable. It can accept write operations and is being replicated
27+
* to other nodes.
28+
*/
29+
WRITABLE,
30+
31+
/**
32+
* The project or repository is in read-only mode. It cannot accept write operations and is not being
33+
* replicated to other nodes.
34+
*/
35+
READ_ONLY
36+
}

common/src/main/java/com/linecorp/centraldogma/common/RepositoryStatus.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717

1818
/**
1919
* The status of a repository.
20+
*
21+
* @deprecated Use {@link ReplicationStatus} instead.
2022
*/
23+
// TODO(ikhoon): Remove this enum in the future.
24+
@Deprecated
2125
public enum RepositoryStatus {
2226

2327
/**

common/src/main/java/com/linecorp/centraldogma/common/jsonpatch/JsonPatchOperation.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,9 @@ public static ReplaceOperation replace(String path, JsonNode value) {
229229
/**
230230
* Creates a new JSON Patch {@code safeReplace} operation.
231231
*
232+
* <p>This operation is similar to {@link #replace(JsonPointer, JsonNode)}, but it throws an error if the
233+
* path does not have the expected value.
234+
*
232235
* @param path the JSON Pointer for this operation
233236
* @param oldValue the old value to replace
234237
* @param newValue the new value to replace the old value
@@ -240,6 +243,9 @@ public static SafeReplaceOperation safeReplace(JsonPointer path, JsonNode oldVal
240243
/**
241244
* Creates a new JSON Patch {@code safeReplace} operation.
242245
*
246+
* <p>This operation is similar to {@link #replace(String, JsonNode)}, but it throws an error if the
247+
* path does not have the expected value.
248+
*
243249
* @param path the JSON Pointer for this operation
244250
* @param oldValue the old value to replace
245251
* @param newValue the new value to replace the old value

common/src/main/java/com/linecorp/centraldogma/internal/api/v1/RepositoryDto.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
import com.google.common.base.MoreObjects;
3333

3434
import com.linecorp.centraldogma.common.Author;
35-
import com.linecorp.centraldogma.common.RepositoryStatus;
35+
import com.linecorp.centraldogma.common.ReplicationStatus;
3636
import com.linecorp.centraldogma.common.Revision;
3737

3838
@JsonInclude(Include.NON_NULL)
@@ -58,7 +58,7 @@ public static RepositoryDto removed(String name) {
5858
private final String createdAt;
5959

6060
@Nullable
61-
private final RepositoryStatus status;
61+
private final ReplicationStatus status;
6262

6363
RepositoryDto(String name) {
6464
this.name = requireNonNull(name, "name");
@@ -70,7 +70,7 @@ public static RepositoryDto removed(String name) {
7070
}
7171

7272
public RepositoryDto(String projectName, String repoName, Author creator, Revision headRevision,
73-
long creationTimeMillis, RepositoryStatus status) {
73+
long creationTimeMillis, ReplicationStatus status) {
7474
this(requireNonNull(repoName, "repoName"), requireNonNull(creator, "creator"),
7575
requireNonNull(headRevision, "headRevision"),
7676
PROJECTS_PREFIX + '/' + requireNonNull(projectName, "projectName") + REPOS + '/' + repoName,
@@ -83,7 +83,7 @@ public RepositoryDto(@JsonProperty("name") String name,
8383
@JsonProperty("headRevision") @Nullable Revision headRevision,
8484
@JsonProperty("url") @Nullable String url,
8585
@JsonProperty("createdAt") @Nullable String createdAt,
86-
@JsonProperty("status") @Nullable RepositoryStatus status) {
86+
@JsonProperty("status") @Nullable ReplicationStatus status) {
8787
this.name = requireNonNull(name, "name");
8888
this.creator = creator;
8989
this.headRevision = headRevision;
@@ -123,7 +123,7 @@ public String createdAt() {
123123

124124
@Nullable
125125
@JsonProperty("status")
126-
public RepositoryStatus status() {
126+
public ReplicationStatus status() {
127127
return status;
128128
}
129129

server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -165,17 +165,18 @@
165165
import com.linecorp.centraldogma.server.internal.api.sysadmin.MirrorAccessControlService;
166166
import com.linecorp.centraldogma.server.internal.api.sysadmin.ServerStatusService;
167167
import com.linecorp.centraldogma.server.internal.api.variable.VariableServiceV1;
168+
import com.linecorp.centraldogma.server.internal.management.RepoStatusManager;
169+
import com.linecorp.centraldogma.server.internal.management.ServerStatusManager;
168170
import com.linecorp.centraldogma.server.internal.mirror.DefaultMirrorAccessController;
169171
import com.linecorp.centraldogma.server.internal.mirror.DefaultMirroringServicePlugin;
170172
import com.linecorp.centraldogma.server.internal.mirror.MirrorAccessControl;
171173
import com.linecorp.centraldogma.server.internal.mirror.MirrorRunner;
172174
import com.linecorp.centraldogma.server.internal.replication.ZooKeeperCommandExecutor;
173175
import com.linecorp.centraldogma.server.internal.storage.project.DefaultProjectManager;
174176
import com.linecorp.centraldogma.server.internal.storage.project.ProjectApiManager;
175-
import com.linecorp.centraldogma.server.internal.storage.repository.CrudRepository;
176-
import com.linecorp.centraldogma.server.internal.storage.repository.git.GitCrudRepository;
177+
import com.linecorp.centraldogma.server.internal.storage.repository.crud.CrudRepository;
178+
import com.linecorp.centraldogma.server.internal.storage.repository.crud.ReplicatingCrudRepository;
177179
import com.linecorp.centraldogma.server.management.ServerStatus;
178-
import com.linecorp.centraldogma.server.management.ServerStatusManager;
179180
import com.linecorp.centraldogma.server.metadata.MetadataService;
180181
import com.linecorp.centraldogma.server.mirror.MirrorProvider;
181182
import com.linecorp.centraldogma.server.mirror.MirroringServicePluginConfig;
@@ -304,6 +305,8 @@ public static CentralDogma forConfig(File configFile) throws IOException {
304305
@Nullable
305306
private ServerStatusManager statusManager;
306307
@Nullable
308+
private RepoStatusManager repoStatusManager;
309+
@Nullable
307310
private InternalProjectInitializer projectInitializer;
308311
@Nullable
309312
private volatile MirrorRunner mirrorRunner;
@@ -620,20 +623,22 @@ private CommandExecutor startCommandExecutor(
620623
}
621624

622625
statusManager = new ServerStatusManager(cfg.dataDir());
626+
repoStatusManager = new RepoStatusManager(statusManager, pm);
623627
logger.info("Startup mode: {}", statusManager.serverStatus());
624628
final CommandExecutor executor;
625629
final ReplicationMethod replicationMethod = cfg.replicationConfig().method();
626630
switch (replicationMethod) {
627631
case ZOOKEEPER:
628-
executor = newZooKeeperCommandExecutor(pm, repositoryWorker, statusManager, meterRegistry,
632+
executor = newZooKeeperCommandExecutor(pm, repositoryWorker, statusManager, repoStatusManager,
633+
meterRegistry,
629634
sessionManager, encryptionStorageManager,
630635
onTakeLeadership, onReleaseLeadership,
631636
onTakeZoneLeadership, onReleaseZoneLeadership);
632637
break;
633638
case NONE:
634639
logger.info("No replication mechanism specified; entering standalone");
635-
executor = new StandaloneCommandExecutor(pm, repositoryWorker, statusManager, sessionManager,
636-
encryptionStorageManager,
640+
executor = new StandaloneCommandExecutor(pm, repositoryWorker, statusManager, repoStatusManager,
641+
sessionManager, encryptionStorageManager,
637642
onTakeLeadership, onReleaseLeadership,
638643
onTakeZoneLeadership, onReleaseZoneLeadership);
639644
break;
@@ -647,6 +652,7 @@ private CommandExecutor startCommandExecutor(
647652
executor.setWritable(initialServerStatus.writable());
648653
if (!initialServerStatus.replicating()) {
649654
projectInitializer.initializeInReadOnlyMode();
655+
repoStatusManager.initialize();
650656
setMirrorAccessControllerRepository(pm, executor);
651657
return executor;
652658
}
@@ -669,19 +675,21 @@ private CommandExecutor startCommandExecutor(
669675
// Trigger the exception if any.
670676
startFuture.get();
671677
projectInitializer.initialize();
678+
repoStatusManager.initialize();
672679
} catch (Exception e) {
673680
logger.warn("Failed to start the command executor. Entering read-only.", e);
674681
projectInitializer.initializeInReadOnlyMode();
682+
repoStatusManager.initialize();
675683
}
676684
setMirrorAccessControllerRepository(pm, executor);
677685
return executor;
678686
}
679687

680688
private void setMirrorAccessControllerRepository(ProjectManager pm, CommandExecutor executor) {
681689
final CrudRepository<MirrorAccessControl> accessControlRepository =
682-
new GitCrudRepository<>(MirrorAccessControl.class, executor, pm,
683-
INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA,
684-
MIRROR_ACCESS_CONTROL_PATH);
690+
new ReplicatingCrudRepository<>(MirrorAccessControl.class, executor, pm,
691+
INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA,
692+
MIRROR_ACCESS_CONTROL_PATH);
685693
mirrorAccessController.setRepository(accessControlRepository);
686694
}
687695

@@ -924,6 +932,7 @@ private AuthProvider createAuthProvider(
924932
private CommandExecutor newZooKeeperCommandExecutor(
925933
ProjectManager pm, Executor repositoryWorker,
926934
ServerStatusManager serverStatusManager,
935+
RepoStatusManager repoStatusManager,
927936
MeterRegistry meterRegistry,
928937
@Nullable SessionManager sessionManager,
929938
EncryptionStorageManager encryptionStorageManager,
@@ -945,8 +954,8 @@ private CommandExecutor newZooKeeperCommandExecutor(
945954
// so that we can recover from ZooKeeper maintenance automatically.
946955
return new ZooKeeperCommandExecutor(
947956
zkCfg, dataDir,
948-
new StandaloneCommandExecutor(pm, repositoryWorker, serverStatusManager, sessionManager,
949-
encryptionStorageManager,
957+
new StandaloneCommandExecutor(pm, repositoryWorker, serverStatusManager, repoStatusManager,
958+
sessionManager, encryptionStorageManager,
950959
/* onTakeLeadership */ null, /* onReleaseLeadership */ null,
951960
/* onTakeZoneLeadership */ null, /* onReleaseZoneLeadership */ null),
952961
meterRegistry, zone,
@@ -1008,12 +1017,13 @@ private void configureHttpApi(ServerBuilder sb,
10081017
decoratorBuilder.build(decorator);
10091018
}
10101019

1011-
assert statusManager != null;
1020+
assert statusManager != null && repoStatusManager != null;
10121021
final ContextPathServicesBuilder apiV1ServiceBuilder = sb.contextPath(API_V1_PATH_PREFIX);
10131022
apiV1ServiceBuilder
10141023
.annotatedService(new ServerStatusService(executor, statusManager))
10151024
.annotatedService(new ProjectServiceV1(projectApiManager, executor))
1016-
.annotatedService(new RepositoryServiceV1(executor, mds, encryptionStorageManager))
1025+
.annotatedService(new RepositoryServiceV1(executor, mds, encryptionStorageManager,
1026+
repoStatusManager))
10171027
.annotatedService(new CredentialServiceV1(projectApiManager, executor))
10181028
.annotatedService(new VariableServiceV1(pm, executor));
10191029
if (LOGBACK_ENABLED) {
@@ -1241,6 +1251,7 @@ private void doStop() {
12411251
final ExecutorService purgeWorker = this.purgeWorker;
12421252
final SessionManager sessionManager = this.sessionManager;
12431253
final MirrorRunner mirrorRunner = this.mirrorRunner;
1254+
final ServerStatusManager statusManager = this.statusManager;
12441255

12451256
this.server = null;
12461257
this.executor = null;
@@ -1249,13 +1260,18 @@ private void doStop() {
12491260
this.repositoryWorker = null;
12501261
this.sessionManager = null;
12511262
this.mirrorRunner = null;
1263+
this.statusManager = null;
12521264
if (meterRegistryToBeClosed != null) {
12531265
assert meterRegistry instanceof CompositeMeterRegistry;
12541266
((CompositeMeterRegistry) meterRegistry).remove(meterRegistryToBeClosed);
12551267
meterRegistryToBeClosed.close();
12561268
meterRegistryToBeClosed = null;
12571269
}
12581270

1271+
if (statusManager != null) {
1272+
statusManager.close();
1273+
}
1274+
12591275
logger.info("Stopping the Central Dogma ..");
12601276
if (!doStop(server, executor, pm, repositoryWorker, purgeWorker, sessionManager, mirrorRunner,
12611277
encryptionStorageManager)) {

server/src/main/java/com/linecorp/centraldogma/server/command/Command.java

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import com.linecorp.centraldogma.common.Author;
3131
import com.linecorp.centraldogma.common.Change;
3232
import com.linecorp.centraldogma.common.Markup;
33+
import com.linecorp.centraldogma.common.ReplicationStatus;
3334
import com.linecorp.centraldogma.common.Revision;
3435
import com.linecorp.centraldogma.server.EncryptionConfig;
3536
import com.linecorp.centraldogma.server.auth.Session;
@@ -64,6 +65,8 @@
6465
@Type(value = RemoveSessionCommand.class, name = "REMOVE_SESSIONS"),
6566
@Type(value = CreateSessionMasterKeyCommand.class, name = "CREATE_SESSION_MASTER_KEY"),
6667
@Type(value = UpdateServerStatusCommand.class, name = "UPDATE_SERVER_STATUS"),
68+
@Type(value = UpdateProjectStatusCommand.class, name = "UPDATE_PROJECT_STATUS"),
69+
@Type(value = UpdateRepositoryStatusCommand.class, name = "UPDATE_REPOSITORY_STATUS"),
6770
@Type(value = ForcePushCommand.class, name = "FORCE_PUSH_COMMAND"),
6871
})
6972
public interface Command<T> {
@@ -331,8 +334,8 @@ static Command<Void> migrateToEncryptedRepository(@Nullable Long timestamp, Auth
331334
* @param changes the changes to be applied
332335
*/
333336
static Command<Revision> push(Author author, String projectName, String repositoryName,
334-
Revision baseRevision, String summary, String detail,
335-
Markup markup, Change<?>... changes) {
337+
Revision baseRevision, String summary, String detail,
338+
Markup markup, Change<?>... changes) {
336339

337340
return push(null, author, projectName, repositoryName, baseRevision, summary, detail, markup, changes);
338341
}
@@ -478,6 +481,34 @@ static Command<Void> updateServerStatus(ServerStatus serverStatus) {
478481
return new UpdateServerStatusCommand(null, null, serverStatus);
479482
}
480483

484+
/**
485+
* Returns a new {@link Command} which is used to update the status of a project.
486+
*/
487+
static Command<Void> updateProjectStatus(String projectName, ReplicationStatus projectStatus) {
488+
requireNonNull(projectName, "projectName");
489+
requireNonNull(projectStatus, "projectStatus");
490+
return new UpdateProjectStatusCommand(null, null, projectName, projectStatus);
491+
}
492+
493+
/**
494+
* Returns a new {@link Command} which is used to update the status of a repository.
495+
*/
496+
static Command<Void> updateRepositoryStatus(String projectName, String repositoryName,
497+
ReplicationStatus replicationStatus) {
498+
return updateRepositoryStatus(projectName, repositoryName, Author.SYSTEM, replicationStatus);
499+
}
500+
501+
/**
502+
* Returns a new {@link Command} which is used to update the status of a repository.
503+
*/
504+
static Command<Void> updateRepositoryStatus(String projectName, String repositoryName, Author author,
505+
ReplicationStatus replicationStatus) {
506+
requireNonNull(projectName, "projectName");
507+
requireNonNull(repositoryName, "repositoryName");
508+
requireNonNull(replicationStatus, "replicationStatus");
509+
return new UpdateRepositoryStatusCommand(null, author, projectName, repositoryName, replicationStatus);
510+
}
511+
481512
/**
482513
* Returns a new {@link Command} which is used to force-push {@link Command} even the server is in
483514
* read-only mode. This command is useful for migrating the repository content during maintenance mode.

server/src/main/java/com/linecorp/centraldogma/server/command/CommandExecutorStatusManager.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121

2222
import com.google.common.base.MoreObjects;
2323

24+
import com.linecorp.centraldogma.server.internal.management.ServerStatusManager;
2425
import com.linecorp.centraldogma.server.management.ServerStatus;
25-
import com.linecorp.centraldogma.server.management.ServerStatusManager;
2626

2727
/**
2828
* Manages the status of a {@link CommandExecutor}.

server/src/main/java/com/linecorp/centraldogma/server/command/CommandType.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ public enum CommandType {
4747
PURGE_PROJECT(Void.class),
4848
PURGE_REPOSITORY(Void.class),
4949
UPDATE_SERVER_STATUS(Void.class),
50+
UPDATE_PROJECT_STATUS(Void.class),
51+
UPDATE_REPOSITORY_STATUS(Void.class),
5052
// The result type of FORCE_PUSH is Object because it can be any type.
5153
FORCE_PUSH(Object.class);
5254

server/src/main/java/com/linecorp/centraldogma/server/command/ProjectCommand.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
*
3131
* @param <T> the result type of a {@link Command}
3232
*/
33-
abstract class ProjectCommand<T> extends AbstractCommand<T> {
33+
public abstract class ProjectCommand<T> extends AbstractCommand<T> {
3434

3535
private final String projectName;
3636

@@ -40,6 +40,9 @@ abstract class ProjectCommand<T> extends AbstractCommand<T> {
4040
this.projectName = requireNonNull(projectName, "projectName");
4141
}
4242

43+
/**
44+
* Returns the project name.
45+
*/
4346
@JsonProperty
4447
public final String projectName() {
4548
return projectName;

0 commit comments

Comments
 (0)